A LangChain deep agent remembers how every page looks, checks each text color against WCAG contrast in code, finds the sections that drift from your site’s design, and drafts the fixes for you to approve.
import type { SiteClient } from '@fetchski/attract-cms-sdk';import type { BaseChatModel } from '@langchain/core/language_models/chat_models';import { MemorySaver } from '@langchain/langgraph';import { ChatOpenRouter } from '@langchain/openrouter';import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';import { todoListMiddleware } from 'langchain';import { type Run, siteTools } from './tools.ts';const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgroundsand button styles on every page, with text people can read. Different pages were built at differenttimes, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.Your long-term memory is /memories/, kept between runs:- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the owner's choices. It is loaded into your instructions at the start of every run.- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each one, and knows from it whether the page changed since.Work in this order:1. Plan your work with write_todos.2. Call read_site_design once, and list_pages once.3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a time: give the id, title and memory file of each. Unchanged pages already have a current memory file; pages marked "next run" wait for a later run, so leave them out of this one.4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from read_site_design, which colors the site changed, the fonts, and the patterns most page files share: section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each outlier with its page, section path and field: a hex color off the palette where the site uses a palette token, a font or font override no other page uses, a button variant or corner unlike the rest, custom CSS that sets colors or fonts the palette already has.`;const FIX = `7. Where the site has two patterns and you can't tell which one is intended (two button corners, two eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the answer into /memories/site.md, so no later run asks again.8. For each page with fixes a section setting can make (a one-off color back to its palette token, a button to the site's pattern, a text color that fails contrast), call save_style_changes once with every change on that page. The owner approves each page. If the owner sends one back with notes, change it to match and call save_style_changes again; if they skip one, don't try again. Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs andbetween list items.`;const REPORT_ONLY = `7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).`;const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,then write its memory file (the path you are given) with write_file, in this shape:# <title>id: <id>updated: <updated, copied exactly>Page: <its own colors and settings, or none>## <section path> (<section type>)- background: dark <value>, light <value>- <field>: dark <value>, light <value> (one line per color; write "default" for '')- settings: <field> <value>, <field> <value> (every setting)- custom CSS: <the CSS, or none>Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you aregiven with read_file. In each section, call check_contrast for every color that colors text or an icon(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against thesection's background: the dark value in the dark theme, the light value in the light theme. A solidbutton's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui foricons and borders, normal for the rest. Skip colors that are "default": they follow the palette.Write the findings file you are told, one line per failing pair:- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with onlythe file's path.`;export interface AgentOptions { /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */ reportOnly?: boolean;}/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */export function buildAgent( site: SiteClient, model: BaseChatModel, run: Run, log: (line: string) => void, { reportOnly = false }: AgentOptions = {}) { const tools = siteTools(site, run, log); return createDeepAgent({ model, systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX), tools: reportOnly ? [tools.readSiteDesign, tools.listPages] : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges], middleware: [todoListMiddleware()], subagents: [ { name: 'style-collector', description: "Reads a batch of pages' styles and writes one memory file per page.", systemPrompt: STYLE_COLLECTOR, tools: [tools.readPageStyle] }, { name: 'contrast-checker', description: 'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.', systemPrompt: CONTRAST_CHECKER, tools: [tools.checkContrast] } ], // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives // for one run. site.md is loaded into the agent's instructions at the start of each run. backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }), memory: ['/memories/site.md'], // Nothing is written without the owner: approve it, edit it, or reject it. interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } }, checkpointer: new MemorySaver() });}/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */export function modelFromEnv() { return new ChatOpenRouter({ model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest', temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0), maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000) });}
import { resolve } from 'node:path';import type { SiteClient } from '@fetchski/attract-cms-sdk';import { interrupt } from '@langchain/langgraph';import { FilesystemBackend } from 'deepagents';import { tool } from 'langchain';import { z } from 'zod';import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';import { checkContrast, paletteContrast } from './design.ts';import { compareWithMemory, type PageStatus } from './memory.ts';/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */export interface Run { memories: FilesystemBackend; pages: PageStatus[]; saved: Map<string, string>;}/** * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the * Deep Agents FilesystemBackend the agent sees as /memories/. */export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run { const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true }); return { memories, pages: [], saved: new Map() };}/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) { // Site design is read once a run, by whichever tool needs it first. let design: ReturnType<typeof readSiteDesign> | undefined; const siteDesign = () => { design ??= readSiteDesign(site); return design; }; const listPagesTool = tool( async () => { const { pages, removed } = await compareWithMemory(run.memories, await listPages(site)); run.pages = pages; const count = (status: string) => pages.filter((page) => page.status === status).length; log( `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.` ); if (count('next run') > 0) { log(`${count('next run')} more wait for the next run (MAX_PAGES).`); } if (removed.length > 0) { log(`Forgot ${removed.length} page(s) that no longer exist.`); } return JSON.stringify(pages.map(({ updatedAt, ...page }) => page)); }, { name: 'list_pages', description: 'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.', schema: z.object({}) } ); const readSiteDesignTool = tool( async () => { const { palette, customized, fonts, customCss } = await siteDesign(); log('Read Site design.'); return JSON.stringify({ palette, customized, paletteContrast: paletteContrast(palette), fonts, customCss }); }, { name: 'read_site_design', description: 'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' + 'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.', schema: z.object({}) } ); const readPageStyleTool = tool( async (args) => { const style = await readPageStyle(site, args.id); log(`Read the style of "${style.title}".`); // The page's last-modified time, for its memory file: the next run compares it with the page's. const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? ''; return JSON.stringify({ updated, ...style }); }, { name: 'read_page_style', description: "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " + "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.", schema: z.object({ id: z.string().describe('The id from list_pages') }) } ); const checkContrastTool = tool( async (args) => { const { palette } = await siteDesign(); return JSON.stringify( checkContrast(args.text, args.background, args.theme, args.size, palette) ); }, { name: 'check_contrast', description: 'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' + 'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' + 'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.', schema: z.object({ text: z.string().describe('The text (or icon) color'), background: z.string().describe('The background it sits on'), theme: z.enum(['dark', 'light']), size: z .enum(['normal', 'large', 'ui']) .describe('large: headings and big titles; ui: icons and borders') }) } ); const saveStyleChangesTool = tool( async (args) => { const answer = await saveStyleChanges(site, args.id, args.changes); if (answer.startsWith('Saved')) { run.saved.set(args.id, args.title); } return answer; }, { name: 'save_style_changes', description: "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " + 'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' + '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.', schema: z.object({ id: z.string(), title: z.string().describe("The page's title, shown to the owner"), changes: z .array(z.object({ path: z.string(), before: z.string(), after: z.string() })) .min(1) .describe('Every change to this page, each at its path') }) } ); const askOwner = tool( // Pauses the run until the owner answers; their answer is the tool's result. async (args) => String(interrupt({ question: args.question })), { name: 'ask_owner', description: 'Asks the website owner one question, and waits for their answer.', schema: z.object({ question: z.string() }) } ); return { listPages: listPagesTool, readSiteDesign: readSiteDesignTool, readPageStyle: readPageStyleTool, checkContrast: checkContrastTool, saveStyleChanges: saveStyleChangesTool, askOwner };}
/** The site's two themes. Dark is the default; visitors can switch to light. */export type Theme = 'dark' | 'light';/** Each palette token's color in each theme, as `#rrggbb`. */export type Palette = Record<Theme, Record<string, string>>;/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */export type Size = 'normal' | 'large' | 'ui';// The colors every site starts with, per palette token: [dark, light]. Site design → Colors// overrides them; a field left empty there keeps these.const BUILT_IN: Record<string, [string, string]> = { accent: ['#625bff', '#544dee'], 'on-accent': ['#ffffff', '#ffffff'], mint: ['#34ddb3', '#0fa380'], coral: ['#ff8e63', '#d8551f'], surface: ['#09090b', '#e4e4e7'], raised: ['#18181b', '#d9d9de'], sunken: ['#0e0e11', '#cfcfd6'], hill: ['#121215', '#d8d8dd'], ink: ['#f4f4f5', '#18181b'], 'ink-muted': ['#a1a1aa', '#52525b'], success: ['#34d399', '#059669'], warning: ['#fbbf24', '#d97706'], danger: ['#f87171', '#dc2626'], info: ['#60a5fa', '#2563eb']};// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.const PALETTE_FIELDS = { paletteAccent: 'accent', paletteOnAccent: 'on-accent', paletteSecondary: 'mint', paletteTertiary: 'coral', paletteLink: 'link', paletteSurface: 'surface', paletteRaised: 'raised', paletteSunken: 'sunken', paletteInk: 'ink', paletteInkMuted: 'ink-muted', paletteSuccess: 'success', paletteWarning: 'warning', paletteDanger: 'danger', paletteInfo: 'info'} as const;const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [ field, `${field}Light`]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];// The text and background pairs every page relies on: [text token, background token].const PALETTE_PAIRS: [string, string][] = [ ['ink', 'surface'], ['ink', 'raised'], ['ink-muted', 'surface'], ['on-accent', 'accent'], ['link', 'surface']];/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */function hex(value: string): string | null { const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim()); if (!match?.[1]) { return null; } const digits = match[1].slice(0, 6); const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits; return `#${full.toLowerCase()}`;}/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */function blend(top: string, bottom: string, share: number): string { const channels = (color: string) => [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16)); const [a, b] = [channels(top), channels(bottom)]; const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share))); return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;}/** The palette the site renders, from Global's palette fields over the built-in colors. */export function resolvePalette(fields: Record<string, unknown>): Palette { const palette: Palette = { dark: {}, light: {} }; const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null); for (const [field, token] of Object.entries(PALETTE_FIELDS)) { const [darkDefault, lightDefault] = BUILT_IN[token] ?? []; const dark = set(field) ?? darkDefault; const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault; if (dark) palette.dark[token] = dark; if (light) palette.light[token] = light; } for (const theme of ['dark', 'light'] as const) { const colors = palette[theme]; colors.link ??= colors.accent ?? ''; colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? ''; // The soft accent tint is the accent, see-through, over the page. colors['accent-soft'] = blend( colors.accent ?? '', colors.surface ?? '', theme === 'dark' ? 0.16 : 0.14 ); } return palette;}/** The palette fields this site changed: the rest are the built-in colors. */export function customizedFields(fields: Record<string, unknown>): string[] { return PALETTE_FIELD_NAMES.filter( (name) => typeof fields[name] === 'string' && fields[name] !== '' );}/** * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`), * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through). * Answers `#rrggbb`, or why it can't be measured. */export function colorOf( value: string, theme: Theme, palette: Palette): { color: string } | { why: string } { const text = value.trim(); if (!text || text === 'default') { return { why: 'not set: it follows the palette' }; } if (text === 'image or video') { return { why: 'the background is an image or video' }; } const token = /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text); const fromPalette = palette[theme][token]; if (fromPalette) { return { color: fromPalette }; } const color = hex(text); if (color) { return { color }; } if (/^#[0-9a-f]{8}$/i.test(text)) { return { why: `${text} is partly see-through, so it depends on what is behind it` }; } return { why: `"${text}" is not a plain color` };}function channel(value: number): number { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;}/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */export function luminance(color: string): number { const n = Number.parseInt(color.slice(1), 16); return ( 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255) );}/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */export function contrastRatio(a: string, b: string): number { const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (light + 0.05) / (dark + 0.05);}/** One text and background pair, checked against WCAG AA. */export type ContrastResult = | { theme: Theme; text: string; background: string; ratio: number; needs: number; passes: boolean; } | { theme: Theme; measured: false; why: string };/** Checks a text color against its background in one theme. The math is here, never the model's. */export function checkContrast( text: string, background: string, theme: Theme, size: Size, palette: Palette): ContrastResult { const fore = colorOf(text, theme, palette); const back = colorOf(background, theme, palette); if ('why' in fore || 'why' in back) { const why = 'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`; return { theme, measured: false, why }; } const ratio = contrastRatio(fore.color, back.color); const needs = size === 'normal' ? 4.5 : 3; return { theme, text: fore.color, background: back.color, ratio: Math.floor(ratio * 100) / 100, needs, passes: ratio >= needs };}/** The palette's own text pairs, checked in both themes. */export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] { return (['dark', 'light'] as const).flatMap((theme) => PALETTE_PAIRS.map(([text, background]) => ({ pair: `${text} on ${background}`, ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette) })) );}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';/** A page, and when its draft last changed. */export interface PageRow { id: string; // the draft's id, `…:en:draft` title: string; updatedAt: string;}/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */export interface ColorField { field: string; dark: string; light: string;}/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */export interface SectionStyle { path: string; type: string; background: { dark: string; light: string } | null; colors: ColorField[]; settings: Record<string, unknown>; customCss: string;}/** One change to a section's setting: the value at `path` goes from `before` to `after`. */export interface Change { path: string; before: string; after: string;}// The page fields that hold sections. Fixes are made inside these only.const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;// Settings that are part of a section's look (colors and backgrounds are read on their own).const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;const COLOR = /Color$/;const BACKGROUND = /Bg(Light)?(Custom)?$/;/** Every page on the site, as drafts: the latest version, published or not. */export async function listPages(site: SiteClient): Promise<PageRow[]> { const pages = await site .pages() .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' }); return pages .filter((page) => page.type !== '@apostrophecms/archive-page') .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));}/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */export async function readSiteDesign(site: SiteClient) { const fonts = ['Display', 'Body', 'Accent'] as const; const fontFields = fonts.flatMap((slot) => [ `font${slot}Source`, `font${slot}Curated`, `font${slot}CdnFamily` ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[]; const [global = {}] = await site .globalContent() .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']); const fields = global as Record<string, unknown>; const font = (slot: string) => { const source = String(fields[`font${slot}Source`] ?? 'default'); if (source === 'curated') return String(fields[`font${slot}Curated`]); if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]); return source === 'upload' ? 'uploaded font files' : 'the built-in font'; }; return { palette: resolvePalette(fields) satisfies Palette, customized: customizedFields(fields), fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') }, customCss: cssOf(fields.designCss) };}/** A custom CSS field's CSS: the site stores `{ css }`. */function cssOf(value: unknown): string { if (typeof value === 'string') return value; return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';}/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] { for (const [key, value] of Object.entries(widget)) { if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) { return { dark: 'image or video', light: 'image or video' }; } } const select = Object.keys(widget).find((key) => key.endsWith('Bg')); if (!select) { return null; } const pick = (choice: unknown, custom: unknown) => choice === 'custom' ? String(custom ?? '') : !choice || choice === 'transparent' ? 'page' : String(choice); const dark = pick(widget[select], widget[`${select}Custom`]); const lightChoice = widget[`${select}Light`]; const light = !lightChoice || lightChoice === 'inherit' ? dark : pick(lightChoice, widget[`${select}LightCustom`]); return { dark, light };}/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */function lookOf(fields: Record<string, unknown>) { const colors: ColorField[] = []; const settings: Record<string, unknown> = {}; for (const [key, value] of Object.entries(fields)) { if (key.startsWith('_') || value === '' || value === null || value === undefined) continue; const field = key.replace(/Light$/, ''); if (COLOR.test(field)) { if (!colors.some((color) => color.field === field)) { const dark = String(fields[field] ?? ''); colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) }); } } else if (SETTING.test(key) && !BACKGROUND.test(key)) { if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) { settings[key] = value; } } } return { colors, settings };}/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) { const items = (area as { items?: unknown[] } | undefined)?.items ?? []; for (const [index, item] of items.entries()) { const widget = item as Record<string, unknown>; const at = `${path}.items.${index}`; const { colors, settings } = lookOf(widget); const section = { path: at, type: String(widget.type), background: backgroundOf(widget), colors, settings, customCss: cssOf(widget.sectionCss) }; if ( !nested || section.background || colors.length || Object.keys(settings).length || section.customCss ) { into.push(section); } for (const [key, value] of Object.entries(widget)) { if ((value as { metaType?: string } | null)?.metaType === 'area') { sectionsIn(value, `${at}.${key}`, into, true); } } }}/** A page's draft, as a plain object. */async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> { return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };}/** A page's look: its own style settings, then every section's. */export async function readPageStyle(site: SiteClient, id: string) { const page = await readDraft(site, id); const sections: SectionStyle[] = []; for (const area of AREAS) { sectionsIn(page[area], area, sections); } const own = Object.fromEntries( Object.entries(page).filter( ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo') ) ); return { title: String(page.title), page: lookOf(own), sections };}/** Why a change can't be saved, or null when it can. */function refusal(change: Change): string | null { const [area = '', ...rest] = change.path.split('.'); const field = rest.at(-1) ?? ''; if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) { return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`; } if (/css/i.test(field)) { return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`; } const isColor = COLOR.test(field.replace(/Light$/, '')); if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) { return `"${field}" is not a color, background or style setting.`; } if ( isColor && change.after && !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after) ) { return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`; } return null;}/** * Writes the changes to the page's DRAFT. Each change must still match what the agent read * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent. */export async function saveStyleChanges( site: SiteClient, id: string, changes: readonly Change[]): Promise<string> { const draftId = id.replace(/:published$/, ':draft'); const page = await readDraft(site, draftId); const areas = new Map<string, Record<string, unknown>>(); for (const change of changes) { const refused = refusal(change); if (refused) { return `Not saved: ${refused}`; } const [area = '', ...rest] = change.path.split('.'); if (!areas.has(area)) { areas.set(area, structuredClone(page[area]) as Record<string, unknown>); } let parent = areas.get(area); for (const key of rest.slice(0, -1)) { parent = parent?.[key] as Record<string, unknown> | undefined; } const last = rest.at(-1) ?? ''; if (!parent || String(parent[last] ?? '') !== change.before) { return `Not saved: "${change.path}" changed since you read it. Read the page again.`; } parent[last] = change.after; } await site.pages().updatePage({ id: draftId, sections: areas.get('sections'), aboveSections: areas.get('aboveSections'), belowSections: areas.get('belowSections') }); return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;}/** A private link to a page's draft, for anyone you send it to: no sign-in. */export async function previewLink(site: SiteClient, id: string): Promise<string> { const page = await readDraft(site, id); const shared = await site.pages().sharePage({ id, share: true }); return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;}
import type { FilesystemBackend } from 'deepagents';import type { PageRow } from './content.ts';/** A page, and whether it changed since its memory file was written. */export interface PageStatus extends PageRow { status: 'new' | 'changed' | 'unchanged' | 'next run'; memoryFile: string; // where the agent keeps its notes on this page}/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */export function memoryFileFor(id: string): string { return `/memories/pages/${id.split(':')[0]}.md`;}/** The same file inside the memories backend, which is mounted at /memories/. */const inMemories = (file: string) => file.replace(/^\/memories/, '');/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */async function updatedInMemory(memories: FilesystemBackend, id: string) { const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5); return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');}/** * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the * next run. A changed page's old file is deleted for the style-collector to write again, and the files * of pages that no longer exist are deleted. */export async function compareWithMemory( memories: FilesystemBackend, pages: readonly PageRow[]): Promise<{ pages: PageStatus[]; removed: string[] }> { const statuses: PageStatus[] = []; let toRead = 0; for (const page of pages) { const updated = await updatedInMemory(memories, page.id); let status: PageStatus['status'] = updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed'; if (status !== 'unchanged' && ++toRead > MAX_PAGES) { status = 'next run'; } if (status === 'changed') { await memories.delete(inMemories(memoryFileFor(page.id))); } statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) }); } const live = new Set(statuses.map((page) => inMemories(page.memoryFile))); const removed = ((await memories.ls('/pages/')).files ?? []) .map((file) => file.path) .filter((path) => !live.has(path)); for (const path of removed) { await memories.delete(path); } return { pages: statuses, removed };}
import { randomUUID } from 'node:crypto';import { Command } from '@langchain/langgraph';import type { buildAgent } from './agent.ts';import type { Change } from './content.ts';import type { Usage } from './usage.ts';/** A draft the agent wants to save: the document, and every change to it. */export interface Draft { kind: string; title: string; changes: Change[];}/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */export type Choice = | { type: 'all' } | { type: 'some'; keep: number[] } | { type: 'revise'; feedback: string } | { type: 'skip'; reason: string };/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */export interface Owner { /** The agent is working (a spinner, in the terminal). */ busy?(): void; /** Answers the agent's question. */ answer(question: string): Promise<string>; /** Decides on a draft the agent wants to save. */ decide(draft: Draft): Promise<Choice>;}/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */export const nobody: Owner = { answer: async () => { throw new Error('A report-only run never asks.'); }, decide: async () => { throw new Error('A report-only run never saves.'); }};interface ActionRequest { name: string; args: { kind?: string; id?: string; title?: string; changes?: Change[] };}type Decision = | { type: 'approve' } | { type: 'reject'; message: string } | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);/** * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the * drafts it wants to write. Resolves the agent's closing summary. */export async function runWithOwner( agent: ReturnType<typeof buildAgent>, task: string, owner: Owner, usage?: Usage): Promise<string> { // `usage` counts every model call's tokens and cost, the subagents' too. const config = { configurable: { thread_id: randomUUID() }, recursionLimit: MAX_STEPS, callbacks: usage ? [usage] : [] }; let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] }; for (;;) { owner.busy?.(); const result = await agent.invoke(input, config); // Several rewriters can pause at once: each pause gets its own answer, by its id. const pending = result.__interrupt__ ?? []; if (pending.length === 0) { const last = result.messages.at(-1); return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? ''); } const answers: Record<string, unknown> = {}; for (const { id, value } of pending) { const paused = value as { question?: string; actionRequests?: ActionRequest[] }; if (paused.question) { answers[String(id)] = await owner.answer(paused.question); continue; } const decisions: Decision[] = []; for (const action of paused.actionRequests ?? []) { decisions.push(await review(action, owner)); } answers[String(id)] = { decisions }; } input = new Command({ resume: answers }); }}/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */async function review(action: ActionRequest, owner: Owner): Promise<Decision> { const changes = action.args.changes ?? []; const choice = await owner.decide({ kind: action.args.kind ?? 'page', title: action.args.title ?? '', changes }); switch (choice.type) { case 'all': return { type: 'approve' }; case 'some': { const kept = changes.filter((_, index) => choice.keep.includes(index)); if (kept.length === 0) { return { type: 'reject', message: 'The owner kept none of these changes. Do not try them again.' }; } return { type: 'edit', editedAction: { name: action.name, args: { ...action.args, changes: kept } } }; } case 'revise': return { type: 'reject', message: `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` + 'Revise your changes to match, then call the same tool again with every change.' }; case 'skip': return { type: 'reject', message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.` }; }}
import { stdout } from 'node:process';import { stripVTControlCharacters, styleText } from 'node:util';import * as prompt from '@clack/prompts';import { diffWordsWithSpace } from 'diff';import { Marked, type Tokens } from 'marked';import stringWidth from 'string-width';import yoctoSpinner from 'yocto-spinner';import type { Change } from './content.ts';import type { Choice, Draft, Owner } from './review.ts';import type { Usage } from './usage.ts';/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */export class Stopped extends Error {}/** * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is * doing and what the run has used so far; its words as formatted text. */export function terminalOwner(usage?: Usage): Owner & { step: (line: string) => void; report: (markdown: string) => void; failed: (error: unknown) => void;} { // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers. const spin = yoctoSpinner({ stream: stdout }); // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere // else each one would print a new line, so output sent to a file gets the start and the steps. const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env); let spinning = false; let started = 0; let clock: NodeJS.Timeout | undefined; let steps: string[] = []; let doing = 'The agent is working'; // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped. // On a narrow terminal the tokens and cost go first, then the time, before the task is cut. const line = () => { const seconds = Math.floor((Date.now() - started) / 1000); const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`; const width = (stdout.columns || 80) - 3; const tail = [[spent, time], [time]] .map((parts) => parts.filter(Boolean).join(' · ')) .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? ''; const room = tail ? width - stringWidth(tail) - 2 : width; return tail ? `${fit(doing, room)} ${styleText('dim', tail)}` : fit(doing, room); }; const withUsage = (text: string) => usage ? `${text} ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text; const refresh = () => { if (spinning && live) { spin.text = line(); } }; if (usage) { usage.onCall = refresh; } // The spinner sits on the run's guide line, like the rest of the terminal. const startSpinner = () => { started = Date.now(); spinning = true; stdout.write(`${styleText('gray', prompt.S_BAR)}\n`); spin.start(line()); if (live) { clock = setInterval(refresh, 1000); } }; const stopSpinner = (finish: () => void) => { clearInterval(clock); spin.stop(); spinning = false; finish(); }; // What the agent did since you last heard from it, as one dim line. const settle = () => { if (spinning) { const done = withUsage(styleText('dim', steps.join(' · ') || 'Done')); stopSpinner(() => prompt.log.step(done, { spacing: 0 })); } steps = []; }; return { busy() { if (!spinning) { doing = 'The agent is working'; startSpinner(); } }, step(text) { doing = text.replace(/\.$/, ''); steps.push(doing); refresh(); }, async answer(question) { settle(); say(markdown(question), 'cyan'); return ask(() => prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' }) ); }, async decide(draft) { settle(); say( [ styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`), ...draft.changes.map((change, index) => showChange(change, index)) ].join('\n\n'), 'cyan' ); return decide(draft); }, /** Shows the run's report. */ report(text) { settle(); say(markdown(text), 'green'); }, /** The run stopped on an error: the spinner says so, and the error follows. */ failed(error) { if (spinning) { stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 })); } prompt.log.error(error instanceof Error ? error.message : String(error)); } };}/** "183,204 tokens": read and written together. */function tokens(usage: Usage): string { return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;}/** * Text cut to a number of terminal cells, never through a character (an emoji counts as two). * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji * is 8), so the cut keeps both within the limit. */function fit(text: string, cells: number): string { const size = (part: string) => Math.max(stringWidth(part), part.length); if (size(text) <= cells) { return text; } let cut = ''; for (const { segment } of new Intl.Segmenter().segment(text)) { if (size(`${cut}${segment}…`) > cells) { break; } cut += segment; } return `${cut}…`;}/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */function say(text: string, color: 'cyan' | 'green') { prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });}// The terminal's color and style codes (ESC [ … m), as styleText writes them.const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */export function wrap(text: string, width: number): string { const visible = (line: string) => stripVTControlCharacters(line).length; return text .split('\n') .map((line) => { // A wrapped line sits under the text, past any list number or bullet. const lead = /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? ''; const indent = ' '.repeat(lead.length); const words = line.trimStart().split(' '); const lines: string[] = []; let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? ''); for (const word of words) { if (visible(`${current} ${word}`) > width && current.trim()) { // The colors in force so far carry onto the next line: the guide line resets them. const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? ''; lines.push(`${current}\x1b[0m`); current = indent + colors + word; } else { current = `${current} ${word}`; } } lines.push(current); return lines.join('\n'); }) .join('\n');}/** Starts and ends the run's frame in the terminal. */export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));export const outro = (line: string) => prompt.outro(line);export const note = (line: string) => prompt.log.info(line);async function decide(draft: Draft): Promise<Choice> { const many = draft.changes.length > 1; const choice = await ask(() => prompt.select({ message: 'Your call', options: [ { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' }, ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []), { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' }, { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' } ] }) ); if (choice === 'some') { const keep = await ask(() => prompt.multiselect({ message: 'Keep these (Space to toggle, Enter to save)', options: draft.changes.map((change, index) => ({ value: index, label: `${index + 1}. ${plain(change.after)}` })), initialValues: draft.changes.map((_, index) => index), required: false }) ); return { type: 'some', keep }; } if (choice === 'revise') { return { type: 'revise', feedback: await ask(() => prompt.text({ message: 'What should change?', validate: (value) => (value?.trim() ? undefined : 'Say what should change.') }) ) }; } if (choice === 'skip') { return { type: 'skip', reason: await ask(() => prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' }) ) }; } return { type: 'all' };}/** Waits for one prompt; Ctrl+C or Esc stops the run. */async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> { const value = await show(); if (prompt.isCancel(value)) { throw new Stopped('You stopped the run.'); } return value as Exclude<T, symbol>;}/** * One change, numbered: the text before and after, whole, with the words that change marked (red * and struck through before, green after). Still readable with the colors gone, pasted elsewhere. */export function showChange(change: Change, index: number): string { const parts = diffWordsWithSpace(change.before, change.after); const kept = parts.filter((part) => !part.added && !part.removed); const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`; // Mostly rewritten: the old text in red and the new in green, with no marks to read through. if ( kept .map((part) => part.value) .join('') .trim().length < change.after.length / 2 ) { return [ label, ` ${styleText('dim', 'before')} ${styleText('red', change.before)}`, ` ${styleText('dim', 'after ')} ${styleText('green', change.after)}` ].join('\n'); } const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) => parts .filter((part) => !part[drop]) .map((part) => part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value) ) .join(''); return [ label, ` ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`, ` ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}` ].join('\n');}/** Rich text's HTML tags, dimmed, so the words stand out. */const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));/** Text with its HTML tags taken out, on one line, for a short label. */function plain(text: string, max = 70): string { const flat = text .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;}// The agent writes Markdown. This renders it for the terminal, with a blank line after every// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.const terminal = new Marked({ renderer: { heading({ tokens }) { return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`; }, paragraph({ tokens }) { return `${this.parser.parseInline(tokens)}\n\n`; }, list(list: Tokens.List) { const start = typeof list.start === 'number' ? list.start : 1; return list.items .map((item, index) => { const bullet = list.ordered ? `${start + index}.` : '•'; const body = this.parser.parse(item.tokens).trim(); // Lines under the first are indented to sit under its text. const pad = ' '.repeat(bullet.length + 1); return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`; }) .join(''); }, text(token) { return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text; }, strong({ tokens }) { return styleText('bold', this.parser.parseInline(tokens)); }, em({ tokens }) { return styleText('italic', this.parser.parseInline(tokens)); }, codespan({ text }) { return styleText('cyan', text); }, code({ text }) { return `${styleText('cyan', text)}\n\n`; }, blockquote({ tokens }) { return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`; }, link({ href, tokens }) { const text = this.parser.parseInline(tokens); return text === href ? styleText('underline', href) : `${text} ${styleText('dim', `(${href})`)}`; }, hr() { return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`; }, br() { return '\n'; }, html({ text }) { return text; } }});/** The agent's Markdown, formatted for the terminal. */export function markdown(text: string): string { return String(terminal.parse(text, { async: false })).trim();}
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';import { AIMessage } from '@langchain/core/messages';import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';/** * Counts every model call in a run, the subagents' too: tokens read and written, and what * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call. */export class Usage extends BaseCallbackHandler { name = 'usage'; calls = 0; inputTokens = 0; outputTokens = 0; /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */ cost = 0; /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */ uncosted = 0; /** Called after each model call, e.g. to refresh a running total on screen. */ onCall?: () => void; override handleLLMEnd(output: LLMResult) { this.calls += 1; const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message; const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined; this.inputTokens += usage?.input_tokens ?? 0; this.outputTokens += usage?.output_tokens ?? 0; const cost = output.llmOutput?.tokenUsage?.cost; if (typeof cost === 'number') { this.cost += cost; } else { this.uncosted += 1; } this.onCall?.(); } /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */ line(): string { const count = new Intl.NumberFormat('en-US'); const parts = [ `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`, `${count.format(this.inputTokens)} tokens in`, `${count.format(this.outputTokens)} out`, `$${this.cost.toFixed(4)}` ]; const line = parts.join(' · '); return this.uncosted > 0 ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)` : line; } /** The run's usage, as a Markdown section for the report and its email. */ markdown(): string { return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`; }}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { previewLink } from './content.ts';import type { Usage } from './usage.ts';/** A draft the run saved, and the private link that previews it. */export interface WaitingDraft { title: string; link: string;}/** Every page the run saved a draft of (id → title), each with its preview link. */export async function draftsWaiting( site: SiteClient, saved: Map<string, string>): Promise<WaitingDraft[]> { const drafts: WaitingDraft[] = []; for (const [id, title] of saved) { drafts.push({ title, link: await previewLink(site, id) }); } return drafts;}/** * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then * what the run used (tokens and cost). */export function reportMarkdown( summary: string, drafts: readonly WaitingDraft[], usage?: Usage, date = new Date()) { // The site's host for the heading; a missing or mistyped URL still gets a report that says why. const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site'; const lines = [ `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`, '', summary.trim() ]; if (drafts.length > 0) { lines.push('', '## Drafts waiting for you', ''); lines.push('Publish each one in the CMS when it looks right.', ''); for (const draft of drafts) { lines.push(`- "${draft.title}", preview: ${draft.link}`); } } if (usage) { lines.push('', usage.markdown()); } return `${lines.join('\n')}\n`;}
import { createSiteClient } from '@fetchski/attract-cms-sdk';import { buildAgent, modelFromEnv } from './agent.ts';import { draftsWaiting, reportMarkdown } from './report.ts';import { runWithOwner } from './review.ts';import { intro, outro, Stopped, terminalOwner } from './terminal.ts';import { newRun } from './tools.ts';import { Usage } from './usage.ts';// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .envconst site = createSiteClient();const run = newRun();const usage = new Usage();const owner = terminalOwner(usage);const agent = buildAgent(site, modelFromEnv(), run, owner.step);intro('One design');let summary: string;try { summary = await runWithOwner( agent, 'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.', owner, usage );} catch (error) { if (!(error instanceof Stopped)) { owner.failed(error); throw error; } // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them. summary = 'You stopped the run before it finished.';}const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);owner.report(report);outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
import { deepStrictEqual, ok, strictEqual } from 'node:assert';import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { test } from 'node:test';import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';import { AIMessage } from '@langchain/core/messages';import type { LLMResult } from '@langchain/core/outputs';import { fakeModel } from 'langchain';import { buildAgent } from '../src/agent.ts';import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';import { newRun, siteTools } from '../src/tools.ts';import { Usage } from '../src/usage.ts';const HOME = 'page-home:en:draft';const ABOUT = 'page-about:en:draft';const MONDAY = '2026-10-26T09:00:00.000Z';const TUESDAY = '2026-10-27T09:00:00.000Z';// A fresh memory folder, per test.const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));function area(widget: Record<string, unknown>) { return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };}// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.const PAGES: Record<string, ReturnType<typeof factories.Page>> = { [HOME]: factories.Page({ id: HOME, title: 'Home', sections: area({ type: 'intro', introBg: 'raised', introEyebrowColor: '--fetchski-mint', introCtaRadius: 'slight' }) }), [ABOUT]: factories.Page({ id: ABOUT, title: 'About us', sections: area({ type: 'media-text', mediaTextBg: 'custom', mediaTextBgCustom: '#ffffff', mediaTextHeadingColor: '#777777', mediaTextCtaRadius: 'pill' }) })};function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) { return fakeSite({ 'pages.listPages': factories.ListPagesResponse({ results: Object.entries(updated).map(([id, updatedAt]) => ({ id, title: String(PAGES[id]?.title), type: 'default-page', updatedAt: new Date(updatedAt) })) }), 'pages.getPage': (args) => PAGES[String(args.id)], 'globalContent.listGlobalContent': factories.GlobalList({ results: [ { paletteSecondary: '#2dd4bf', fontDisplaySource: 'curated', fontDisplayCurated: 'fraunces' } ], pages: 1, currentPage: 1 }) });}const HEADING_FIX = { path: 'sections.items.0.mediaTextHeadingColor', before: '#777777', after: '--fetchski-ink'};const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.function scriptedModel() { return ( fakeModel() .respondWithTools([ { name: 'read_site_design', args: {} }, { name: 'list_pages', args: {} } ]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) // The style-collector subagent: .respondWithTools([ { name: 'read_page_style', args: { id: HOME } }, { name: 'read_page_style', args: { id: ABOUT } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-home.md', content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised` } }, { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777` } } ]) .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md')) // The main agent again: .respondWithTools([ { name: 'task', args: { subagent_type: 'contrast-checker', description: 'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md' } } ]) // The contrast-checker subagent: .respondWithTools([ { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } } ]) .respondWithTools([ { name: 'check_contrast', args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/findings/contrast-1.md', content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5' } } ]) .respond(new AIMessage('/findings/contrast-1.md')) // The main agent again: .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/site.md', content: '# Site design\n- Buttons: slight corners' } } ]) .respondWithTools([ { name: 'ask_owner', args: { question: 'Home has slight button corners, About us has pill. Which is yours?' } } ]) .respondWithTools([ { name: 'save_style_changes', args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] } } ]) .respond( new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.') ) );}// The person at the keyboard, scripted: their answers to questions, then their call on each draft.function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } { const drafts: Draft[] = []; return { drafts, answer: async () => answers.shift() ?? '', decide: async (draft) => { drafts.push(draft); return choices.shift() ?? { type: 'skip', reason: '' }; } };}test('a first run collects every page into memory, and saves an approved fix as a draft', async () => { const site = siteWithTwoDesigns(); const folder = memoryFolder(); const run = newRun(folder); const owner = scriptedOwner(['Slight'], [{ type: 'all' }]); const summary = await runWithOwner( buildAgent(site, scriptedModel(), run, () => {}), 'Check this whole website.', owner ); strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.'); site.assertCalled('pages.updatePage', (args) => { const section = (args.sections as { items: Record<string, unknown>[] }).items[0]; return ( args.id === ABOUT && section?.mediaTextHeadingColor === '--fetchski-ink' && section.mediaTextCtaRadius === 'slight' ); }); deepStrictEqual([...run.saved], [[ABOUT, 'About us']]); deepStrictEqual( owner.drafts.map((draft) => draft.title), ['About us'] ); ok(existsSync(join(folder, 'pages', 'page-home.md'))); ok(existsSync(join(folder, 'site.md'))); // The next run finds both pages unchanged, from their memory files alone. const next = newRun(folder); await siteTools(site, next, () => {}).listPages.invoke({}); deepStrictEqual( next.pages.map((page) => page.status), ['unchanged', 'unchanged'] );});test('a fix the owner skips is never written', async () => { const site = siteWithTwoDesigns(); await runWithOwner( buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}), 'Check this whole website.', scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }]) ); site.assertNotCalled('pages.updatePage');});test('a report-only second run collects only the changed page, and never asks or writes', async () => { // The last run scanned Home, About us and a page since deleted. Since then, only About us changed. const folder = memoryFolder(); mkdirSync(join(folder, 'pages')); for (const name of ['page-home', 'page-about', 'page-old']) { writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`); } writeFileSync( join(folder, 'site.md'), '# Site design\n- Buttons: slight corners, the owner chose' ); const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY }); const run = newRun(folder); const model = fakeModel() .respondWithTools([{ name: 'list_pages', args: {} }]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us, as it is now\nupdated: ${TUESDAY}\n` } } ]) .respond(new AIMessage('/memories/pages/page-about.md')) .respond(new AIMessage('## Contrast\nNo failures.')); const summary = await runWithOwner( buildAgent(site, model, run, () => {}, { reportOnly: true }), 'Check this whole website, and report what you find.', nobody ); strictEqual(summary, '## Contrast\nNo failures.'); deepStrictEqual( run.pages.map((page) => [page.title, page.status]), [ ['Home', 'unchanged'], ['About us', 'changed'] ] ); site.assertCalledTimes('pages.getPage', 1); site.assertCalled('pages.getPage', (args) => args.id === ABOUT); site.assertNotCalled('pages.updatePage'); strictEqual( readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'), `# About us, as it is now\nupdated: ${TUESDAY}\n` ); strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false); // site.md, with the owner's choice, was in the agent's instructions from the start. const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? ''; ok(instructions.includes('Buttons: slight corners, the owner chose'));});test('check_contrast measures with the WCAG formula, in code', async () => { const palette = resolvePalette({}); strictEqual(contrastRatio('#000000', '#ffffff'), 21); strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48); deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), { theme: 'light', text: '#777777', background: '#ffffff', ratio: 4.47, needs: 4.5, passes: false }); const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette); ok('passes' in large && large.passes); // Through the tool: palette tokens resolve with the site's own palette, in the theme asked. const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {}); const mint = JSON.parse( await tool.invoke({ text: '--fetchski-mint', background: 'page', theme: 'dark', size: 'normal' }) ); deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]); const overVideo = JSON.parse( await tool.invoke({ text: '#ffffff', background: 'image or video', theme: 'dark', size: 'large' }) ); deepStrictEqual(overVideo, { theme: 'dark', measured: false, why: 'background: the background is an image or video' });});test('usage adds up tokens and the cost OpenRouter reports for each call', () => { const usage = new Usage(); const call = (input: number, output: number, cost?: number) => usage.handleLLMEnd({ generations: [ [ { text: '', message: new AIMessage({ content: '', usage_metadata: { input_tokens: input, output_tokens: output, total_tokens: input + output } }) } ] ], // OpenRouter's own `usage` object, as ChatOpenRouter passes it on. llmOutput: { tokenUsage: cost === undefined ? {} : { cost } } } as unknown as LLMResult); call(1000, 200, 0.0012); call(3000, 50, 0.0021); call(10, 5); strictEqual( usage.line(), '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)' ); ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));});
# Your site, and the API key from "Make the API key"FETCHSKI_ATTRACT_URL=https://acme.exampleFETCHSKI_ATTRACT_API_KEY=fsk_acme_…FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.OPENROUTER_API_KEY=sk-or-v1-…OPENROUTER_MODEL=~google/gemini-flash-latest# How freely it words things (0 = the same way every time), and the most it writes in one answerOPENROUTER_TEMPERATURE=0OPENROUTER_MAX_TOKENS=16000# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)MAX_PAGES=80AGENT_MAX_STEPS=400
import type { SiteClient } from '@fetchski/attract-cms-sdk';import type { BaseChatModel } from '@langchain/core/language_models/chat_models';import { MemorySaver } from '@langchain/langgraph';import { ChatOpenRouter } from '@langchain/openrouter';import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';import { todoListMiddleware } from 'langchain';import { type Run, siteTools } from './tools.ts';const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgroundsand button styles on every page, with text people can read. Different pages were built at differenttimes, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.Your long-term memory is /memories/, kept between runs:- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the owner's choices. It is loaded into your instructions at the start of every run.- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each one, and knows from it whether the page changed since.Work in this order:1. Plan your work with write_todos.2. Call read_site_design once, and list_pages once.3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a time: give the id, title and memory file of each. Unchanged pages already have a current memory file; pages marked "next run" wait for a later run, so leave them out of this one.4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from read_site_design, which colors the site changed, the fonts, and the patterns most page files share: section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each outlier with its page, section path and field: a hex color off the palette where the site uses a palette token, a font or font override no other page uses, a button variant or corner unlike the rest, custom CSS that sets colors or fonts the palette already has.`;const FIX = `7. Where the site has two patterns and you can't tell which one is intended (two button corners, two eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the answer into /memories/site.md, so no later run asks again.8. For each page with fixes a section setting can make (a one-off color back to its palette token, a button to the site's pattern, a text color that fails contrast), call save_style_changes once with every change on that page. The owner approves each page. If the owner sends one back with notes, change it to match and call save_style_changes again; if they skip one, don't try again. Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs andbetween list items.`;const REPORT_ONLY = `7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).`;const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,then write its memory file (the path you are given) with write_file, in this shape:# <title>id: <id>updated: <updated, copied exactly>Page: <its own colors and settings, or none>## <section path> (<section type>)- background: dark <value>, light <value>- <field>: dark <value>, light <value> (one line per color; write "default" for '')- settings: <field> <value>, <field> <value> (every setting)- custom CSS: <the CSS, or none>Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you aregiven with read_file. In each section, call check_contrast for every color that colors text or an icon(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against thesection's background: the dark value in the dark theme, the light value in the light theme. A solidbutton's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui foricons and borders, normal for the rest. Skip colors that are "default": they follow the palette.Write the findings file you are told, one line per failing pair:- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with onlythe file's path.`;export interface AgentOptions { /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */ reportOnly?: boolean;}/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */export function buildAgent( site: SiteClient, model: BaseChatModel, run: Run, log: (line: string) => void, { reportOnly = false }: AgentOptions = {}) { const tools = siteTools(site, run, log); return createDeepAgent({ model, systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX), tools: reportOnly ? [tools.readSiteDesign, tools.listPages] : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges], middleware: [todoListMiddleware()], subagents: [ { name: 'style-collector', description: "Reads a batch of pages' styles and writes one memory file per page.", systemPrompt: STYLE_COLLECTOR, tools: [tools.readPageStyle] }, { name: 'contrast-checker', description: 'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.', systemPrompt: CONTRAST_CHECKER, tools: [tools.checkContrast] } ], // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives // for one run. site.md is loaded into the agent's instructions at the start of each run. backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }), memory: ['/memories/site.md'], // Nothing is written without the owner: approve it, edit it, or reject it. interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } }, checkpointer: new MemorySaver() });}/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */export function modelFromEnv() { return new ChatOpenRouter({ model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest', temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0), maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000) });}
import { resolve } from 'node:path';import type { SiteClient } from '@fetchski/attract-cms-sdk';import { interrupt } from '@langchain/langgraph';import { FilesystemBackend } from 'deepagents';import { tool } from 'langchain';import { z } from 'zod';import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';import { checkContrast, paletteContrast } from './design.ts';import { compareWithMemory, type PageStatus } from './memory.ts';/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */export interface Run { memories: FilesystemBackend; pages: PageStatus[]; saved: Map<string, string>;}/** * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the * Deep Agents FilesystemBackend the agent sees as /memories/. */export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run { const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true }); return { memories, pages: [], saved: new Map() };}/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) { // Site design is read once a run, by whichever tool needs it first. let design: ReturnType<typeof readSiteDesign> | undefined; const siteDesign = () => { design ??= readSiteDesign(site); return design; }; const listPagesTool = tool( async () => { const { pages, removed } = await compareWithMemory(run.memories, await listPages(site)); run.pages = pages; const count = (status: string) => pages.filter((page) => page.status === status).length; log( `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.` ); if (count('next run') > 0) { log(`${count('next run')} more wait for the next run (MAX_PAGES).`); } if (removed.length > 0) { log(`Forgot ${removed.length} page(s) that no longer exist.`); } return JSON.stringify(pages.map(({ updatedAt, ...page }) => page)); }, { name: 'list_pages', description: 'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.', schema: z.object({}) } ); const readSiteDesignTool = tool( async () => { const { palette, customized, fonts, customCss } = await siteDesign(); log('Read Site design.'); return JSON.stringify({ palette, customized, paletteContrast: paletteContrast(palette), fonts, customCss }); }, { name: 'read_site_design', description: 'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' + 'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.', schema: z.object({}) } ); const readPageStyleTool = tool( async (args) => { const style = await readPageStyle(site, args.id); log(`Read the style of "${style.title}".`); // The page's last-modified time, for its memory file: the next run compares it with the page's. const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? ''; return JSON.stringify({ updated, ...style }); }, { name: 'read_page_style', description: "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " + "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.", schema: z.object({ id: z.string().describe('The id from list_pages') }) } ); const checkContrastTool = tool( async (args) => { const { palette } = await siteDesign(); return JSON.stringify( checkContrast(args.text, args.background, args.theme, args.size, palette) ); }, { name: 'check_contrast', description: 'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' + 'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' + 'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.', schema: z.object({ text: z.string().describe('The text (or icon) color'), background: z.string().describe('The background it sits on'), theme: z.enum(['dark', 'light']), size: z .enum(['normal', 'large', 'ui']) .describe('large: headings and big titles; ui: icons and borders') }) } ); const saveStyleChangesTool = tool( async (args) => { const answer = await saveStyleChanges(site, args.id, args.changes); if (answer.startsWith('Saved')) { run.saved.set(args.id, args.title); } return answer; }, { name: 'save_style_changes', description: "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " + 'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' + '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.', schema: z.object({ id: z.string(), title: z.string().describe("The page's title, shown to the owner"), changes: z .array(z.object({ path: z.string(), before: z.string(), after: z.string() })) .min(1) .describe('Every change to this page, each at its path') }) } ); const askOwner = tool( // Pauses the run until the owner answers; their answer is the tool's result. async (args) => String(interrupt({ question: args.question })), { name: 'ask_owner', description: 'Asks the website owner one question, and waits for their answer.', schema: z.object({ question: z.string() }) } ); return { listPages: listPagesTool, readSiteDesign: readSiteDesignTool, readPageStyle: readPageStyleTool, checkContrast: checkContrastTool, saveStyleChanges: saveStyleChangesTool, askOwner };}
/** The site's two themes. Dark is the default; visitors can switch to light. */export type Theme = 'dark' | 'light';/** Each palette token's color in each theme, as `#rrggbb`. */export type Palette = Record<Theme, Record<string, string>>;/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */export type Size = 'normal' | 'large' | 'ui';// The colors every site starts with, per palette token: [dark, light]. Site design → Colors// overrides them; a field left empty there keeps these.const BUILT_IN: Record<string, [string, string]> = { accent: ['#625bff', '#544dee'], 'on-accent': ['#ffffff', '#ffffff'], mint: ['#34ddb3', '#0fa380'], coral: ['#ff8e63', '#d8551f'], surface: ['#09090b', '#e4e4e7'], raised: ['#18181b', '#d9d9de'], sunken: ['#0e0e11', '#cfcfd6'], hill: ['#121215', '#d8d8dd'], ink: ['#f4f4f5', '#18181b'], 'ink-muted': ['#a1a1aa', '#52525b'], success: ['#34d399', '#059669'], warning: ['#fbbf24', '#d97706'], danger: ['#f87171', '#dc2626'], info: ['#60a5fa', '#2563eb']};// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.const PALETTE_FIELDS = { paletteAccent: 'accent', paletteOnAccent: 'on-accent', paletteSecondary: 'mint', paletteTertiary: 'coral', paletteLink: 'link', paletteSurface: 'surface', paletteRaised: 'raised', paletteSunken: 'sunken', paletteInk: 'ink', paletteInkMuted: 'ink-muted', paletteSuccess: 'success', paletteWarning: 'warning', paletteDanger: 'danger', paletteInfo: 'info'} as const;const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [ field, `${field}Light`]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];// The text and background pairs every page relies on: [text token, background token].const PALETTE_PAIRS: [string, string][] = [ ['ink', 'surface'], ['ink', 'raised'], ['ink-muted', 'surface'], ['on-accent', 'accent'], ['link', 'surface']];/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */function hex(value: string): string | null { const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim()); if (!match?.[1]) { return null; } const digits = match[1].slice(0, 6); const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits; return `#${full.toLowerCase()}`;}/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */function blend(top: string, bottom: string, share: number): string { const channels = (color: string) => [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16)); const [a, b] = [channels(top), channels(bottom)]; const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share))); return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;}/** The palette the site renders, from Global's palette fields over the built-in colors. */export function resolvePalette(fields: Record<string, unknown>): Palette { const palette: Palette = { dark: {}, light: {} }; const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null); for (const [field, token] of Object.entries(PALETTE_FIELDS)) { const [darkDefault, lightDefault] = BUILT_IN[token] ?? []; const dark = set(field) ?? darkDefault; const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault; if (dark) palette.dark[token] = dark; if (light) palette.light[token] = light; } for (const theme of ['dark', 'light'] as const) { const colors = palette[theme]; colors.link ??= colors.accent ?? ''; colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? ''; // The soft accent tint is the accent, see-through, over the page. colors['accent-soft'] = blend( colors.accent ?? '', colors.surface ?? '', theme === 'dark' ? 0.16 : 0.14 ); } return palette;}/** The palette fields this site changed: the rest are the built-in colors. */export function customizedFields(fields: Record<string, unknown>): string[] { return PALETTE_FIELD_NAMES.filter( (name) => typeof fields[name] === 'string' && fields[name] !== '' );}/** * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`), * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through). * Answers `#rrggbb`, or why it can't be measured. */export function colorOf( value: string, theme: Theme, palette: Palette): { color: string } | { why: string } { const text = value.trim(); if (!text || text === 'default') { return { why: 'not set: it follows the palette' }; } if (text === 'image or video') { return { why: 'the background is an image or video' }; } const token = /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text); const fromPalette = palette[theme][token]; if (fromPalette) { return { color: fromPalette }; } const color = hex(text); if (color) { return { color }; } if (/^#[0-9a-f]{8}$/i.test(text)) { return { why: `${text} is partly see-through, so it depends on what is behind it` }; } return { why: `"${text}" is not a plain color` };}function channel(value: number): number { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;}/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */export function luminance(color: string): number { const n = Number.parseInt(color.slice(1), 16); return ( 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255) );}/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */export function contrastRatio(a: string, b: string): number { const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (light + 0.05) / (dark + 0.05);}/** One text and background pair, checked against WCAG AA. */export type ContrastResult = | { theme: Theme; text: string; background: string; ratio: number; needs: number; passes: boolean; } | { theme: Theme; measured: false; why: string };/** Checks a text color against its background in one theme. The math is here, never the model's. */export function checkContrast( text: string, background: string, theme: Theme, size: Size, palette: Palette): ContrastResult { const fore = colorOf(text, theme, palette); const back = colorOf(background, theme, palette); if ('why' in fore || 'why' in back) { const why = 'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`; return { theme, measured: false, why }; } const ratio = contrastRatio(fore.color, back.color); const needs = size === 'normal' ? 4.5 : 3; return { theme, text: fore.color, background: back.color, ratio: Math.floor(ratio * 100) / 100, needs, passes: ratio >= needs };}/** The palette's own text pairs, checked in both themes. */export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] { return (['dark', 'light'] as const).flatMap((theme) => PALETTE_PAIRS.map(([text, background]) => ({ pair: `${text} on ${background}`, ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette) })) );}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';/** A page, and when its draft last changed. */export interface PageRow { id: string; // the draft's id, `…:en:draft` title: string; updatedAt: string;}/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */export interface ColorField { field: string; dark: string; light: string;}/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */export interface SectionStyle { path: string; type: string; background: { dark: string; light: string } | null; colors: ColorField[]; settings: Record<string, unknown>; customCss: string;}/** One change to a section's setting: the value at `path` goes from `before` to `after`. */export interface Change { path: string; before: string; after: string;}// The page fields that hold sections. Fixes are made inside these only.const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;// Settings that are part of a section's look (colors and backgrounds are read on their own).const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;const COLOR = /Color$/;const BACKGROUND = /Bg(Light)?(Custom)?$/;/** Every page on the site, as drafts: the latest version, published or not. */export async function listPages(site: SiteClient): Promise<PageRow[]> { const pages = await site .pages() .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' }); return pages .filter((page) => page.type !== '@apostrophecms/archive-page') .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));}/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */export async function readSiteDesign(site: SiteClient) { const fonts = ['Display', 'Body', 'Accent'] as const; const fontFields = fonts.flatMap((slot) => [ `font${slot}Source`, `font${slot}Curated`, `font${slot}CdnFamily` ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[]; const [global = {}] = await site .globalContent() .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']); const fields = global as Record<string, unknown>; const font = (slot: string) => { const source = String(fields[`font${slot}Source`] ?? 'default'); if (source === 'curated') return String(fields[`font${slot}Curated`]); if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]); return source === 'upload' ? 'uploaded font files' : 'the built-in font'; }; return { palette: resolvePalette(fields) satisfies Palette, customized: customizedFields(fields), fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') }, customCss: cssOf(fields.designCss) };}/** A custom CSS field's CSS: the site stores `{ css }`. */function cssOf(value: unknown): string { if (typeof value === 'string') return value; return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';}/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] { for (const [key, value] of Object.entries(widget)) { if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) { return { dark: 'image or video', light: 'image or video' }; } } const select = Object.keys(widget).find((key) => key.endsWith('Bg')); if (!select) { return null; } const pick = (choice: unknown, custom: unknown) => choice === 'custom' ? String(custom ?? '') : !choice || choice === 'transparent' ? 'page' : String(choice); const dark = pick(widget[select], widget[`${select}Custom`]); const lightChoice = widget[`${select}Light`]; const light = !lightChoice || lightChoice === 'inherit' ? dark : pick(lightChoice, widget[`${select}LightCustom`]); return { dark, light };}/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */function lookOf(fields: Record<string, unknown>) { const colors: ColorField[] = []; const settings: Record<string, unknown> = {}; for (const [key, value] of Object.entries(fields)) { if (key.startsWith('_') || value === '' || value === null || value === undefined) continue; const field = key.replace(/Light$/, ''); if (COLOR.test(field)) { if (!colors.some((color) => color.field === field)) { const dark = String(fields[field] ?? ''); colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) }); } } else if (SETTING.test(key) && !BACKGROUND.test(key)) { if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) { settings[key] = value; } } } return { colors, settings };}/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) { const items = (area as { items?: unknown[] } | undefined)?.items ?? []; for (const [index, item] of items.entries()) { const widget = item as Record<string, unknown>; const at = `${path}.items.${index}`; const { colors, settings } = lookOf(widget); const section = { path: at, type: String(widget.type), background: backgroundOf(widget), colors, settings, customCss: cssOf(widget.sectionCss) }; if ( !nested || section.background || colors.length || Object.keys(settings).length || section.customCss ) { into.push(section); } for (const [key, value] of Object.entries(widget)) { if ((value as { metaType?: string } | null)?.metaType === 'area') { sectionsIn(value, `${at}.${key}`, into, true); } } }}/** A page's draft, as a plain object. */async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> { return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };}/** A page's look: its own style settings, then every section's. */export async function readPageStyle(site: SiteClient, id: string) { const page = await readDraft(site, id); const sections: SectionStyle[] = []; for (const area of AREAS) { sectionsIn(page[area], area, sections); } const own = Object.fromEntries( Object.entries(page).filter( ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo') ) ); return { title: String(page.title), page: lookOf(own), sections };}/** Why a change can't be saved, or null when it can. */function refusal(change: Change): string | null { const [area = '', ...rest] = change.path.split('.'); const field = rest.at(-1) ?? ''; if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) { return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`; } if (/css/i.test(field)) { return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`; } const isColor = COLOR.test(field.replace(/Light$/, '')); if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) { return `"${field}" is not a color, background or style setting.`; } if ( isColor && change.after && !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after) ) { return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`; } return null;}/** * Writes the changes to the page's DRAFT. Each change must still match what the agent read * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent. */export async function saveStyleChanges( site: SiteClient, id: string, changes: readonly Change[]): Promise<string> { const draftId = id.replace(/:published$/, ':draft'); const page = await readDraft(site, draftId); const areas = new Map<string, Record<string, unknown>>(); for (const change of changes) { const refused = refusal(change); if (refused) { return `Not saved: ${refused}`; } const [area = '', ...rest] = change.path.split('.'); if (!areas.has(area)) { areas.set(area, structuredClone(page[area]) as Record<string, unknown>); } let parent = areas.get(area); for (const key of rest.slice(0, -1)) { parent = parent?.[key] as Record<string, unknown> | undefined; } const last = rest.at(-1) ?? ''; if (!parent || String(parent[last] ?? '') !== change.before) { return `Not saved: "${change.path}" changed since you read it. Read the page again.`; } parent[last] = change.after; } await site.pages().updatePage({ id: draftId, sections: areas.get('sections'), aboveSections: areas.get('aboveSections'), belowSections: areas.get('belowSections') }); return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;}/** A private link to a page's draft, for anyone you send it to: no sign-in. */export async function previewLink(site: SiteClient, id: string): Promise<string> { const page = await readDraft(site, id); const shared = await site.pages().sharePage({ id, share: true }); return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;}
import type { FilesystemBackend } from 'deepagents';import type { PageRow } from './content.ts';/** A page, and whether it changed since its memory file was written. */export interface PageStatus extends PageRow { status: 'new' | 'changed' | 'unchanged' | 'next run'; memoryFile: string; // where the agent keeps its notes on this page}/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */export function memoryFileFor(id: string): string { return `/memories/pages/${id.split(':')[0]}.md`;}/** The same file inside the memories backend, which is mounted at /memories/. */const inMemories = (file: string) => file.replace(/^\/memories/, '');/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */async function updatedInMemory(memories: FilesystemBackend, id: string) { const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5); return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');}/** * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the * next run. A changed page's old file is deleted for the style-collector to write again, and the files * of pages that no longer exist are deleted. */export async function compareWithMemory( memories: FilesystemBackend, pages: readonly PageRow[]): Promise<{ pages: PageStatus[]; removed: string[] }> { const statuses: PageStatus[] = []; let toRead = 0; for (const page of pages) { const updated = await updatedInMemory(memories, page.id); let status: PageStatus['status'] = updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed'; if (status !== 'unchanged' && ++toRead > MAX_PAGES) { status = 'next run'; } if (status === 'changed') { await memories.delete(inMemories(memoryFileFor(page.id))); } statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) }); } const live = new Set(statuses.map((page) => inMemories(page.memoryFile))); const removed = ((await memories.ls('/pages/')).files ?? []) .map((file) => file.path) .filter((path) => !live.has(path)); for (const path of removed) { await memories.delete(path); } return { pages: statuses, removed };}
import { randomUUID } from 'node:crypto';import { Command } from '@langchain/langgraph';import type { buildAgent } from './agent.ts';import type { Change } from './content.ts';import type { Usage } from './usage.ts';/** A draft the agent wants to save: the document, and every change to it. */export interface Draft { kind: string; title: string; changes: Change[];}/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */export type Choice = | { type: 'all' } | { type: 'some'; keep: number[] } | { type: 'revise'; feedback: string } | { type: 'skip'; reason: string };/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */export interface Owner { /** The agent is working (a spinner, in the terminal). */ busy?(): void; /** Answers the agent's question. */ answer(question: string): Promise<string>; /** Decides on a draft the agent wants to save. */ decide(draft: Draft): Promise<Choice>;}/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */export const nobody: Owner = { answer: async () => { throw new Error('A report-only run never asks.'); }, decide: async () => { throw new Error('A report-only run never saves.'); }};interface ActionRequest { name: string; args: { kind?: string; id?: string; title?: string; changes?: Change[] };}type Decision = | { type: 'approve' } | { type: 'reject'; message: string } | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);/** * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the * drafts it wants to write. Resolves the agent's closing summary. */export async function runWithOwner( agent: ReturnType<typeof buildAgent>, task: string, owner: Owner, usage?: Usage): Promise<string> { // `usage` counts every model call's tokens and cost, the subagents' too. const config = { configurable: { thread_id: randomUUID() }, recursionLimit: MAX_STEPS, callbacks: usage ? [usage] : [] }; let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] }; for (;;) { owner.busy?.(); const result = await agent.invoke(input, config); // Several rewriters can pause at once: each pause gets its own answer, by its id. const pending = result.__interrupt__ ?? []; if (pending.length === 0) { const last = result.messages.at(-1); return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? ''); } const answers: Record<string, unknown> = {}; for (const { id, value } of pending) { const paused = value as { question?: string; actionRequests?: ActionRequest[] }; if (paused.question) { answers[String(id)] = await owner.answer(paused.question); continue; } const decisions: Decision[] = []; for (const action of paused.actionRequests ?? []) { decisions.push(await review(action, owner)); } answers[String(id)] = { decisions }; } input = new Command({ resume: answers }); }}/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */async function review(action: ActionRequest, owner: Owner): Promise<Decision> { const changes = action.args.changes ?? []; const choice = await owner.decide({ kind: action.args.kind ?? 'page', title: action.args.title ?? '', changes }); switch (choice.type) { case 'all': return { type: 'approve' }; case 'some': { const kept = changes.filter((_, index) => choice.keep.includes(index)); if (kept.length === 0) { return { type: 'reject', message: 'The owner kept none of these changes. Do not try them again.' }; } return { type: 'edit', editedAction: { name: action.name, args: { ...action.args, changes: kept } } }; } case 'revise': return { type: 'reject', message: `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` + 'Revise your changes to match, then call the same tool again with every change.' }; case 'skip': return { type: 'reject', message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.` }; }}
import { stdout } from 'node:process';import { stripVTControlCharacters, styleText } from 'node:util';import * as prompt from '@clack/prompts';import { diffWordsWithSpace } from 'diff';import { Marked, type Tokens } from 'marked';import stringWidth from 'string-width';import yoctoSpinner from 'yocto-spinner';import type { Change } from './content.ts';import type { Choice, Draft, Owner } from './review.ts';import type { Usage } from './usage.ts';/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */export class Stopped extends Error {}/** * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is * doing and what the run has used so far; its words as formatted text. */export function terminalOwner(usage?: Usage): Owner & { step: (line: string) => void; report: (markdown: string) => void; failed: (error: unknown) => void;} { // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers. const spin = yoctoSpinner({ stream: stdout }); // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere // else each one would print a new line, so output sent to a file gets the start and the steps. const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env); let spinning = false; let started = 0; let clock: NodeJS.Timeout | undefined; let steps: string[] = []; let doing = 'The agent is working'; // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped. // On a narrow terminal the tokens and cost go first, then the time, before the task is cut. const line = () => { const seconds = Math.floor((Date.now() - started) / 1000); const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`; const width = (stdout.columns || 80) - 3; const tail = [[spent, time], [time]] .map((parts) => parts.filter(Boolean).join(' · ')) .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? ''; const room = tail ? width - stringWidth(tail) - 2 : width; return tail ? `${fit(doing, room)} ${styleText('dim', tail)}` : fit(doing, room); }; const withUsage = (text: string) => usage ? `${text} ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text; const refresh = () => { if (spinning && live) { spin.text = line(); } }; if (usage) { usage.onCall = refresh; } // The spinner sits on the run's guide line, like the rest of the terminal. const startSpinner = () => { started = Date.now(); spinning = true; stdout.write(`${styleText('gray', prompt.S_BAR)}\n`); spin.start(line()); if (live) { clock = setInterval(refresh, 1000); } }; const stopSpinner = (finish: () => void) => { clearInterval(clock); spin.stop(); spinning = false; finish(); }; // What the agent did since you last heard from it, as one dim line. const settle = () => { if (spinning) { const done = withUsage(styleText('dim', steps.join(' · ') || 'Done')); stopSpinner(() => prompt.log.step(done, { spacing: 0 })); } steps = []; }; return { busy() { if (!spinning) { doing = 'The agent is working'; startSpinner(); } }, step(text) { doing = text.replace(/\.$/, ''); steps.push(doing); refresh(); }, async answer(question) { settle(); say(markdown(question), 'cyan'); return ask(() => prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' }) ); }, async decide(draft) { settle(); say( [ styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`), ...draft.changes.map((change, index) => showChange(change, index)) ].join('\n\n'), 'cyan' ); return decide(draft); }, /** Shows the run's report. */ report(text) { settle(); say(markdown(text), 'green'); }, /** The run stopped on an error: the spinner says so, and the error follows. */ failed(error) { if (spinning) { stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 })); } prompt.log.error(error instanceof Error ? error.message : String(error)); } };}/** "183,204 tokens": read and written together. */function tokens(usage: Usage): string { return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;}/** * Text cut to a number of terminal cells, never through a character (an emoji counts as two). * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji * is 8), so the cut keeps both within the limit. */function fit(text: string, cells: number): string { const size = (part: string) => Math.max(stringWidth(part), part.length); if (size(text) <= cells) { return text; } let cut = ''; for (const { segment } of new Intl.Segmenter().segment(text)) { if (size(`${cut}${segment}…`) > cells) { break; } cut += segment; } return `${cut}…`;}/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */function say(text: string, color: 'cyan' | 'green') { prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });}// The terminal's color and style codes (ESC [ … m), as styleText writes them.const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */export function wrap(text: string, width: number): string { const visible = (line: string) => stripVTControlCharacters(line).length; return text .split('\n') .map((line) => { // A wrapped line sits under the text, past any list number or bullet. const lead = /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? ''; const indent = ' '.repeat(lead.length); const words = line.trimStart().split(' '); const lines: string[] = []; let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? ''); for (const word of words) { if (visible(`${current} ${word}`) > width && current.trim()) { // The colors in force so far carry onto the next line: the guide line resets them. const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? ''; lines.push(`${current}\x1b[0m`); current = indent + colors + word; } else { current = `${current} ${word}`; } } lines.push(current); return lines.join('\n'); }) .join('\n');}/** Starts and ends the run's frame in the terminal. */export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));export const outro = (line: string) => prompt.outro(line);export const note = (line: string) => prompt.log.info(line);async function decide(draft: Draft): Promise<Choice> { const many = draft.changes.length > 1; const choice = await ask(() => prompt.select({ message: 'Your call', options: [ { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' }, ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []), { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' }, { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' } ] }) ); if (choice === 'some') { const keep = await ask(() => prompt.multiselect({ message: 'Keep these (Space to toggle, Enter to save)', options: draft.changes.map((change, index) => ({ value: index, label: `${index + 1}. ${plain(change.after)}` })), initialValues: draft.changes.map((_, index) => index), required: false }) ); return { type: 'some', keep }; } if (choice === 'revise') { return { type: 'revise', feedback: await ask(() => prompt.text({ message: 'What should change?', validate: (value) => (value?.trim() ? undefined : 'Say what should change.') }) ) }; } if (choice === 'skip') { return { type: 'skip', reason: await ask(() => prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' }) ) }; } return { type: 'all' };}/** Waits for one prompt; Ctrl+C or Esc stops the run. */async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> { const value = await show(); if (prompt.isCancel(value)) { throw new Stopped('You stopped the run.'); } return value as Exclude<T, symbol>;}/** * One change, numbered: the text before and after, whole, with the words that change marked (red * and struck through before, green after). Still readable with the colors gone, pasted elsewhere. */export function showChange(change: Change, index: number): string { const parts = diffWordsWithSpace(change.before, change.after); const kept = parts.filter((part) => !part.added && !part.removed); const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`; // Mostly rewritten: the old text in red and the new in green, with no marks to read through. if ( kept .map((part) => part.value) .join('') .trim().length < change.after.length / 2 ) { return [ label, ` ${styleText('dim', 'before')} ${styleText('red', change.before)}`, ` ${styleText('dim', 'after ')} ${styleText('green', change.after)}` ].join('\n'); } const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) => parts .filter((part) => !part[drop]) .map((part) => part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value) ) .join(''); return [ label, ` ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`, ` ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}` ].join('\n');}/** Rich text's HTML tags, dimmed, so the words stand out. */const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));/** Text with its HTML tags taken out, on one line, for a short label. */function plain(text: string, max = 70): string { const flat = text .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;}// The agent writes Markdown. This renders it for the terminal, with a blank line after every// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.const terminal = new Marked({ renderer: { heading({ tokens }) { return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`; }, paragraph({ tokens }) { return `${this.parser.parseInline(tokens)}\n\n`; }, list(list: Tokens.List) { const start = typeof list.start === 'number' ? list.start : 1; return list.items .map((item, index) => { const bullet = list.ordered ? `${start + index}.` : '•'; const body = this.parser.parse(item.tokens).trim(); // Lines under the first are indented to sit under its text. const pad = ' '.repeat(bullet.length + 1); return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`; }) .join(''); }, text(token) { return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text; }, strong({ tokens }) { return styleText('bold', this.parser.parseInline(tokens)); }, em({ tokens }) { return styleText('italic', this.parser.parseInline(tokens)); }, codespan({ text }) { return styleText('cyan', text); }, code({ text }) { return `${styleText('cyan', text)}\n\n`; }, blockquote({ tokens }) { return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`; }, link({ href, tokens }) { const text = this.parser.parseInline(tokens); return text === href ? styleText('underline', href) : `${text} ${styleText('dim', `(${href})`)}`; }, hr() { return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`; }, br() { return '\n'; }, html({ text }) { return text; } }});/** The agent's Markdown, formatted for the terminal. */export function markdown(text: string): string { return String(terminal.parse(text, { async: false })).trim();}
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';import { AIMessage } from '@langchain/core/messages';import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';/** * Counts every model call in a run, the subagents' too: tokens read and written, and what * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call. */export class Usage extends BaseCallbackHandler { name = 'usage'; calls = 0; inputTokens = 0; outputTokens = 0; /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */ cost = 0; /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */ uncosted = 0; /** Called after each model call, e.g. to refresh a running total on screen. */ onCall?: () => void; override handleLLMEnd(output: LLMResult) { this.calls += 1; const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message; const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined; this.inputTokens += usage?.input_tokens ?? 0; this.outputTokens += usage?.output_tokens ?? 0; const cost = output.llmOutput?.tokenUsage?.cost; if (typeof cost === 'number') { this.cost += cost; } else { this.uncosted += 1; } this.onCall?.(); } /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */ line(): string { const count = new Intl.NumberFormat('en-US'); const parts = [ `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`, `${count.format(this.inputTokens)} tokens in`, `${count.format(this.outputTokens)} out`, `$${this.cost.toFixed(4)}` ]; const line = parts.join(' · '); return this.uncosted > 0 ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)` : line; } /** The run's usage, as a Markdown section for the report and its email. */ markdown(): string { return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`; }}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { previewLink } from './content.ts';import type { Usage } from './usage.ts';/** A draft the run saved, and the private link that previews it. */export interface WaitingDraft { title: string; link: string;}/** Every page the run saved a draft of (id → title), each with its preview link. */export async function draftsWaiting( site: SiteClient, saved: Map<string, string>): Promise<WaitingDraft[]> { const drafts: WaitingDraft[] = []; for (const [id, title] of saved) { drafts.push({ title, link: await previewLink(site, id) }); } return drafts;}/** * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then * what the run used (tokens and cost). */export function reportMarkdown( summary: string, drafts: readonly WaitingDraft[], usage?: Usage, date = new Date()) { // The site's host for the heading; a missing or mistyped URL still gets a report that says why. const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site'; const lines = [ `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`, '', summary.trim() ]; if (drafts.length > 0) { lines.push('', '## Drafts waiting for you', ''); lines.push('Publish each one in the CMS when it looks right.', ''); for (const draft of drafts) { lines.push(`- "${draft.title}", preview: ${draft.link}`); } } if (usage) { lines.push('', usage.markdown()); } return `${lines.join('\n')}\n`;}
import { createSiteClient } from '@fetchski/attract-cms-sdk';import { buildAgent, modelFromEnv } from './agent.ts';import { draftsWaiting, reportMarkdown } from './report.ts';import { runWithOwner } from './review.ts';import { intro, outro, Stopped, terminalOwner } from './terminal.ts';import { newRun } from './tools.ts';import { Usage } from './usage.ts';// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .envconst site = createSiteClient();const run = newRun();const usage = new Usage();const owner = terminalOwner(usage);const agent = buildAgent(site, modelFromEnv(), run, owner.step);intro('One design');let summary: string;try { summary = await runWithOwner( agent, 'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.', owner, usage );} catch (error) { if (!(error instanceof Stopped)) { owner.failed(error); throw error; } // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them. summary = 'You stopped the run before it finished.';}const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);owner.report(report);outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
import { deepStrictEqual, ok, strictEqual } from 'node:assert';import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { test } from 'node:test';import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';import { AIMessage } from '@langchain/core/messages';import type { LLMResult } from '@langchain/core/outputs';import { fakeModel } from 'langchain';import { buildAgent } from '../src/agent.ts';import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';import { newRun, siteTools } from '../src/tools.ts';import { Usage } from '../src/usage.ts';const HOME = 'page-home:en:draft';const ABOUT = 'page-about:en:draft';const MONDAY = '2026-10-26T09:00:00.000Z';const TUESDAY = '2026-10-27T09:00:00.000Z';// A fresh memory folder, per test.const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));function area(widget: Record<string, unknown>) { return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };}// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.const PAGES: Record<string, ReturnType<typeof factories.Page>> = { [HOME]: factories.Page({ id: HOME, title: 'Home', sections: area({ type: 'intro', introBg: 'raised', introEyebrowColor: '--fetchski-mint', introCtaRadius: 'slight' }) }), [ABOUT]: factories.Page({ id: ABOUT, title: 'About us', sections: area({ type: 'media-text', mediaTextBg: 'custom', mediaTextBgCustom: '#ffffff', mediaTextHeadingColor: '#777777', mediaTextCtaRadius: 'pill' }) })};function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) { return fakeSite({ 'pages.listPages': factories.ListPagesResponse({ results: Object.entries(updated).map(([id, updatedAt]) => ({ id, title: String(PAGES[id]?.title), type: 'default-page', updatedAt: new Date(updatedAt) })) }), 'pages.getPage': (args) => PAGES[String(args.id)], 'globalContent.listGlobalContent': factories.GlobalList({ results: [ { paletteSecondary: '#2dd4bf', fontDisplaySource: 'curated', fontDisplayCurated: 'fraunces' } ], pages: 1, currentPage: 1 }) });}const HEADING_FIX = { path: 'sections.items.0.mediaTextHeadingColor', before: '#777777', after: '--fetchski-ink'};const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.function scriptedModel() { return ( fakeModel() .respondWithTools([ { name: 'read_site_design', args: {} }, { name: 'list_pages', args: {} } ]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) // The style-collector subagent: .respondWithTools([ { name: 'read_page_style', args: { id: HOME } }, { name: 'read_page_style', args: { id: ABOUT } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-home.md', content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised` } }, { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777` } } ]) .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md')) // The main agent again: .respondWithTools([ { name: 'task', args: { subagent_type: 'contrast-checker', description: 'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md' } } ]) // The contrast-checker subagent: .respondWithTools([ { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } } ]) .respondWithTools([ { name: 'check_contrast', args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/findings/contrast-1.md', content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5' } } ]) .respond(new AIMessage('/findings/contrast-1.md')) // The main agent again: .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/site.md', content: '# Site design\n- Buttons: slight corners' } } ]) .respondWithTools([ { name: 'ask_owner', args: { question: 'Home has slight button corners, About us has pill. Which is yours?' } } ]) .respondWithTools([ { name: 'save_style_changes', args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] } } ]) .respond( new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.') ) );}// The person at the keyboard, scripted: their answers to questions, then their call on each draft.function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } { const drafts: Draft[] = []; return { drafts, answer: async () => answers.shift() ?? '', decide: async (draft) => { drafts.push(draft); return choices.shift() ?? { type: 'skip', reason: '' }; } };}test('a first run collects every page into memory, and saves an approved fix as a draft', async () => { const site = siteWithTwoDesigns(); const folder = memoryFolder(); const run = newRun(folder); const owner = scriptedOwner(['Slight'], [{ type: 'all' }]); const summary = await runWithOwner( buildAgent(site, scriptedModel(), run, () => {}), 'Check this whole website.', owner ); strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.'); site.assertCalled('pages.updatePage', (args) => { const section = (args.sections as { items: Record<string, unknown>[] }).items[0]; return ( args.id === ABOUT && section?.mediaTextHeadingColor === '--fetchski-ink' && section.mediaTextCtaRadius === 'slight' ); }); deepStrictEqual([...run.saved], [[ABOUT, 'About us']]); deepStrictEqual( owner.drafts.map((draft) => draft.title), ['About us'] ); ok(existsSync(join(folder, 'pages', 'page-home.md'))); ok(existsSync(join(folder, 'site.md'))); // The next run finds both pages unchanged, from their memory files alone. const next = newRun(folder); await siteTools(site, next, () => {}).listPages.invoke({}); deepStrictEqual( next.pages.map((page) => page.status), ['unchanged', 'unchanged'] );});test('a fix the owner skips is never written', async () => { const site = siteWithTwoDesigns(); await runWithOwner( buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}), 'Check this whole website.', scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }]) ); site.assertNotCalled('pages.updatePage');});test('a report-only second run collects only the changed page, and never asks or writes', async () => { // The last run scanned Home, About us and a page since deleted. Since then, only About us changed. const folder = memoryFolder(); mkdirSync(join(folder, 'pages')); for (const name of ['page-home', 'page-about', 'page-old']) { writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`); } writeFileSync( join(folder, 'site.md'), '# Site design\n- Buttons: slight corners, the owner chose' ); const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY }); const run = newRun(folder); const model = fakeModel() .respondWithTools([{ name: 'list_pages', args: {} }]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us, as it is now\nupdated: ${TUESDAY}\n` } } ]) .respond(new AIMessage('/memories/pages/page-about.md')) .respond(new AIMessage('## Contrast\nNo failures.')); const summary = await runWithOwner( buildAgent(site, model, run, () => {}, { reportOnly: true }), 'Check this whole website, and report what you find.', nobody ); strictEqual(summary, '## Contrast\nNo failures.'); deepStrictEqual( run.pages.map((page) => [page.title, page.status]), [ ['Home', 'unchanged'], ['About us', 'changed'] ] ); site.assertCalledTimes('pages.getPage', 1); site.assertCalled('pages.getPage', (args) => args.id === ABOUT); site.assertNotCalled('pages.updatePage'); strictEqual( readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'), `# About us, as it is now\nupdated: ${TUESDAY}\n` ); strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false); // site.md, with the owner's choice, was in the agent's instructions from the start. const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? ''; ok(instructions.includes('Buttons: slight corners, the owner chose'));});test('check_contrast measures with the WCAG formula, in code', async () => { const palette = resolvePalette({}); strictEqual(contrastRatio('#000000', '#ffffff'), 21); strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48); deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), { theme: 'light', text: '#777777', background: '#ffffff', ratio: 4.47, needs: 4.5, passes: false }); const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette); ok('passes' in large && large.passes); // Through the tool: palette tokens resolve with the site's own palette, in the theme asked. const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {}); const mint = JSON.parse( await tool.invoke({ text: '--fetchski-mint', background: 'page', theme: 'dark', size: 'normal' }) ); deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]); const overVideo = JSON.parse( await tool.invoke({ text: '#ffffff', background: 'image or video', theme: 'dark', size: 'large' }) ); deepStrictEqual(overVideo, { theme: 'dark', measured: false, why: 'background: the background is an image or video' });});test('usage adds up tokens and the cost OpenRouter reports for each call', () => { const usage = new Usage(); const call = (input: number, output: number, cost?: number) => usage.handleLLMEnd({ generations: [ [ { text: '', message: new AIMessage({ content: '', usage_metadata: { input_tokens: input, output_tokens: output, total_tokens: input + output } }) } ] ], // OpenRouter's own `usage` object, as ChatOpenRouter passes it on. llmOutput: { tokenUsage: cost === undefined ? {} : { cost } } } as unknown as LLMResult); call(1000, 200, 0.0012); call(3000, 50, 0.0021); call(10, 5); strictEqual( usage.line(), '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)' ); ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));});
# Your site, and the API key from "Make the API key"FETCHSKI_ATTRACT_URL=https://acme.exampleFETCHSKI_ATTRACT_API_KEY=fsk_acme_…FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.OPENROUTER_API_KEY=sk-or-v1-…OPENROUTER_MODEL=~google/gemini-flash-latest# How freely it words things (0 = the same way every time), and the most it writes in one answerOPENROUTER_TEMPERATURE=0OPENROUTER_MAX_TOKENS=16000# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)MAX_PAGES=80AGENT_MAX_STEPS=400
Your CMS has a site agent built in, and it can check and change colors, fonts and CSS. But it works on
one page, or one section, at a time, and it never compares pages. Build ten pages with it and every
page can end up with a design of its own: a gray heading here, a pill-shaped button there, a band in a
color that isn’t in your palette. Each one looked fine on the day. Together they make the site feel
patched, and some of that text is hard to read in the light theme nobody checked.This agent checks the whole site, against itself and against WCAG contrast, and it remembers what it
saw, so you can run it every week and it reads only the pages someone changed.
What you’ll build
A small TypeScript program built on LangChain Deep Agents.
The SDK’s calls are the agent’s tools. It reads Site design (your palette in both themes, your
fonts) and lists every page. A style-collector subagent reads the pages in batches and writes one
memory file per page: each section’s background, every color in both themes, its button and font
settings, its custom CSS. A contrast-checker subagent checks every text color against its
background with a tool that does the WCAG math in code, never in the model. The agent then compares
every page with site.md, the site’s own design as most pages use it, and finds the outliers.Where your site has two patterns, it asks you which one you meant. It proposes the fixes a section’s
own settings can make, one page at a time, each change’s value before and after: save it, keep only
some of its changes, tell the agent what to change (it tries again), or skip it. A saved fix is a
draft. Nothing goes live until you publish it in the CMS. As it works, the terminal shows what the
run has used, in tokens and dollars, and the report ends with the total.The agent’s long-term memory is Deep Agents memory:
files under /memories/, kept in a memories folder in your project. site.md is loaded into the
agent’s instructions at the start of every run. Each page’s memory file records when the page last
changed, so the next run collects only the pages that are new or changed, and forgets pages you
deleted. A first run reads everything.
What it can and can’t measure. It checks the colors your site is set to, in both themes, with your
palette filling in every color a section leaves to it. It can’t see a page as a visitor does: text over
a background image or video, and colors that are partly see-through, can’t be measured, so the
report lists them under Check by eye. A color a section leaves empty follows the palette, and the
palette’s own pairs (text on the page, text on a card, text on your accent color) are checked once for
the whole site. Custom CSS is read and compared, not measured.
Before you start, you need:
Node.js 26.12.1 or later and one of npm, pnpm or Bun.
A person who may manage API keys on the site, to make the key below.
An OpenRouter account with credit, and a key made at
openrouter.ai/settings/keys. Give that key a credit
limit (say $5): it is the hard ceiling on what any run can spend.
Money: with the model set below (~google/gemini-flash-latest), a first run over a 50-page site costs about $0.50 to $2.
Later runs read only the pages that changed, so they cost less; a weekly check that only reports
costs less again (Going further).
Time: about 20 minutes to set up; a first run takes 10 to 20 minutes plus your answers.
Make the API key
Open API Keys
Sign in to your site’s CMS, press ⌘K (Ctrl+K on Windows),
type API Keys and press Enter. Click Create API Key.You should see: the Create API Key form: Label, Scopes, Expires, Request signing.
Name it
Label:One design.
Pick the Design fixes pack
Under Scopes, keep Start from a pack and pick the Design fixes pack. It ticks these
two scopes, and only these:
Tick
Scope
Why
Pages: write drafts
content:pages:write
Lists and reads the pages’ drafts (with when each last changed), saves the fixes to them, and makes their preview links.
Read site settings
global:read
Reads Site design: your palette in both themes, your fonts and the site-wide custom CSS. It can’t change them.
Don’t add a publish or delete scope: without one the key can’t put anything
live, so even a confused agent can’t change what visitors see. You publish, in the CMS.Don’t add Write custom CSS (global:css) or Change site settings (global:write)
either. A section’s custom CSS can only be written with both, and Change site settings would
let the key change your whole palette and every site setting. So the agent never writes CSS,
your palette or your fonts: it suggests those changes in its report, for you to make in Site
design, or with the CMS’s built-in site agent, which refuses a palette change that fails
contrast.Check Granted before you create the key: it should list exactly the 2 scopes above.
Your site’s packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with Edit scopes, or have someone who manages API keys choose Restore default
packs in Scope Packs.No Design fixes pack in the list? Someone who manages API keys restores it: ⌘K, Scope
Packs, Restore default packs. Or choose Pick scopes one by one and tick the two scopes
above.
Sign its writes
Request signing:Token + signature on writes. The SDK signs every write for you with the
key’s signing secret, so a stolen token alone can’t change your drafts.
Create it, and copy both secrets
Click Create key.You should see: the Secret token (fsk_acme_…) and the Signing secret (fss_…),
each with a Copy button. They are shown once: copy both now. The token goes into .npmrc
and both into .env, below.
Why: the packages and their versions. deepagents is LangChain’s Deep Agents,
@langchain/openrouter its OpenRouter chat model, @fetchski/attract-cms-sdk the SDK.
@clack/prompts, diff,
marked, string-width and
yocto-spinner draw the conversation in your
terminal. Node runs
the TypeScript files directly (node src/main.ts), so there is no build step; TypeScript is only
for checking.
Point the @fetchski scope at your site
Do this in one-design, with the Secret token you copied above.Your own site serves the @fetchski packages, and any API key from it lets you in. In your
project’s folder (beside its package.json), make a file named .npmrc holding these two lines:
The first line says @fetchski packages come from your site; the second is the key to sign in with.
Put your site’s address in both lines (set your site in the bar under this page’s title and the
example shows it), and paste your whole key in place of fsk_acme_…. Write the key itself, never a
variable or a value from .env: npm and pnpm don’t read .env, and pnpm refuses a variable here.
Any API key from your site works, the same one your app uses included: its scopes and signing
mode don’t matter for installing, only that it’s switched on and not past its expiry date. No key
yet? Make one in the CMS under API Keys (Authentication).
Then keep the file out of git. Add this line to the project’s .gitignore (make the file if
there isn’t one):
.gitignore
.npmrc
.npmrc now holds a key to your site: never commit it. If git already tracks a .npmrc, stop
tracking it with git rm --cached .npmrc. Keep these lines in this project’s .npmrc only, never
in the .npmrc in your home folder. In CI, write the same file from a secret before installing.
Why: your site serves the Fetchski SDK, so the install asks it for every @fetchski/…
package, with your key.You should see:.npmrc beside package.json, and .npmrc in .gitignore.
Install
npm install
pnpm install
bun install
Then check that Node finds the two packages that matter most:
You should see:installed. A 401 Unauthorized during the install means your site refused
the key in .npmrc (mistyped, switched off or past its Expires date), or .npmrc isn’t in
one-design.
Why: strict type-checking. noEmit makes tsc only check, never write JavaScript, so the
typecheck script is plain tsc. allowImportingTsExtensions and erasableSyntaxOnly match how Node
runs .ts files itself: imports name the .ts file, and the code uses only types Node can strip.
Add your secrets
Paste the key’s Secret token and Signing secret over fsk_acme_… and fss_…, and your
OpenRouter key over sk-or-v1-…. Your site’s address goes in FETCHSKI_ATTRACT_URL.
.env
# Your site, and the API key from "Make the API key"FETCHSKI_ATTRACT_URL=https://acme.exampleFETCHSKI_ATTRACT_API_KEY=fsk_acme_…FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.OPENROUTER_API_KEY=sk-or-v1-…OPENROUTER_MODEL=~google/gemini-flash-latest# How freely it words things (0 = the same way every time), and the most it writes in one answerOPENROUTER_TEMPERATURE=0OPENROUTER_MAX_TOKENS=16000# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)MAX_PAGES=80AGENT_MAX_STEPS=400
.gitignore
node_modules.env.npmrc
Why: the SDK reads the three FETCHSKI_ATTRACT_* values on its own, and the OpenRouter model
reads OPENROUTER_API_KEY. .gitignore keeps .env and .npmrc (your keys) out of git.The other values are yours to change, and every one has a default in the code:
Setting
What it does
OPENROUTER_MODEL
The model the agent thinks with. Any model from openrouter.ai/models that supports tools works. ~google/gemini-flash-latest is an alias: OpenRouter always serves the newest Gemini Flash under it, so the project never names a model that gets retired. Swap in ~anthropic/claude-sonnet-latest or ~openai/gpt-mini-latest the same way.
OPENROUTER_TEMPERATURE
How freely the model words things. 0 gives the same answer to the same question, which is what a check wants.
OPENROUTER_MAX_TOKENS
The most the model writes in one answer. 16000 leaves room for a long memory file and for the thinking a model does before it answers; a run’s cost is capped by the key’s credit limit and the step ceiling, not by this.
MAX_PAGES
The most new or changed pages one run reads. The rest wait for the next run, so a big site is covered over a few runs.
AGENT_MAX_STEPS
The most steps one run takes (each model call or tool call is one).
Every value in .env is text; the code turns the numbers into numbers with Number().
Add src/design.ts: the contrast math
src/design.ts
/** The site's two themes. Dark is the default; visitors can switch to light. */export type Theme = 'dark' | 'light';/** Each palette token's color in each theme, as `#rrggbb`. */export type Palette = Record<Theme, Record<string, string>>;/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */export type Size = 'normal' | 'large' | 'ui';// The colors every site starts with, per palette token: [dark, light]. Site design → Colors// overrides them; a field left empty there keeps these.const BUILT_IN: Record<string, [string, string]> = { accent: ['#625bff', '#544dee'], 'on-accent': ['#ffffff', '#ffffff'], mint: ['#34ddb3', '#0fa380'], coral: ['#ff8e63', '#d8551f'], surface: ['#09090b', '#e4e4e7'], raised: ['#18181b', '#d9d9de'], sunken: ['#0e0e11', '#cfcfd6'], hill: ['#121215', '#d8d8dd'], ink: ['#f4f4f5', '#18181b'], 'ink-muted': ['#a1a1aa', '#52525b'], success: ['#34d399', '#059669'], warning: ['#fbbf24', '#d97706'], danger: ['#f87171', '#dc2626'], info: ['#60a5fa', '#2563eb']};// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.const PALETTE_FIELDS = { paletteAccent: 'accent', paletteOnAccent: 'on-accent', paletteSecondary: 'mint', paletteTertiary: 'coral', paletteLink: 'link', paletteSurface: 'surface', paletteRaised: 'raised', paletteSunken: 'sunken', paletteInk: 'ink', paletteInkMuted: 'ink-muted', paletteSuccess: 'success', paletteWarning: 'warning', paletteDanger: 'danger', paletteInfo: 'info'} as const;const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [ field, `${field}Light`]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];// The text and background pairs every page relies on: [text token, background token].const PALETTE_PAIRS: [string, string][] = [ ['ink', 'surface'], ['ink', 'raised'], ['ink-muted', 'surface'], ['on-accent', 'accent'], ['link', 'surface']];/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */function hex(value: string): string | null { const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim()); if (!match?.[1]) { return null; } const digits = match[1].slice(0, 6); const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits; return `#${full.toLowerCase()}`;}/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */function blend(top: string, bottom: string, share: number): string { const channels = (color: string) => [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16)); const [a, b] = [channels(top), channels(bottom)]; const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share))); return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;}/** The palette the site renders, from Global's palette fields over the built-in colors. */export function resolvePalette(fields: Record<string, unknown>): Palette { const palette: Palette = { dark: {}, light: {} }; const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null); for (const [field, token] of Object.entries(PALETTE_FIELDS)) { const [darkDefault, lightDefault] = BUILT_IN[token] ?? []; const dark = set(field) ?? darkDefault; const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault; if (dark) palette.dark[token] = dark; if (light) palette.light[token] = light; } for (const theme of ['dark', 'light'] as const) { const colors = palette[theme]; colors.link ??= colors.accent ?? ''; colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? ''; // The soft accent tint is the accent, see-through, over the page. colors['accent-soft'] = blend( colors.accent ?? '', colors.surface ?? '', theme === 'dark' ? 0.16 : 0.14 ); } return palette;}/** The palette fields this site changed: the rest are the built-in colors. */export function customizedFields(fields: Record<string, unknown>): string[] { return PALETTE_FIELD_NAMES.filter( (name) => typeof fields[name] === 'string' && fields[name] !== '' );}/** * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`), * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through). * Answers `#rrggbb`, or why it can't be measured. */export function colorOf( value: string, theme: Theme, palette: Palette): { color: string } | { why: string } { const text = value.trim(); if (!text || text === 'default') { return { why: 'not set: it follows the palette' }; } if (text === 'image or video') { return { why: 'the background is an image or video' }; } const token = /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text); const fromPalette = palette[theme][token]; if (fromPalette) { return { color: fromPalette }; } const color = hex(text); if (color) { return { color }; } if (/^#[0-9a-f]{8}$/i.test(text)) { return { why: `${text} is partly see-through, so it depends on what is behind it` }; } return { why: `"${text}" is not a plain color` };}function channel(value: number): number { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;}/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */export function luminance(color: string): number { const n = Number.parseInt(color.slice(1), 16); return ( 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255) );}/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */export function contrastRatio(a: string, b: string): number { const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (light + 0.05) / (dark + 0.05);}/** One text and background pair, checked against WCAG AA. */export type ContrastResult = | { theme: Theme; text: string; background: string; ratio: number; needs: number; passes: boolean; } | { theme: Theme; measured: false; why: string };/** Checks a text color against its background in one theme. The math is here, never the model's. */export function checkContrast( text: string, background: string, theme: Theme, size: Size, palette: Palette): ContrastResult { const fore = colorOf(text, theme, palette); const back = colorOf(background, theme, palette); if ('why' in fore || 'why' in back) { const why = 'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`; return { theme, measured: false, why }; } const ratio = contrastRatio(fore.color, back.color); const needs = size === 'normal' ? 4.5 : 3; return { theme, text: fore.color, background: back.color, ratio: Math.floor(ratio * 100) / 100, needs, passes: ratio >= needs };}/** The palette's own text pairs, checked in both themes. */export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] { return (['dark', 'light'] as const).flatMap((theme) => PALETTE_PAIRS.map(([text, background]) => ({ pair: `${text} on ${background}`, ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette) })) );}
Why: the part a model must never guess. contrastRatio() is the WCAG 2.2 formula (relative
luminance, then (lighter + 0.05) / (darker + 0.05)), and checkContrast() holds it to AA: 4.5:1
for normal text, 3:1 for large text and for icons and borders. resolvePalette() turns Site
design’s palette into a hex color per token and theme, the way the site paints it: a field you
left empty keeps the color every site starts with, a light-theme color left empty borrows the dark
one (except the page, card and text colors, which have their own light defaults), and an empty
link color follows the accent. colorOf() reads a color the way a section stores it: a hex color,
a palette token (--fetchski-accent), a background preset (raised), or page when the section
has no band of its own. Anything it can’t measure, it says why.
Add src/content.ts: the SDK calls
src/content.ts
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';/** A page, and when its draft last changed. */export interface PageRow { id: string; // the draft's id, `…:en:draft` title: string; updatedAt: string;}/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */export interface ColorField { field: string; dark: string; light: string;}/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */export interface SectionStyle { path: string; type: string; background: { dark: string; light: string } | null; colors: ColorField[]; settings: Record<string, unknown>; customCss: string;}/** One change to a section's setting: the value at `path` goes from `before` to `after`. */export interface Change { path: string; before: string; after: string;}// The page fields that hold sections. Fixes are made inside these only.const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;// Settings that are part of a section's look (colors and backgrounds are read on their own).const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;const COLOR = /Color$/;const BACKGROUND = /Bg(Light)?(Custom)?$/;/** Every page on the site, as drafts: the latest version, published or not. */export async function listPages(site: SiteClient): Promise<PageRow[]> { const pages = await site .pages() .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' }); return pages .filter((page) => page.type !== '@apostrophecms/archive-page') .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));}/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */export async function readSiteDesign(site: SiteClient) { const fonts = ['Display', 'Body', 'Accent'] as const; const fontFields = fonts.flatMap((slot) => [ `font${slot}Source`, `font${slot}Curated`, `font${slot}CdnFamily` ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[]; const [global = {}] = await site .globalContent() .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']); const fields = global as Record<string, unknown>; const font = (slot: string) => { const source = String(fields[`font${slot}Source`] ?? 'default'); if (source === 'curated') return String(fields[`font${slot}Curated`]); if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]); return source === 'upload' ? 'uploaded font files' : 'the built-in font'; }; return { palette: resolvePalette(fields) satisfies Palette, customized: customizedFields(fields), fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') }, customCss: cssOf(fields.designCss) };}/** A custom CSS field's CSS: the site stores `{ css }`. */function cssOf(value: unknown): string { if (typeof value === 'string') return value; return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';}/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] { for (const [key, value] of Object.entries(widget)) { if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) { return { dark: 'image or video', light: 'image or video' }; } } const select = Object.keys(widget).find((key) => key.endsWith('Bg')); if (!select) { return null; } const pick = (choice: unknown, custom: unknown) => choice === 'custom' ? String(custom ?? '') : !choice || choice === 'transparent' ? 'page' : String(choice); const dark = pick(widget[select], widget[`${select}Custom`]); const lightChoice = widget[`${select}Light`]; const light = !lightChoice || lightChoice === 'inherit' ? dark : pick(lightChoice, widget[`${select}LightCustom`]); return { dark, light };}/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */function lookOf(fields: Record<string, unknown>) { const colors: ColorField[] = []; const settings: Record<string, unknown> = {}; for (const [key, value] of Object.entries(fields)) { if (key.startsWith('_') || value === '' || value === null || value === undefined) continue; const field = key.replace(/Light$/, ''); if (COLOR.test(field)) { if (!colors.some((color) => color.field === field)) { const dark = String(fields[field] ?? ''); colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) }); } } else if (SETTING.test(key) && !BACKGROUND.test(key)) { if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) { settings[key] = value; } } } return { colors, settings };}/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) { const items = (area as { items?: unknown[] } | undefined)?.items ?? []; for (const [index, item] of items.entries()) { const widget = item as Record<string, unknown>; const at = `${path}.items.${index}`; const { colors, settings } = lookOf(widget); const section = { path: at, type: String(widget.type), background: backgroundOf(widget), colors, settings, customCss: cssOf(widget.sectionCss) }; if ( !nested || section.background || colors.length || Object.keys(settings).length || section.customCss ) { into.push(section); } for (const [key, value] of Object.entries(widget)) { if ((value as { metaType?: string } | null)?.metaType === 'area') { sectionsIn(value, `${at}.${key}`, into, true); } } }}/** A page's draft, as a plain object. */async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> { return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };}/** A page's look: its own style settings, then every section's. */export async function readPageStyle(site: SiteClient, id: string) { const page = await readDraft(site, id); const sections: SectionStyle[] = []; for (const area of AREAS) { sectionsIn(page[area], area, sections); } const own = Object.fromEntries( Object.entries(page).filter( ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo') ) ); return { title: String(page.title), page: lookOf(own), sections };}/** Why a change can't be saved, or null when it can. */function refusal(change: Change): string | null { const [area = '', ...rest] = change.path.split('.'); const field = rest.at(-1) ?? ''; if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) { return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`; } if (/css/i.test(field)) { return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`; } const isColor = COLOR.test(field.replace(/Light$/, '')); if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) { return `"${field}" is not a color, background or style setting.`; } if ( isColor && change.after && !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after) ) { return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`; } return null;}/** * Writes the changes to the page's DRAFT. Each change must still match what the agent read * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent. */export async function saveStyleChanges( site: SiteClient, id: string, changes: readonly Change[]): Promise<string> { const draftId = id.replace(/:published$/, ':draft'); const page = await readDraft(site, draftId); const areas = new Map<string, Record<string, unknown>>(); for (const change of changes) { const refused = refusal(change); if (refused) { return `Not saved: ${refused}`; } const [area = '', ...rest] = change.path.split('.'); if (!areas.has(area)) { areas.set(area, structuredClone(page[area]) as Record<string, unknown>); } let parent = areas.get(area); for (const key of rest.slice(0, -1)) { parent = parent?.[key] as Record<string, unknown> | undefined; } const last = rest.at(-1) ?? ''; if (!parent || String(parent[last] ?? '') !== change.before) { return `Not saved: "${change.path}" changed since you read it. Read the page again.`; } parent[last] = change.after; } await site.pages().updatePage({ id: draftId, sections: areas.get('sections'), aboveSections: areas.get('aboveSections'), belowSections: areas.get('belowSections') }); return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;}/** A private link to a page's draft, for anyone you send it to: no sign-in. */export async function previewLink(site: SiteClient, id: string): Promise<string> { const page = await readDraft(site, id); const shared = await site.pages().sharePage({ id, share: true }); return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;}
Why: everything the agent does to your site, in plain functions over the SDK. listPages()
lists every page’s draft with the cheap listPagesPick() (only ids, titles and updatedAt, when
the page last changed; see Find an id). readSiteDesign() reads the palette, fonts
and site-wide CSS from Site design. readPageStyle() reads a page’s draft and keeps only its look:
each section’s background in both themes (a …Light field left empty uses the dark one), every
color field with its …Light twin, its button, font and size settings, and its custom CSS.
saveStyleChanges() writes the draft, and refuses if a value changed since the agent read it, so
it never overwrites an editor. It changes only a section’s colors, backgrounds and style settings:
it refuses custom CSS, page settings and Site design, with the reason, so the agent puts those in
its report.
Add src/memory.ts: what changed since the last run
src/memory.ts
import type { FilesystemBackend } from 'deepagents';import type { PageRow } from './content.ts';/** A page, and whether it changed since its memory file was written. */export interface PageStatus extends PageRow { status: 'new' | 'changed' | 'unchanged' | 'next run'; memoryFile: string; // where the agent keeps its notes on this page}/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */export function memoryFileFor(id: string): string { return `/memories/pages/${id.split(':')[0]}.md`;}/** The same file inside the memories backend, which is mounted at /memories/. */const inMemories = (file: string) => file.replace(/^\/memories/, '');/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */async function updatedInMemory(memories: FilesystemBackend, id: string) { const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5); return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');}/** * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the * next run. A changed page's old file is deleted for the style-collector to write again, and the files * of pages that no longer exist are deleted. */export async function compareWithMemory( memories: FilesystemBackend, pages: readonly PageRow[]): Promise<{ pages: PageStatus[]; removed: string[] }> { const statuses: PageStatus[] = []; let toRead = 0; for (const page of pages) { const updated = await updatedInMemory(memories, page.id); let status: PageStatus['status'] = updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed'; if (status !== 'unchanged' && ++toRead > MAX_PAGES) { status = 'next run'; } if (status === 'changed') { await memories.delete(inMemories(memoryFileFor(page.id))); } statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) }); } const live = new Set(statuses.map((page) => inMemories(page.memoryFile))); const removed = ((await memories.ls('/pages/')).files ?? []) .map((file) => file.path) .filter((path) => !live.has(path)); for (const path of removed) { await memories.delete(path); } return { pages: statuses, removed };}
Why: the change detection, in code, so it never depends on the model. It reads the agent’s
own memory files through the same Deep Agents backend the agent writes them with. Each page’s
file starts with an updated: line, the page’s updatedAt when it was collected.
compareWithMemory() marks each page new (no file yet), changed (its updatedAt moved)
or unchanged, deletes the old file of a page that changed so the style-collector writes it
again, and deletes the files of pages that no longer exist. A run that stops halfway leaves the
pages it didn’t reach without a current file, so the next run collects them. Past MAX_PAGES new
or changed pages, the rest are marked next run. A draft the agent saves changes the page’s
updatedAt too, so the next run reads that page again, as it now looks.
Add src/tools.ts: the SDK calls as the agent's tools
src/tools.ts
import { resolve } from 'node:path';import type { SiteClient } from '@fetchski/attract-cms-sdk';import { interrupt } from '@langchain/langgraph';import { FilesystemBackend } from 'deepagents';import { tool } from 'langchain';import { z } from 'zod';import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';import { checkContrast, paletteContrast } from './design.ts';import { compareWithMemory, type PageStatus } from './memory.ts';/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */export interface Run { memories: FilesystemBackend; pages: PageStatus[]; saved: Map<string, string>;}/** * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the * Deep Agents FilesystemBackend the agent sees as /memories/. */export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run { const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true }); return { memories, pages: [], saved: new Map() };}/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) { // Site design is read once a run, by whichever tool needs it first. let design: ReturnType<typeof readSiteDesign> | undefined; const siteDesign = () => { design ??= readSiteDesign(site); return design; }; const listPagesTool = tool( async () => { const { pages, removed } = await compareWithMemory(run.memories, await listPages(site)); run.pages = pages; const count = (status: string) => pages.filter((page) => page.status === status).length; log( `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.` ); if (count('next run') > 0) { log(`${count('next run')} more wait for the next run (MAX_PAGES).`); } if (removed.length > 0) { log(`Forgot ${removed.length} page(s) that no longer exist.`); } return JSON.stringify(pages.map(({ updatedAt, ...page }) => page)); }, { name: 'list_pages', description: 'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.', schema: z.object({}) } ); const readSiteDesignTool = tool( async () => { const { palette, customized, fonts, customCss } = await siteDesign(); log('Read Site design.'); return JSON.stringify({ palette, customized, paletteContrast: paletteContrast(palette), fonts, customCss }); }, { name: 'read_site_design', description: 'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' + 'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.', schema: z.object({}) } ); const readPageStyleTool = tool( async (args) => { const style = await readPageStyle(site, args.id); log(`Read the style of "${style.title}".`); // The page's last-modified time, for its memory file: the next run compares it with the page's. const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? ''; return JSON.stringify({ updated, ...style }); }, { name: 'read_page_style', description: "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " + "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.", schema: z.object({ id: z.string().describe('The id from list_pages') }) } ); const checkContrastTool = tool( async (args) => { const { palette } = await siteDesign(); return JSON.stringify( checkContrast(args.text, args.background, args.theme, args.size, palette) ); }, { name: 'check_contrast', description: 'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' + 'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' + 'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.', schema: z.object({ text: z.string().describe('The text (or icon) color'), background: z.string().describe('The background it sits on'), theme: z.enum(['dark', 'light']), size: z .enum(['normal', 'large', 'ui']) .describe('large: headings and big titles; ui: icons and borders') }) } ); const saveStyleChangesTool = tool( async (args) => { const answer = await saveStyleChanges(site, args.id, args.changes); if (answer.startsWith('Saved')) { run.saved.set(args.id, args.title); } return answer; }, { name: 'save_style_changes', description: "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " + 'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' + '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.', schema: z.object({ id: z.string(), title: z.string().describe("The page's title, shown to the owner"), changes: z .array(z.object({ path: z.string(), before: z.string(), after: z.string() })) .min(1) .describe('Every change to this page, each at its path') }) } ); const askOwner = tool( // Pauses the run until the owner answers; their answer is the tool's result. async (args) => String(interrupt({ question: args.question })), { name: 'ask_owner', description: 'Asks the website owner one question, and waits for their answer.', schema: z.object({ question: z.string() }) } ); return { listPages: listPagesTool, readSiteDesign: readSiteDesignTool, readPageStyle: readPageStyleTool, checkContrast: checkContrastTool, saveStyleChanges: saveStyleChangesTool, askOwner };}
Why: each function becomes a LangChain tool, with a zod schema the model fills in.
check_contrast resolves the colors with your site’s palette and answers the ratio, what AA needs
and whether it passes. ask_owner calls LangGraph’s interrupt(): the run pauses, and your typed
answer becomes the tool’s result. newRun() holds one run: the FilesystemBackend that keeps
the agent’s memory in the memories folder, the pages it listed and the drafts it saved.
read_page_style hands the collector the page’s updatedAt to copy into its file.
Add src/agent.ts: the deep agent
src/agent.ts
import type { SiteClient } from '@fetchski/attract-cms-sdk';import type { BaseChatModel } from '@langchain/core/language_models/chat_models';import { MemorySaver } from '@langchain/langgraph';import { ChatOpenRouter } from '@langchain/openrouter';import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';import { todoListMiddleware } from 'langchain';import { type Run, siteTools } from './tools.ts';const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgroundsand button styles on every page, with text people can read. Different pages were built at differenttimes, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.Your long-term memory is /memories/, kept between runs:- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the owner's choices. It is loaded into your instructions at the start of every run.- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each one, and knows from it whether the page changed since.Work in this order:1. Plan your work with write_todos.2. Call read_site_design once, and list_pages once.3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a time: give the id, title and memory file of each. Unchanged pages already have a current memory file; pages marked "next run" wait for a later run, so leave them out of this one.4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from read_site_design, which colors the site changed, the fonts, and the patterns most page files share: section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each outlier with its page, section path and field: a hex color off the palette where the site uses a palette token, a font or font override no other page uses, a button variant or corner unlike the rest, custom CSS that sets colors or fonts the palette already has.`;const FIX = `7. Where the site has two patterns and you can't tell which one is intended (two button corners, two eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the answer into /memories/site.md, so no later run asks again.8. For each page with fixes a section setting can make (a one-off color back to its palette token, a button to the site's pattern, a text color that fails contrast), call save_style_changes once with every change on that page. The owner approves each page. If the owner sends one back with notes, change it to match and call save_style_changes again; if they skip one, don't try again. Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs andbetween list items.`;const REPORT_ONLY = `7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).`;const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,then write its memory file (the path you are given) with write_file, in this shape:# <title>id: <id>updated: <updated, copied exactly>Page: <its own colors and settings, or none>## <section path> (<section type>)- background: dark <value>, light <value>- <field>: dark <value>, light <value> (one line per color; write "default" for '')- settings: <field> <value>, <field> <value> (every setting)- custom CSS: <the CSS, or none>Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you aregiven with read_file. In each section, call check_contrast for every color that colors text or an icon(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against thesection's background: the dark value in the dark theme, the light value in the light theme. A solidbutton's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui foricons and borders, normal for the rest. Skip colors that are "default": they follow the palette.Write the findings file you are told, one line per failing pair:- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with onlythe file's path.`;export interface AgentOptions { /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */ reportOnly?: boolean;}/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */export function buildAgent( site: SiteClient, model: BaseChatModel, run: Run, log: (line: string) => void, { reportOnly = false }: AgentOptions = {}) { const tools = siteTools(site, run, log); return createDeepAgent({ model, systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX), tools: reportOnly ? [tools.readSiteDesign, tools.listPages] : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges], middleware: [todoListMiddleware()], subagents: [ { name: 'style-collector', description: "Reads a batch of pages' styles and writes one memory file per page.", systemPrompt: STYLE_COLLECTOR, tools: [tools.readPageStyle] }, { name: 'contrast-checker', description: 'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.', systemPrompt: CONTRAST_CHECKER, tools: [tools.checkContrast] } ], // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives // for one run. site.md is loaded into the agent's instructions at the start of each run. backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }), memory: ['/memories/site.md'], // Nothing is written without the owner: approve it, edit it, or reject it. interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } }, checkpointer: new MemorySaver() });}/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */export function modelFromEnv() { return new ChatOpenRouter({ model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest', temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0), maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000) });}
Why:createDeepAgent() gives the agent the filesystem tools (write_file, read_file,
edit_file and more), the task tool to start its two subagents, and write_todos from
todoListMiddleware() for its plan. The style-collector only reads pages; the
contrast-checker only measures. Each sees only its batch, so the main agent’s context stays
small however big the site is. The CompositeBackend keeps the agent’s working notes (the
/findings/ files) for one run only, and routes /memories/ to the memories folder on disk:
that is the long-term memory, as LangChain’s
memory guide sets it up. The
memory option loads /memories/site.md into the agent’s instructions at the start of each run,
so it knows your design and your past answers before it reads a page. Only the main agent holds save_style_changes, and
interruptOn pauses before every call to it; the MemorySaver checkpointer keeps the run’s state
while it waits for you. reportOnly builds an agent with no tool to ask or to save, for a run with
nobody at the keyboard (Going further). modelFromEnv() builds the OpenRouter
model from .env.
Add src/review.ts: you, in the loop
src/review.ts
import { randomUUID } from 'node:crypto';import { Command } from '@langchain/langgraph';import type { buildAgent } from './agent.ts';import type { Change } from './content.ts';import type { Usage } from './usage.ts';/** A draft the agent wants to save: the document, and every change to it. */export interface Draft { kind: string; title: string; changes: Change[];}/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */export type Choice = | { type: 'all' } | { type: 'some'; keep: number[] } | { type: 'revise'; feedback: string } | { type: 'skip'; reason: string };/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */export interface Owner { /** The agent is working (a spinner, in the terminal). */ busy?(): void; /** Answers the agent's question. */ answer(question: string): Promise<string>; /** Decides on a draft the agent wants to save. */ decide(draft: Draft): Promise<Choice>;}/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */export const nobody: Owner = { answer: async () => { throw new Error('A report-only run never asks.'); }, decide: async () => { throw new Error('A report-only run never saves.'); }};interface ActionRequest { name: string; args: { kind?: string; id?: string; title?: string; changes?: Change[] };}type Decision = | { type: 'approve' } | { type: 'reject'; message: string } | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);/** * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the * drafts it wants to write. Resolves the agent's closing summary. */export async function runWithOwner( agent: ReturnType<typeof buildAgent>, task: string, owner: Owner, usage?: Usage): Promise<string> { // `usage` counts every model call's tokens and cost, the subagents' too. const config = { configurable: { thread_id: randomUUID() }, recursionLimit: MAX_STEPS, callbacks: usage ? [usage] : [] }; let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] }; for (;;) { owner.busy?.(); const result = await agent.invoke(input, config); // Several rewriters can pause at once: each pause gets its own answer, by its id. const pending = result.__interrupt__ ?? []; if (pending.length === 0) { const last = result.messages.at(-1); return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? ''); } const answers: Record<string, unknown> = {}; for (const { id, value } of pending) { const paused = value as { question?: string; actionRequests?: ActionRequest[] }; if (paused.question) { answers[String(id)] = await owner.answer(paused.question); continue; } const decisions: Decision[] = []; for (const action of paused.actionRequests ?? []) { decisions.push(await review(action, owner)); } answers[String(id)] = { decisions }; } input = new Command({ resume: answers }); }}/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */async function review(action: ActionRequest, owner: Owner): Promise<Decision> { const changes = action.args.changes ?? []; const choice = await owner.decide({ kind: action.args.kind ?? 'page', title: action.args.title ?? '', changes }); switch (choice.type) { case 'all': return { type: 'approve' }; case 'some': { const kept = changes.filter((_, index) => choice.keep.includes(index)); if (kept.length === 0) { return { type: 'reject', message: 'The owner kept none of these changes. Do not try them again.' }; } return { type: 'edit', editedAction: { name: action.name, args: { ...action.args, changes: kept } } }; } case 'revise': return { type: 'reject', message: `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` + 'Revise your changes to match, then call the same tool again with every change.' }; case 'skip': return { type: 'reject', message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.` }; }}
Why: the run goes until the agent pauses. A question gets your answer; a draft gets your
call, turned into the form the agent’s approval step takes (save all = approve, keep some = edit,
change this or skip = reject, with a message the agent reads). Several pauses can come at once,
so each gets its own answer by its id, in one new Command({ resume }). recursionLimit stops a
run that goes on too long, and callbacks hands every model call to usage.
Add src/terminal.ts: the conversation, in your terminal
src/terminal.ts
import { stdout } from 'node:process';import { stripVTControlCharacters, styleText } from 'node:util';import * as prompt from '@clack/prompts';import { diffWordsWithSpace } from 'diff';import { Marked, type Tokens } from 'marked';import stringWidth from 'string-width';import yoctoSpinner from 'yocto-spinner';import type { Change } from './content.ts';import type { Choice, Draft, Owner } from './review.ts';import type { Usage } from './usage.ts';/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */export class Stopped extends Error {}/** * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is * doing and what the run has used so far; its words as formatted text. */export function terminalOwner(usage?: Usage): Owner & { step: (line: string) => void; report: (markdown: string) => void; failed: (error: unknown) => void;} { // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers. const spin = yoctoSpinner({ stream: stdout }); // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere // else each one would print a new line, so output sent to a file gets the start and the steps. const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env); let spinning = false; let started = 0; let clock: NodeJS.Timeout | undefined; let steps: string[] = []; let doing = 'The agent is working'; // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped. // On a narrow terminal the tokens and cost go first, then the time, before the task is cut. const line = () => { const seconds = Math.floor((Date.now() - started) / 1000); const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`; const width = (stdout.columns || 80) - 3; const tail = [[spent, time], [time]] .map((parts) => parts.filter(Boolean).join(' · ')) .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? ''; const room = tail ? width - stringWidth(tail) - 2 : width; return tail ? `${fit(doing, room)} ${styleText('dim', tail)}` : fit(doing, room); }; const withUsage = (text: string) => usage ? `${text} ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text; const refresh = () => { if (spinning && live) { spin.text = line(); } }; if (usage) { usage.onCall = refresh; } // The spinner sits on the run's guide line, like the rest of the terminal. const startSpinner = () => { started = Date.now(); spinning = true; stdout.write(`${styleText('gray', prompt.S_BAR)}\n`); spin.start(line()); if (live) { clock = setInterval(refresh, 1000); } }; const stopSpinner = (finish: () => void) => { clearInterval(clock); spin.stop(); spinning = false; finish(); }; // What the agent did since you last heard from it, as one dim line. const settle = () => { if (spinning) { const done = withUsage(styleText('dim', steps.join(' · ') || 'Done')); stopSpinner(() => prompt.log.step(done, { spacing: 0 })); } steps = []; }; return { busy() { if (!spinning) { doing = 'The agent is working'; startSpinner(); } }, step(text) { doing = text.replace(/\.$/, ''); steps.push(doing); refresh(); }, async answer(question) { settle(); say(markdown(question), 'cyan'); return ask(() => prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' }) ); }, async decide(draft) { settle(); say( [ styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`), ...draft.changes.map((change, index) => showChange(change, index)) ].join('\n\n'), 'cyan' ); return decide(draft); }, /** Shows the run's report. */ report(text) { settle(); say(markdown(text), 'green'); }, /** The run stopped on an error: the spinner says so, and the error follows. */ failed(error) { if (spinning) { stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 })); } prompt.log.error(error instanceof Error ? error.message : String(error)); } };}/** "183,204 tokens": read and written together. */function tokens(usage: Usage): string { return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;}/** * Text cut to a number of terminal cells, never through a character (an emoji counts as two). * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji * is 8), so the cut keeps both within the limit. */function fit(text: string, cells: number): string { const size = (part: string) => Math.max(stringWidth(part), part.length); if (size(text) <= cells) { return text; } let cut = ''; for (const { segment } of new Intl.Segmenter().segment(text)) { if (size(`${cut}${segment}…`) > cells) { break; } cut += segment; } return `${cut}…`;}/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */function say(text: string, color: 'cyan' | 'green') { prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });}// The terminal's color and style codes (ESC [ … m), as styleText writes them.const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */export function wrap(text: string, width: number): string { const visible = (line: string) => stripVTControlCharacters(line).length; return text .split('\n') .map((line) => { // A wrapped line sits under the text, past any list number or bullet. const lead = /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? ''; const indent = ' '.repeat(lead.length); const words = line.trimStart().split(' '); const lines: string[] = []; let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? ''); for (const word of words) { if (visible(`${current} ${word}`) > width && current.trim()) { // The colors in force so far carry onto the next line: the guide line resets them. const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? ''; lines.push(`${current}\x1b[0m`); current = indent + colors + word; } else { current = `${current} ${word}`; } } lines.push(current); return lines.join('\n'); }) .join('\n');}/** Starts and ends the run's frame in the terminal. */export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));export const outro = (line: string) => prompt.outro(line);export const note = (line: string) => prompt.log.info(line);async function decide(draft: Draft): Promise<Choice> { const many = draft.changes.length > 1; const choice = await ask(() => prompt.select({ message: 'Your call', options: [ { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' }, ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []), { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' }, { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' } ] }) ); if (choice === 'some') { const keep = await ask(() => prompt.multiselect({ message: 'Keep these (Space to toggle, Enter to save)', options: draft.changes.map((change, index) => ({ value: index, label: `${index + 1}. ${plain(change.after)}` })), initialValues: draft.changes.map((_, index) => index), required: false }) ); return { type: 'some', keep }; } if (choice === 'revise') { return { type: 'revise', feedback: await ask(() => prompt.text({ message: 'What should change?', validate: (value) => (value?.trim() ? undefined : 'Say what should change.') }) ) }; } if (choice === 'skip') { return { type: 'skip', reason: await ask(() => prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' }) ) }; } return { type: 'all' };}/** Waits for one prompt; Ctrl+C or Esc stops the run. */async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> { const value = await show(); if (prompt.isCancel(value)) { throw new Stopped('You stopped the run.'); } return value as Exclude<T, symbol>;}/** * One change, numbered: the text before and after, whole, with the words that change marked (red * and struck through before, green after). Still readable with the colors gone, pasted elsewhere. */export function showChange(change: Change, index: number): string { const parts = diffWordsWithSpace(change.before, change.after); const kept = parts.filter((part) => !part.added && !part.removed); const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`; // Mostly rewritten: the old text in red and the new in green, with no marks to read through. if ( kept .map((part) => part.value) .join('') .trim().length < change.after.length / 2 ) { return [ label, ` ${styleText('dim', 'before')} ${styleText('red', change.before)}`, ` ${styleText('dim', 'after ')} ${styleText('green', change.after)}` ].join('\n'); } const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) => parts .filter((part) => !part[drop]) .map((part) => part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value) ) .join(''); return [ label, ` ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`, ` ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}` ].join('\n');}/** Rich text's HTML tags, dimmed, so the words stand out. */const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));/** Text with its HTML tags taken out, on one line, for a short label. */function plain(text: string, max = 70): string { const flat = text .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;}// The agent writes Markdown. This renders it for the terminal, with a blank line after every// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.const terminal = new Marked({ renderer: { heading({ tokens }) { return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`; }, paragraph({ tokens }) { return `${this.parser.parseInline(tokens)}\n\n`; }, list(list: Tokens.List) { const start = typeof list.start === 'number' ? list.start : 1; return list.items .map((item, index) => { const bullet = list.ordered ? `${start + index}.` : '•'; const body = this.parser.parse(item.tokens).trim(); // Lines under the first are indented to sit under its text. const pad = ' '.repeat(bullet.length + 1); return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`; }) .join(''); }, text(token) { return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text; }, strong({ tokens }) { return styleText('bold', this.parser.parseInline(tokens)); }, em({ tokens }) { return styleText('italic', this.parser.parseInline(tokens)); }, codespan({ text }) { return styleText('cyan', text); }, code({ text }) { return `${styleText('cyan', text)}\n\n`; }, blockquote({ tokens }) { return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`; }, link({ href, tokens }) { const text = this.parser.parseInline(tokens); return text === href ? styleText('underline', href) : `${text} ${styleText('dim', `(${href})`)}`; }, hr() { return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`; }, br() { return '\n'; }, html({ text }) { return text; } }});/** The agent's Markdown, formatted for the terminal. */export function markdown(text: string): string { return String(terminal.parse(text, { async: false })).trim();}
Why: everything you see while the agent runs. A spinner says what the agent is doing and
what the run has used so far; its questions are Markdown, set out with a blank line between
paragraphs and list items; each draft shows every change’s text before and after, with the
changed words marked. You save it all, pick the changes to keep, tell the agent what to change,
or skip it. Ctrl+C at any prompt stops the run; the drafts saved so far stay drafts.
Add src/usage.ts: tokens and cost
src/usage.ts
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';import { AIMessage } from '@langchain/core/messages';import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';/** * Counts every model call in a run, the subagents' too: tokens read and written, and what * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call. */export class Usage extends BaseCallbackHandler { name = 'usage'; calls = 0; inputTokens = 0; outputTokens = 0; /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */ cost = 0; /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */ uncosted = 0; /** Called after each model call, e.g. to refresh a running total on screen. */ onCall?: () => void; override handleLLMEnd(output: LLMResult) { this.calls += 1; const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message; const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined; this.inputTokens += usage?.input_tokens ?? 0; this.outputTokens += usage?.output_tokens ?? 0; const cost = output.llmOutput?.tokenUsage?.cost; if (typeof cost === 'number') { this.cost += cost; } else { this.uncosted += 1; } this.onCall?.(); } /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */ line(): string { const count = new Intl.NumberFormat('en-US'); const parts = [ `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`, `${count.format(this.inputTokens)} tokens in`, `${count.format(this.outputTokens)} out`, `$${this.cost.toFixed(4)}` ]; const line = parts.join(' · '); return this.uncosted > 0 ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)` : line; } /** The run's usage, as a Markdown section for the report and its email. */ markdown(): string { return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`; }}
Why: LangChain calls handleLLMEnd after every model call, the subagents’ too, with the
answer OpenRouter sent: its token counts and cost, in US dollars. The run adds them up for the
terminal and the report, so you see what each run really cost.
Add src/report.ts: the run's report
src/report.ts
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { previewLink } from './content.ts';import type { Usage } from './usage.ts';/** A draft the run saved, and the private link that previews it. */export interface WaitingDraft { title: string; link: string;}/** Every page the run saved a draft of (id → title), each with its preview link. */export async function draftsWaiting( site: SiteClient, saved: Map<string, string>): Promise<WaitingDraft[]> { const drafts: WaitingDraft[] = []; for (const [id, title] of saved) { drafts.push({ title, link: await previewLink(site, id) }); } return drafts;}/** * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then * what the run used (tokens and cost). */export function reportMarkdown( summary: string, drafts: readonly WaitingDraft[], usage?: Usage, date = new Date()) { // The site's host for the heading; a missing or mistyped URL still gets a report that says why. const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site'; const lines = [ `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`, '', summary.trim() ]; if (drafts.length > 0) { lines.push('', '## Drafts waiting for you', ''); lines.push('Publish each one in the CMS when it looks right.', ''); for (const draft of drafts) { lines.push(`- "${draft.title}", preview: ${draft.link}`); } } if (usage) { lines.push('', usage.markdown()); } return `${lines.join('\n')}\n`;}
Why: the end of a run, as one Markdown report: the agent’s summary, then each draft it saved
with a private preview link. The link works for anyone you send it to, without signing in.
Markdown reads well in a terminal, and the same report can go to a file or an email
(Going further).
Add src/main.ts: start it
src/main.ts
import { createSiteClient } from '@fetchski/attract-cms-sdk';import { buildAgent, modelFromEnv } from './agent.ts';import { draftsWaiting, reportMarkdown } from './report.ts';import { runWithOwner } from './review.ts';import { intro, outro, Stopped, terminalOwner } from './terminal.ts';import { newRun } from './tools.ts';import { Usage } from './usage.ts';// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .envconst site = createSiteClient();const run = newRun();const usage = new Usage();const owner = terminalOwner(usage);const agent = buildAgent(site, modelFromEnv(), run, owner.step);intro('One design');let summary: string;try { summary = await runWithOwner( agent, 'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.', owner, usage );} catch (error) { if (!(error instanceof Stopped)) { owner.failed(error); throw error; } // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them. summary = 'You stopped the run before it finished.';}const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);owner.report(report);outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
Why: builds the site client and the model from .env, runs the agent with you at the
keyboard, and shows the report, its usage and cost included.
Add test/agent.test.ts: the tests
test/agent.test.ts
import { deepStrictEqual, ok, strictEqual } from 'node:assert';import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { test } from 'node:test';import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';import { AIMessage } from '@langchain/core/messages';import type { LLMResult } from '@langchain/core/outputs';import { fakeModel } from 'langchain';import { buildAgent } from '../src/agent.ts';import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';import { newRun, siteTools } from '../src/tools.ts';import { Usage } from '../src/usage.ts';const HOME = 'page-home:en:draft';const ABOUT = 'page-about:en:draft';const MONDAY = '2026-10-26T09:00:00.000Z';const TUESDAY = '2026-10-27T09:00:00.000Z';// A fresh memory folder, per test.const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));function area(widget: Record<string, unknown>) { return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };}// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.const PAGES: Record<string, ReturnType<typeof factories.Page>> = { [HOME]: factories.Page({ id: HOME, title: 'Home', sections: area({ type: 'intro', introBg: 'raised', introEyebrowColor: '--fetchski-mint', introCtaRadius: 'slight' }) }), [ABOUT]: factories.Page({ id: ABOUT, title: 'About us', sections: area({ type: 'media-text', mediaTextBg: 'custom', mediaTextBgCustom: '#ffffff', mediaTextHeadingColor: '#777777', mediaTextCtaRadius: 'pill' }) })};function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) { return fakeSite({ 'pages.listPages': factories.ListPagesResponse({ results: Object.entries(updated).map(([id, updatedAt]) => ({ id, title: String(PAGES[id]?.title), type: 'default-page', updatedAt: new Date(updatedAt) })) }), 'pages.getPage': (args) => PAGES[String(args.id)], 'globalContent.listGlobalContent': factories.GlobalList({ results: [ { paletteSecondary: '#2dd4bf', fontDisplaySource: 'curated', fontDisplayCurated: 'fraunces' } ], pages: 1, currentPage: 1 }) });}const HEADING_FIX = { path: 'sections.items.0.mediaTextHeadingColor', before: '#777777', after: '--fetchski-ink'};const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.function scriptedModel() { return ( fakeModel() .respondWithTools([ { name: 'read_site_design', args: {} }, { name: 'list_pages', args: {} } ]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) // The style-collector subagent: .respondWithTools([ { name: 'read_page_style', args: { id: HOME } }, { name: 'read_page_style', args: { id: ABOUT } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-home.md', content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised` } }, { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777` } } ]) .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md')) // The main agent again: .respondWithTools([ { name: 'task', args: { subagent_type: 'contrast-checker', description: 'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md' } } ]) // The contrast-checker subagent: .respondWithTools([ { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } } ]) .respondWithTools([ { name: 'check_contrast', args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/findings/contrast-1.md', content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5' } } ]) .respond(new AIMessage('/findings/contrast-1.md')) // The main agent again: .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/site.md', content: '# Site design\n- Buttons: slight corners' } } ]) .respondWithTools([ { name: 'ask_owner', args: { question: 'Home has slight button corners, About us has pill. Which is yours?' } } ]) .respondWithTools([ { name: 'save_style_changes', args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] } } ]) .respond( new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.') ) );}// The person at the keyboard, scripted: their answers to questions, then their call on each draft.function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } { const drafts: Draft[] = []; return { drafts, answer: async () => answers.shift() ?? '', decide: async (draft) => { drafts.push(draft); return choices.shift() ?? { type: 'skip', reason: '' }; } };}test('a first run collects every page into memory, and saves an approved fix as a draft', async () => { const site = siteWithTwoDesigns(); const folder = memoryFolder(); const run = newRun(folder); const owner = scriptedOwner(['Slight'], [{ type: 'all' }]); const summary = await runWithOwner( buildAgent(site, scriptedModel(), run, () => {}), 'Check this whole website.', owner ); strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.'); site.assertCalled('pages.updatePage', (args) => { const section = (args.sections as { items: Record<string, unknown>[] }).items[0]; return ( args.id === ABOUT && section?.mediaTextHeadingColor === '--fetchski-ink' && section.mediaTextCtaRadius === 'slight' ); }); deepStrictEqual([...run.saved], [[ABOUT, 'About us']]); deepStrictEqual( owner.drafts.map((draft) => draft.title), ['About us'] ); ok(existsSync(join(folder, 'pages', 'page-home.md'))); ok(existsSync(join(folder, 'site.md'))); // The next run finds both pages unchanged, from their memory files alone. const next = newRun(folder); await siteTools(site, next, () => {}).listPages.invoke({}); deepStrictEqual( next.pages.map((page) => page.status), ['unchanged', 'unchanged'] );});test('a fix the owner skips is never written', async () => { const site = siteWithTwoDesigns(); await runWithOwner( buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}), 'Check this whole website.', scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }]) ); site.assertNotCalled('pages.updatePage');});test('a report-only second run collects only the changed page, and never asks or writes', async () => { // The last run scanned Home, About us and a page since deleted. Since then, only About us changed. const folder = memoryFolder(); mkdirSync(join(folder, 'pages')); for (const name of ['page-home', 'page-about', 'page-old']) { writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`); } writeFileSync( join(folder, 'site.md'), '# Site design\n- Buttons: slight corners, the owner chose' ); const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY }); const run = newRun(folder); const model = fakeModel() .respondWithTools([{ name: 'list_pages', args: {} }]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us, as it is now\nupdated: ${TUESDAY}\n` } } ]) .respond(new AIMessage('/memories/pages/page-about.md')) .respond(new AIMessage('## Contrast\nNo failures.')); const summary = await runWithOwner( buildAgent(site, model, run, () => {}, { reportOnly: true }), 'Check this whole website, and report what you find.', nobody ); strictEqual(summary, '## Contrast\nNo failures.'); deepStrictEqual( run.pages.map((page) => [page.title, page.status]), [ ['Home', 'unchanged'], ['About us', 'changed'] ] ); site.assertCalledTimes('pages.getPage', 1); site.assertCalled('pages.getPage', (args) => args.id === ABOUT); site.assertNotCalled('pages.updatePage'); strictEqual( readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'), `# About us, as it is now\nupdated: ${TUESDAY}\n` ); strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false); // site.md, with the owner's choice, was in the agent's instructions from the start. const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? ''; ok(instructions.includes('Buttons: slight corners, the owner chose'));});test('check_contrast measures with the WCAG formula, in code', async () => { const palette = resolvePalette({}); strictEqual(contrastRatio('#000000', '#ffffff'), 21); strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48); deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), { theme: 'light', text: '#777777', background: '#ffffff', ratio: 4.47, needs: 4.5, passes: false }); const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette); ok('passes' in large && large.passes); // Through the tool: palette tokens resolve with the site's own palette, in the theme asked. const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {}); const mint = JSON.parse( await tool.invoke({ text: '--fetchski-mint', background: 'page', theme: 'dark', size: 'normal' }) ); deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]); const overVideo = JSON.parse( await tool.invoke({ text: '#ffffff', background: 'image or video', theme: 'dark', size: 'large' }) ); deepStrictEqual(overVideo, { theme: 'dark', measured: false, why: 'background: the background is an image or video' });});test('usage adds up tokens and the cost OpenRouter reports for each call', () => { const usage = new Usage(); const call = (input: number, output: number, cost?: number) => usage.handleLLMEnd({ generations: [ [ { text: '', message: new AIMessage({ content: '', usage_metadata: { input_tokens: input, output_tokens: output, total_tokens: input + output } }) } ] ], // OpenRouter's own `usage` object, as ChatOpenRouter passes it on. llmOutput: { tokenUsage: cost === undefined ? {} : { cost } } } as unknown as LLMResult); call(1000, 200, 0.0012); call(3000, 50, 0.0021); call(10, 5); strictEqual( usage.line(), '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)' ); ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));});
Why: the tests run the whole agent, its subagents included, with no site and no model.
fakeSite() stands in for your site and answers every SDK call, and LangChain’s fakeModel()
plays the model’s moves in order. Each test keeps its memory in a fresh temporary folder. They
prove that a first run remembers every page, that a second run collects only the page that changed
(and forgets a deleted one), that the contrast math matches known pairs, and that a fix you reject
is never written. Nothing is sent, written or spent.
Your project
When you’re done, one-design holds these files (and node_modules):
src
agent.ts
content.ts
design.ts
main.ts
memory.ts
report.ts
review.ts
terminal.ts
tools.ts
usage.ts
test
agent.test.ts
memories
.env
.gitignore
.npmrc
package.json
package-lock.json
tsconfig.json
memories/ appears after your first run: a file per page, named by its id, plus site.md (your
site’s design, as the agent understands it, with your answers). Keep the folder: the next run starts
from it. Open memories/site.md and correct it if the agent got your design wrong; it is in the
agent’s instructions on every run. Delete the folder to start over. Set MEMORY_FOLDER in .env to
keep it somewhere else.The lockfile is pnpm-lock.yaml with pnpm, and bun.lock with Bun.
Test it
Check the types, then run the tests. Both are scripts in package.json:
npm run typechecknpm test
pnpm typecheckpnpm test
bun run typecheckbun run test
You should see: nothing from the type check (any output is a type error, with its file and
line), then these lines from the tests, plus a few more counts and the run’s time:
Terminal
✔ a first run collects every page into memory, and saves an approved fix as a draft (112.719ms)✔ a fix the owner skips is never written (62.43ms)✔ a report-only second run collects only the changed page, and never asks or writes (29.061792ms)✔ check_contrast measures with the WCAG formula, in code (1.420042ms)✔ usage adds up tokens and the cost OpenRouter reports for each call (3.879708ms)ℹ tests 5ℹ pass 5ℹ fail 0
Run it
npm start
pnpm start
bun run start
The start script runs node --env-file=.env src/main.ts: Node loads .env, then runs the program.You should see the agent’s progress beside a spinner, then its questions and drafts. The lines
below are the program’s own; the agent’s questions and summary are in its words, so yours will
differ. In your terminal the changed values are colored (red before, green after):
Terminal
┌ One design│◇ Read Site design · Found 14 pages: 14 new, 0 changed, 0 unchanged · Read the style of "Home" · … 187,330 tokens · $0.0391│◆ Most pages give their buttons slight corners, but About us uses pill corners. Which is your site's│ style: slight or pill?│◇ Your answer│ Slight│◇ Done 214,906 tokens · $0.0446│◆ Save a draft of the page "About us"?││ 1. sections.items.2.mediaTextHeadingColor│ before #777777│ after --fetchski-ink││ 2. sections.items.2.mediaTextCtaRadius│ before pill│ after slight│◇ Your call│ Save all 2 changes││ …│◆ One design on acme.example, 2026-10-28││ One contrast failure: the heading on About us (#777777 on #ffffff, 4.47:1, needs 4.5:1), now│ --fetchski-ink in a draft. Its buttons had pill corners; they now match the rest of the site.││ Suggested for Site design: none.││ Check by eye: the hero on Home, which has text over a video.││ Drafts waiting for you││ Publish each one in the CMS when it looks right.││ • "About us", preview: https://acme.example/about-us?aposShareKey=k8Jq2w&aposShareId=ckq3v8x5b0004%3Aen%3Adraft││ Usage││ 31 model calls · 296,118 tokens in · 7,402 out · $0.0618. The cost is what OpenRouter charged for this run.│└ Done: 1 draft saved · 31 model calls · 296,118 tokens in · 7,402 out · $0.0618
Between the progress lines the agent may think for a minute: it is reading, or comparing. Run it
again and the first line changes: Found 14 pages: 0 new, 1 changed, 13 unchanged. Only the changed
page is read again (the draft you approved changed it too), and the agent doesn’t ask a question it
already wrote into memories/site.md. At each fix, pick with the arrow keys and Enter:
Save it (or Save all N changes) saves the draft.
Pick which changes to keep shows a checklist: Space unticks a change, Enter saves the rest.
Tell the agent what to change asks for your words (“use --fetchski-accent for that heading”);
the agent revises the fix and shows it again.
Skip this one writes nothing; the agent reads your reason and leaves that page alone.
Ctrl+C at any prompt stops the run. The drafts saved so far stay drafts, and the report lists them.
The $ figures are what OpenRouter charged, read from its own answers, never estimated.Then open each preview link in both themes, and publish the drafts that look right in the CMS (open
the page, Publish). Send a preview link to anyone who should see it first: it works without
signing in.
If something goes wrong
FETCHSKI_ATTRACT_URL is not set
Terminal
SiteConfigurationError: FETCHSKI_ATTRACT_URL is not set: set it to the site’s public origin (e.g. https://acme.example), or pass createSiteClient({ baseUrl }).
Node didn’t load .env. Start the agent with npm start (or pnpm start, bun run start) from
the project folder: the script loads .env.
OpenRouter API key is required
Terminal
OpenRouter API key is required. Get one at https://openrouter.ai/keys and set it via the `apiKey` parameter or the OPENROUTER_API_KEY environment variable.
Add OPENROUTER_API_KEY to .env.
User not found.
Terminal
OpenRouterAuthError: User not found.
OpenRouter doesn’t know the key: it was mistyped, or deleted. Copy it again from
openrouter.ai/settings/keys.
403: the key needs a scope
Terminal
Forbidden: Site "acme" answered GET /api/v1/@apostrophecms/global with 403 forbidden: This API key can’t do this: it needs the `global:read` scope (or a scope above it, such as its type’s `:all`).
The key is missing the scope the message names. In the CMS, press ⌘K, open API Keys, open
the key, tick the scope under Scopes and save. Without Pages: write drafts, the message
names content:pages:write and the route GET /api/v1/@apostrophecms/page.
401: Invalid API key.
Terminal
AuthenticationFailed: Site "acme" answered GET /api/v1/@apostrophecms/page with 401 invalid: Invalid API key.
The token in .env isn’t the one the site issued, or the key was turned off or has expired. Make
a new key, and paste both of its secrets again.
401: This request must be signed
The agent reads fine, but the first draft you approve fails:
Terminal
AuthenticationFailed: Site "acme" answered PATCH /api/v1/@apostrophecms/page/ckq3v8x5b0004:en:draft with 401 unauthorized: This request must be signed: send X-Fetchski-Timestamp and X-Fetchski-Signature, made with the key’s signing secret.
FETCHSKI_ATTRACT_SIGNING_SECRET is missing from .env. If the message ends X-Fetchski-Signature does not match this request. instead, it is the wrong secret: copy the one shown beside this key’s
token (or make a new key).
Recursion limit reached
Terminal
GraphRecursionError: Recursion limit of 400 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.
The run hit its step ceiling before it finished, so it remembered nothing new. On a large site,
lower MAX_PAGES and run it again: each run that finishes remembers its pages, and the next one
reads the rest. Or raise AGENT_MAX_STEPS (more steps, more cost).
Going further
Three additions, each optional, each building on the one before: keep every report as a file, have
it emailed to you, and run a check every week with nobody at the keyboard.
Save each report to a file
Add src/save-report.ts
src/save-report.ts
import { mkdir, writeFile } from 'node:fs/promises';import { dirname, join } from 'node:path';/** Saves the report as reports/<date>-<time>.md, and answers the file's path. */export async function saveReport(report: string, date = new Date()): Promise<string> { const stamp = `${date.toLocaleDateString('en-CA')}-${date.toTimeString().slice(0, 5).replace(':', '')}`; const file = join(process.env.REPORTS_FOLDER || 'reports', `${stamp}.md`); await mkdir(dirname(file), { recursive: true }); await writeFile(file, report); return file;}
Why: writes the report to reports/ as a Markdown file named by the date and time, such as
reports/2026-10-28-0900.md. Set REPORTS_FOLDER in .env to keep them somewhere else.
Save it at the end of src/main.ts
In src/main.ts, add the save-report.ts import, add note to the import from ./terminal.ts,
and put the last line just above the outro(…) at the end:
src/main.ts
import { saveReport } from './save-report.ts';import { intro, note, outro, Stopped, terminalOwner } from './terminal.ts';// … the rest of main.ts, as it is, down to owner.report(report); …note(`Saved to ${await saveReport(report)}.`);
Keep the reports out of git
The reports quote your site and hold private preview links. Add the folder to .gitignore:
.gitignore
reports
You should see, at the end of your next run: Saved to reports/2026-10-28-0900.md.
Email it to you
Pick one way to send it, then add it to src/main.ts. Both send the same email and need the
same two addresses in .env.
Why:Nodemailer sends email through any mail server, and
marked, already in your project for the terminal, turns the Markdown
report into the email’s formatted version.
Add your mail server to .env
Your email provider shows its SMTP settings: a host, a port, a user name and a password (often
one made for apps). Put them together as a URL:
.env
# Email the report: your mail server, who sends it, and who gets itSMTP_URL="smtps://reports%40acme.example:app-password@smtp.acme.example:465"REPORT_EMAIL_FROM="Website reports <reports@acme.example>"REPORT_EMAIL_TO="you@acme.example"
smtps:// with port 465 is a secure connection from the start; for port 587, write
smtp:// (the connection is secured once it opens). In the user name and the password, write
@ as %40, : as %3A and / as %2F. Send to several people with commas:
REPORT_EMAIL_TO="you@acme.example,partner@acme.example".Keep the double quotes around each value: the sender’s name has spaces and <> in it, and
quotes keep a value whole.
Add src/email-report.ts
src/email-report.ts
import { marked } from 'marked';import { createTransport } from 'nodemailer';/** * Emails the report to REPORT_EMAIL_TO, through the mail server in SMTP_URL. Its first line (the * report's heading) is the subject. Answers what happened, in words: a run never fails over email. */export async function emailReport(report: string): Promise<string> { const { SMTP_URL, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env; if (!SMTP_URL || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) { return 'Not emailed: set SMTP_URL, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.'; } try { await createTransport(SMTP_URL).sendMail({ from: REPORT_EMAIL_FROM, to: REPORT_EMAIL_TO, subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report', text: report, html: await marked.parse(report) }); return `Emailed to ${REPORT_EMAIL_TO}.`; } catch (error) { return `Not emailed: ${error instanceof Error ? error.message : String(error)}`; }}
Why: sends the report with its heading as the subject, as plain text and as formatted HTML.
If the mail server refuses, the run still ends normally and says why: the report is in
reports/ either way.
Postmark: through your Postmark account
Install the mail packages
npm install postmark
pnpm add postmark
bun add postmark
Why:Postmark’s Node.js library
sends email through your Postmark account’s API (it brings its own types), and
marked, already in your project for the terminal, turns the Markdown
report into the email’s formatted version.
Add your Postmark token to .env
In Postmark, open your server, then API Tokens, and copy its Server API token. The
sending address must be a confirmed sender signature or on a verified domain in that account:
.env
# Email the report: your Postmark server token, who sends it, and who gets itPOSTMARK_SERVER_TOKEN="your-server-api-token"REPORT_EMAIL_FROM="Website reports <reports@acme.example>"REPORT_EMAIL_TO="you@acme.example"
Send to several people with commas: REPORT_EMAIL_TO="you@acme.example,partner@acme.example".Keep the double quotes around each value: the sender’s name has spaces and <> in it, and
quotes keep a value whole.
Add src/email-report.ts
src/email-report.ts
import { marked } from 'marked';import { ServerClient } from 'postmark';/** * Emails the report to REPORT_EMAIL_TO, through Postmark. Its first line (the report's heading) * is the subject. Answers what happened, in words: a run never fails over email. */export async function emailReport(report: string): Promise<string> { const { POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env; if (!POSTMARK_SERVER_TOKEN || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) { return 'Not emailed: set POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.'; } try { await new ServerClient(POSTMARK_SERVER_TOKEN).sendEmail({ From: REPORT_EMAIL_FROM, To: REPORT_EMAIL_TO, Subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report', TextBody: report, HtmlBody: await marked.parse(report) }); return `Emailed to ${REPORT_EMAIL_TO}.`; } catch (error) { return `Not emailed: ${error instanceof Error ? error.message : String(error)}`; }}
Why: sends the report with its heading as the subject, as plain text and as formatted HTML.
If Postmark refuses (a wrong token, an unconfirmed sender), the run still ends normally and says
why: the report is in reports/ either way.
Email it at the end of src/main.ts
Add the import at the top of src/main.ts, and the last line under the one that saves the
report, still above the outro(…):
src/main.ts
import { emailReport } from './email-report.ts';// … the rest of main.ts, as it is, down to the line that saves the report …note(`Saved to ${await saveReport(report)}.`);note(await emailReport(report));
You should see, at the end of your next run: Emailed to you@acme.example., and the report
in your inbox a moment later.
Run it every week
A scheduled run has nobody at the keyboard, so it can’t ask you anything or wait for your yes. It
runs in report-only mode instead: it checks the whole site against its design and against WCAG
contrast. It keeps its memory up to date, so each week it reads only the pages someone changed. Its
report is saved in reports/ and emailed to you, so the two parts above come first. The agent
gets no tool to ask or to save, so a report-only run never changes your site. When the report shows
something to fix, run npm start at your keyboard.
Add src/report-only.ts
src/report-only.ts
import { createSiteClient } from '@fetchski/attract-cms-sdk';import { buildAgent, modelFromEnv } from './agent.ts';import { emailReport } from './email-report.ts';import { reportMarkdown } from './report.ts';import { nobody, runWithOwner } from './review.ts';import { saveReport } from './save-report.ts';import { newRun } from './tools.ts';import { Usage } from './usage.ts';// A run with nobody at the keyboard (a scheduled one): it checks every page against the site's design// and WCAG contrast, and reports what it finds. The agent has no tool to ask you or to save a draft, so// this run never writes to the site. It does keep its memory up to date.console.log(`Report-only run, ${new Date().toLocaleString('en-US')}`);// What the run used (tokens and cost) goes in the report, even when the run stops partway.const usage = new Usage();let report: string;try { const site = createSiteClient(); const run = newRun(); const agent = buildAgent(site, modelFromEnv(), run, (line) => console.log(line), { reportOnly: true }); const summary = await runWithOwner( agent, 'Check this whole website against its own design and against WCAG contrast, and report what you find.', nobody, usage ); report = reportMarkdown( `${summary}\n\nTo fix them with the agent, run \`npm start\` at your keyboard.`, [], usage );} catch (error) { // The run stopped (a wrong key, an empty .env, no credit left): the report says why, by email too. const reason = error instanceof Error ? error.message : String(error); report = reportMarkdown( `The check stopped before it finished, so there is no report this time. It stopped with:\n\n` + `\`\`\`text\n${reason}\n\`\`\`\n\n` + 'Run `npm run report` in the project folder to see it again; "If something goes wrong" on the ' + "Recipe's page explains the usual causes.", [], usage ).replace(/^# (.+)$/m, '# $1: the check stopped'); process.exitCode = 1;}console.log(`\n${report}`);console.log(`Saved to ${await saveReport(report)}.`);console.log(await emailReport(report));
Why: the same agent, built with reportOnly: it collects the new and changed pages,
checks contrast and compares every page with memories/site.md, updating the memory as it goes. The
report is printed, saved and emailed. If the run stops (a wrong key, a missing .env value,
no OpenRouter credit left), you still get an email, with “the check stopped” in its subject
and the reason in it.
Try it once by hand, so you know the email arrives before you schedule it:
npm run report
pnpm report
bun run report
You should see the progress lines, then the report, then:
Terminal
Saved to reports/2026-10-28-0900.md.Emailed to you@acme.example.
If the last line starts Not emailed:, it says what to fix in .env.
Schedule it
These steps are for macOS and Linux, with cron. The computer has to be on and awake at the
time, so an always-on machine or a small server is best.Find the folder Node is in:
Terminal
dirname "$(command -v node)"
Open your schedule with crontab -e, and add these two lines. Put the folder you just found
first on the PATH line, and your project’s full path after cd:
crontab
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin0 8 * * 1 cd /Users/you/one-design && mkdir -p reports && npm run report >> reports/run.log 2>&1
0 8 * * 1 runs it every Monday at 8:00: minute 0, hour 8, any day of the month, any month,
Monday (1). cron doesn’t load your shell’s settings, which is why the PATH line names the
folder Node is in.On macOS, keep the project out of Desktop, Documents and Downloads: a scheduled job
can’t open those folders unless you give cronFull Disk Access in System Settings →
Privacy & Security.You should see, every Monday morning, an email like this (the report is the agent’s own
words, so yours will differ):
Email
From: Website reports <reports@acme.example>To: you@acme.exampleSubject: One design on acme.example, 2026-11-02# One design on acme.example, 2026-11-02## Contrast- Pricing, section 3 (call to action), light theme: the button's white text on #34ddb3 is 1.72:1; it needs 4.5:1.## Outliers- About us, section 2: its background is #ff00aa, a color no other page uses and not one in your palette. Every other section uses the palette's surface or raised color.To fix them with the agent, run `npm start` at your keyboard.## Usage22 model calls · 201,477 tokens in · 4,980 out · $0.0419. The cost is what OpenRouter charged for this run.
The same report is in reports/, named by its date and time.No email on Monday? Open reports/run.log in the project: every run adds its output there,
starting with Report-only run, and its date and time. An entry that ends Not emailed: says
what the mail server or Postmark answered. No entry for that Monday means the run never started: the
computer was asleep or off at 8:00, or the cd path or the PATH line in crontab -e is
wrong.
Turn it off
The agent runs only when you start it, or when your schedule does. To retire it:
Remove the schedule, if you made one
Delete its line in crontab -e.
Revoke the site key
In the CMS, ⌘K → API Keys, open One design, untick
Enabled and save. It stops working at once.
Open the page in the CMS, and choose Discard Draft from its menu. The live version is
untouched.
Related
Find where your website contradicts itself
The same tools, checking every fact your site states twice
Give your whole site one voice
The same tools, rewriting every document in the voice you approve
Testing
fakeSite(), factories and assertions
Required scopes
What each scope opens, per content type
Deep Agents: memory
Long-term memory as files, in LangChain’s docs
Deep Agents: backends
Routing /memories/ to a folder on disk, in LangChain’s docs
Deep Agents: human-in-the-loop
Approve, edit and reject, in LangChain’s docs
⌘I
import type { SiteClient } from '@fetchski/attract-cms-sdk';import type { BaseChatModel } from '@langchain/core/language_models/chat_models';import { MemorySaver } from '@langchain/langgraph';import { ChatOpenRouter } from '@langchain/openrouter';import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';import { todoListMiddleware } from 'langchain';import { type Run, siteTools } from './tools.ts';const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgroundsand button styles on every page, with text people can read. Different pages were built at differenttimes, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.Your long-term memory is /memories/, kept between runs:- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the owner's choices. It is loaded into your instructions at the start of every run.- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each one, and knows from it whether the page changed since.Work in this order:1. Plan your work with write_todos.2. Call read_site_design once, and list_pages once.3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a time: give the id, title and memory file of each. Unchanged pages already have a current memory file; pages marked "next run" wait for a later run, so leave them out of this one.4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from read_site_design, which colors the site changed, the fonts, and the patterns most page files share: section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each outlier with its page, section path and field: a hex color off the palette where the site uses a palette token, a font or font override no other page uses, a button variant or corner unlike the rest, custom CSS that sets colors or fonts the palette already has.`;const FIX = `7. Where the site has two patterns and you can't tell which one is intended (two button corners, two eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the answer into /memories/site.md, so no later run asks again.8. For each page with fixes a section setting can make (a one-off color back to its palette token, a button to the site's pattern, a text color that fails contrast), call save_style_changes once with every change on that page. The owner approves each page. If the owner sends one back with notes, change it to match and call save_style_changes again; if they skip one, don't try again. Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs andbetween list items.`;const REPORT_ONLY = `7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section with an image or video behind its text, and each pair the check couldn't measure).`;const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,then write its memory file (the path you are given) with write_file, in this shape:# <title>id: <id>updated: <updated, copied exactly>Page: <its own colors and settings, or none>## <section path> (<section type>)- background: dark <value>, light <value>- <field>: dark <value>, light <value> (one line per color; write "default" for '')- settings: <field> <value>, <field> <value> (every setting)- custom CSS: <the CSS, or none>Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you aregiven with read_file. In each section, call check_contrast for every color that colors text or an icon(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against thesection's background: the dark value in the dark theme, the light value in the light theme. A solidbutton's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui foricons and borders, normal for the rest. Skip colors that are "default": they follow the palette.Write the findings file you are told, one line per failing pair:- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with onlythe file's path.`;export interface AgentOptions { /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */ reportOnly?: boolean;}/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */export function buildAgent( site: SiteClient, model: BaseChatModel, run: Run, log: (line: string) => void, { reportOnly = false }: AgentOptions = {}) { const tools = siteTools(site, run, log); return createDeepAgent({ model, systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX), tools: reportOnly ? [tools.readSiteDesign, tools.listPages] : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges], middleware: [todoListMiddleware()], subagents: [ { name: 'style-collector', description: "Reads a batch of pages' styles and writes one memory file per page.", systemPrompt: STYLE_COLLECTOR, tools: [tools.readPageStyle] }, { name: 'contrast-checker', description: 'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.', systemPrompt: CONTRAST_CHECKER, tools: [tools.checkContrast] } ], // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives // for one run. site.md is loaded into the agent's instructions at the start of each run. backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }), memory: ['/memories/site.md'], // Nothing is written without the owner: approve it, edit it, or reject it. interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } }, checkpointer: new MemorySaver() });}/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */export function modelFromEnv() { return new ChatOpenRouter({ model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest', temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0), maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000) });}
import { resolve } from 'node:path';import type { SiteClient } from '@fetchski/attract-cms-sdk';import { interrupt } from '@langchain/langgraph';import { FilesystemBackend } from 'deepagents';import { tool } from 'langchain';import { z } from 'zod';import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';import { checkContrast, paletteContrast } from './design.ts';import { compareWithMemory, type PageStatus } from './memory.ts';/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */export interface Run { memories: FilesystemBackend; pages: PageStatus[]; saved: Map<string, string>;}/** * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the * Deep Agents FilesystemBackend the agent sees as /memories/. */export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run { const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true }); return { memories, pages: [], saved: new Map() };}/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) { // Site design is read once a run, by whichever tool needs it first. let design: ReturnType<typeof readSiteDesign> | undefined; const siteDesign = () => { design ??= readSiteDesign(site); return design; }; const listPagesTool = tool( async () => { const { pages, removed } = await compareWithMemory(run.memories, await listPages(site)); run.pages = pages; const count = (status: string) => pages.filter((page) => page.status === status).length; log( `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.` ); if (count('next run') > 0) { log(`${count('next run')} more wait for the next run (MAX_PAGES).`); } if (removed.length > 0) { log(`Forgot ${removed.length} page(s) that no longer exist.`); } return JSON.stringify(pages.map(({ updatedAt, ...page }) => page)); }, { name: 'list_pages', description: 'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.', schema: z.object({}) } ); const readSiteDesignTool = tool( async () => { const { palette, customized, fonts, customCss } = await siteDesign(); log('Read Site design.'); return JSON.stringify({ palette, customized, paletteContrast: paletteContrast(palette), fonts, customCss }); }, { name: 'read_site_design', description: 'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' + 'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.', schema: z.object({}) } ); const readPageStyleTool = tool( async (args) => { const style = await readPageStyle(site, args.id); log(`Read the style of "${style.title}".`); // The page's last-modified time, for its memory file: the next run compares it with the page's. const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? ''; return JSON.stringify({ updated, ...style }); }, { name: 'read_page_style', description: "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " + "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.", schema: z.object({ id: z.string().describe('The id from list_pages') }) } ); const checkContrastTool = tool( async (args) => { const { palette } = await siteDesign(); return JSON.stringify( checkContrast(args.text, args.background, args.theme, args.size, palette) ); }, { name: 'check_contrast', description: 'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' + 'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' + 'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.', schema: z.object({ text: z.string().describe('The text (or icon) color'), background: z.string().describe('The background it sits on'), theme: z.enum(['dark', 'light']), size: z .enum(['normal', 'large', 'ui']) .describe('large: headings and big titles; ui: icons and borders') }) } ); const saveStyleChangesTool = tool( async (args) => { const answer = await saveStyleChanges(site, args.id, args.changes); if (answer.startsWith('Saved')) { run.saved.set(args.id, args.title); } return answer; }, { name: 'save_style_changes', description: "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " + 'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' + '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.', schema: z.object({ id: z.string(), title: z.string().describe("The page's title, shown to the owner"), changes: z .array(z.object({ path: z.string(), before: z.string(), after: z.string() })) .min(1) .describe('Every change to this page, each at its path') }) } ); const askOwner = tool( // Pauses the run until the owner answers; their answer is the tool's result. async (args) => String(interrupt({ question: args.question })), { name: 'ask_owner', description: 'Asks the website owner one question, and waits for their answer.', schema: z.object({ question: z.string() }) } ); return { listPages: listPagesTool, readSiteDesign: readSiteDesignTool, readPageStyle: readPageStyleTool, checkContrast: checkContrastTool, saveStyleChanges: saveStyleChangesTool, askOwner };}
/** The site's two themes. Dark is the default; visitors can switch to light. */export type Theme = 'dark' | 'light';/** Each palette token's color in each theme, as `#rrggbb`. */export type Palette = Record<Theme, Record<string, string>>;/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */export type Size = 'normal' | 'large' | 'ui';// The colors every site starts with, per palette token: [dark, light]. Site design → Colors// overrides them; a field left empty there keeps these.const BUILT_IN: Record<string, [string, string]> = { accent: ['#625bff', '#544dee'], 'on-accent': ['#ffffff', '#ffffff'], mint: ['#34ddb3', '#0fa380'], coral: ['#ff8e63', '#d8551f'], surface: ['#09090b', '#e4e4e7'], raised: ['#18181b', '#d9d9de'], sunken: ['#0e0e11', '#cfcfd6'], hill: ['#121215', '#d8d8dd'], ink: ['#f4f4f5', '#18181b'], 'ink-muted': ['#a1a1aa', '#52525b'], success: ['#34d399', '#059669'], warning: ['#fbbf24', '#d97706'], danger: ['#f87171', '#dc2626'], info: ['#60a5fa', '#2563eb']};// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.const PALETTE_FIELDS = { paletteAccent: 'accent', paletteOnAccent: 'on-accent', paletteSecondary: 'mint', paletteTertiary: 'coral', paletteLink: 'link', paletteSurface: 'surface', paletteRaised: 'raised', paletteSunken: 'sunken', paletteInk: 'ink', paletteInkMuted: 'ink-muted', paletteSuccess: 'success', paletteWarning: 'warning', paletteDanger: 'danger', paletteInfo: 'info'} as const;const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [ field, `${field}Light`]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];// The text and background pairs every page relies on: [text token, background token].const PALETTE_PAIRS: [string, string][] = [ ['ink', 'surface'], ['ink', 'raised'], ['ink-muted', 'surface'], ['on-accent', 'accent'], ['link', 'surface']];/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */function hex(value: string): string | null { const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim()); if (!match?.[1]) { return null; } const digits = match[1].slice(0, 6); const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits; return `#${full.toLowerCase()}`;}/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */function blend(top: string, bottom: string, share: number): string { const channels = (color: string) => [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16)); const [a, b] = [channels(top), channels(bottom)]; const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share))); return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;}/** The palette the site renders, from Global's palette fields over the built-in colors. */export function resolvePalette(fields: Record<string, unknown>): Palette { const palette: Palette = { dark: {}, light: {} }; const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null); for (const [field, token] of Object.entries(PALETTE_FIELDS)) { const [darkDefault, lightDefault] = BUILT_IN[token] ?? []; const dark = set(field) ?? darkDefault; const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault; if (dark) palette.dark[token] = dark; if (light) palette.light[token] = light; } for (const theme of ['dark', 'light'] as const) { const colors = palette[theme]; colors.link ??= colors.accent ?? ''; colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? ''; // The soft accent tint is the accent, see-through, over the page. colors['accent-soft'] = blend( colors.accent ?? '', colors.surface ?? '', theme === 'dark' ? 0.16 : 0.14 ); } return palette;}/** The palette fields this site changed: the rest are the built-in colors. */export function customizedFields(fields: Record<string, unknown>): string[] { return PALETTE_FIELD_NAMES.filter( (name) => typeof fields[name] === 'string' && fields[name] !== '' );}/** * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`), * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through). * Answers `#rrggbb`, or why it can't be measured. */export function colorOf( value: string, theme: Theme, palette: Palette): { color: string } | { why: string } { const text = value.trim(); if (!text || text === 'default') { return { why: 'not set: it follows the palette' }; } if (text === 'image or video') { return { why: 'the background is an image or video' }; } const token = /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text); const fromPalette = palette[theme][token]; if (fromPalette) { return { color: fromPalette }; } const color = hex(text); if (color) { return { color }; } if (/^#[0-9a-f]{8}$/i.test(text)) { return { why: `${text} is partly see-through, so it depends on what is behind it` }; } return { why: `"${text}" is not a plain color` };}function channel(value: number): number { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;}/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */export function luminance(color: string): number { const n = Number.parseInt(color.slice(1), 16); return ( 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255) );}/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */export function contrastRatio(a: string, b: string): number { const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (light + 0.05) / (dark + 0.05);}/** One text and background pair, checked against WCAG AA. */export type ContrastResult = | { theme: Theme; text: string; background: string; ratio: number; needs: number; passes: boolean; } | { theme: Theme; measured: false; why: string };/** Checks a text color against its background in one theme. The math is here, never the model's. */export function checkContrast( text: string, background: string, theme: Theme, size: Size, palette: Palette): ContrastResult { const fore = colorOf(text, theme, palette); const back = colorOf(background, theme, palette); if ('why' in fore || 'why' in back) { const why = 'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`; return { theme, measured: false, why }; } const ratio = contrastRatio(fore.color, back.color); const needs = size === 'normal' ? 4.5 : 3; return { theme, text: fore.color, background: back.color, ratio: Math.floor(ratio * 100) / 100, needs, passes: ratio >= needs };}/** The palette's own text pairs, checked in both themes. */export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] { return (['dark', 'light'] as const).flatMap((theme) => PALETTE_PAIRS.map(([text, background]) => ({ pair: `${text} on ${background}`, ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette) })) );}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';/** A page, and when its draft last changed. */export interface PageRow { id: string; // the draft's id, `…:en:draft` title: string; updatedAt: string;}/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */export interface ColorField { field: string; dark: string; light: string;}/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */export interface SectionStyle { path: string; type: string; background: { dark: string; light: string } | null; colors: ColorField[]; settings: Record<string, unknown>; customCss: string;}/** One change to a section's setting: the value at `path` goes from `before` to `after`. */export interface Change { path: string; before: string; after: string;}// The page fields that hold sections. Fixes are made inside these only.const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;// Settings that are part of a section's look (colors and backgrounds are read on their own).const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;const COLOR = /Color$/;const BACKGROUND = /Bg(Light)?(Custom)?$/;/** Every page on the site, as drafts: the latest version, published or not. */export async function listPages(site: SiteClient): Promise<PageRow[]> { const pages = await site .pages() .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' }); return pages .filter((page) => page.type !== '@apostrophecms/archive-page') .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));}/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */export async function readSiteDesign(site: SiteClient) { const fonts = ['Display', 'Body', 'Accent'] as const; const fontFields = fonts.flatMap((slot) => [ `font${slot}Source`, `font${slot}Curated`, `font${slot}CdnFamily` ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[]; const [global = {}] = await site .globalContent() .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']); const fields = global as Record<string, unknown>; const font = (slot: string) => { const source = String(fields[`font${slot}Source`] ?? 'default'); if (source === 'curated') return String(fields[`font${slot}Curated`]); if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]); return source === 'upload' ? 'uploaded font files' : 'the built-in font'; }; return { palette: resolvePalette(fields) satisfies Palette, customized: customizedFields(fields), fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') }, customCss: cssOf(fields.designCss) };}/** A custom CSS field's CSS: the site stores `{ css }`. */function cssOf(value: unknown): string { if (typeof value === 'string') return value; return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';}/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] { for (const [key, value] of Object.entries(widget)) { if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) { return { dark: 'image or video', light: 'image or video' }; } } const select = Object.keys(widget).find((key) => key.endsWith('Bg')); if (!select) { return null; } const pick = (choice: unknown, custom: unknown) => choice === 'custom' ? String(custom ?? '') : !choice || choice === 'transparent' ? 'page' : String(choice); const dark = pick(widget[select], widget[`${select}Custom`]); const lightChoice = widget[`${select}Light`]; const light = !lightChoice || lightChoice === 'inherit' ? dark : pick(lightChoice, widget[`${select}LightCustom`]); return { dark, light };}/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */function lookOf(fields: Record<string, unknown>) { const colors: ColorField[] = []; const settings: Record<string, unknown> = {}; for (const [key, value] of Object.entries(fields)) { if (key.startsWith('_') || value === '' || value === null || value === undefined) continue; const field = key.replace(/Light$/, ''); if (COLOR.test(field)) { if (!colors.some((color) => color.field === field)) { const dark = String(fields[field] ?? ''); colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) }); } } else if (SETTING.test(key) && !BACKGROUND.test(key)) { if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) { settings[key] = value; } } } return { colors, settings };}/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) { const items = (area as { items?: unknown[] } | undefined)?.items ?? []; for (const [index, item] of items.entries()) { const widget = item as Record<string, unknown>; const at = `${path}.items.${index}`; const { colors, settings } = lookOf(widget); const section = { path: at, type: String(widget.type), background: backgroundOf(widget), colors, settings, customCss: cssOf(widget.sectionCss) }; if ( !nested || section.background || colors.length || Object.keys(settings).length || section.customCss ) { into.push(section); } for (const [key, value] of Object.entries(widget)) { if ((value as { metaType?: string } | null)?.metaType === 'area') { sectionsIn(value, `${at}.${key}`, into, true); } } }}/** A page's draft, as a plain object. */async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> { return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };}/** A page's look: its own style settings, then every section's. */export async function readPageStyle(site: SiteClient, id: string) { const page = await readDraft(site, id); const sections: SectionStyle[] = []; for (const area of AREAS) { sectionsIn(page[area], area, sections); } const own = Object.fromEntries( Object.entries(page).filter( ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo') ) ); return { title: String(page.title), page: lookOf(own), sections };}/** Why a change can't be saved, or null when it can. */function refusal(change: Change): string | null { const [area = '', ...rest] = change.path.split('.'); const field = rest.at(-1) ?? ''; if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) { return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`; } if (/css/i.test(field)) { return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`; } const isColor = COLOR.test(field.replace(/Light$/, '')); if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) { return `"${field}" is not a color, background or style setting.`; } if ( isColor && change.after && !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after) ) { return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`; } return null;}/** * Writes the changes to the page's DRAFT. Each change must still match what the agent read * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent. */export async function saveStyleChanges( site: SiteClient, id: string, changes: readonly Change[]): Promise<string> { const draftId = id.replace(/:published$/, ':draft'); const page = await readDraft(site, draftId); const areas = new Map<string, Record<string, unknown>>(); for (const change of changes) { const refused = refusal(change); if (refused) { return `Not saved: ${refused}`; } const [area = '', ...rest] = change.path.split('.'); if (!areas.has(area)) { areas.set(area, structuredClone(page[area]) as Record<string, unknown>); } let parent = areas.get(area); for (const key of rest.slice(0, -1)) { parent = parent?.[key] as Record<string, unknown> | undefined; } const last = rest.at(-1) ?? ''; if (!parent || String(parent[last] ?? '') !== change.before) { return `Not saved: "${change.path}" changed since you read it. Read the page again.`; } parent[last] = change.after; } await site.pages().updatePage({ id: draftId, sections: areas.get('sections'), aboveSections: areas.get('aboveSections'), belowSections: areas.get('belowSections') }); return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;}/** A private link to a page's draft, for anyone you send it to: no sign-in. */export async function previewLink(site: SiteClient, id: string): Promise<string> { const page = await readDraft(site, id); const shared = await site.pages().sharePage({ id, share: true }); return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;}
import type { FilesystemBackend } from 'deepagents';import type { PageRow } from './content.ts';/** A page, and whether it changed since its memory file was written. */export interface PageStatus extends PageRow { status: 'new' | 'changed' | 'unchanged' | 'next run'; memoryFile: string; // where the agent keeps its notes on this page}/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */export function memoryFileFor(id: string): string { return `/memories/pages/${id.split(':')[0]}.md`;}/** The same file inside the memories backend, which is mounted at /memories/. */const inMemories = (file: string) => file.replace(/^\/memories/, '');/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */async function updatedInMemory(memories: FilesystemBackend, id: string) { const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5); return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');}/** * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the * next run. A changed page's old file is deleted for the style-collector to write again, and the files * of pages that no longer exist are deleted. */export async function compareWithMemory( memories: FilesystemBackend, pages: readonly PageRow[]): Promise<{ pages: PageStatus[]; removed: string[] }> { const statuses: PageStatus[] = []; let toRead = 0; for (const page of pages) { const updated = await updatedInMemory(memories, page.id); let status: PageStatus['status'] = updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed'; if (status !== 'unchanged' && ++toRead > MAX_PAGES) { status = 'next run'; } if (status === 'changed') { await memories.delete(inMemories(memoryFileFor(page.id))); } statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) }); } const live = new Set(statuses.map((page) => inMemories(page.memoryFile))); const removed = ((await memories.ls('/pages/')).files ?? []) .map((file) => file.path) .filter((path) => !live.has(path)); for (const path of removed) { await memories.delete(path); } return { pages: statuses, removed };}
import { randomUUID } from 'node:crypto';import { Command } from '@langchain/langgraph';import type { buildAgent } from './agent.ts';import type { Change } from './content.ts';import type { Usage } from './usage.ts';/** A draft the agent wants to save: the document, and every change to it. */export interface Draft { kind: string; title: string; changes: Change[];}/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */export type Choice = | { type: 'all' } | { type: 'some'; keep: number[] } | { type: 'revise'; feedback: string } | { type: 'skip'; reason: string };/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */export interface Owner { /** The agent is working (a spinner, in the terminal). */ busy?(): void; /** Answers the agent's question. */ answer(question: string): Promise<string>; /** Decides on a draft the agent wants to save. */ decide(draft: Draft): Promise<Choice>;}/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */export const nobody: Owner = { answer: async () => { throw new Error('A report-only run never asks.'); }, decide: async () => { throw new Error('A report-only run never saves.'); }};interface ActionRequest { name: string; args: { kind?: string; id?: string; title?: string; changes?: Change[] };}type Decision = | { type: 'approve' } | { type: 'reject'; message: string } | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);/** * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the * drafts it wants to write. Resolves the agent's closing summary. */export async function runWithOwner( agent: ReturnType<typeof buildAgent>, task: string, owner: Owner, usage?: Usage): Promise<string> { // `usage` counts every model call's tokens and cost, the subagents' too. const config = { configurable: { thread_id: randomUUID() }, recursionLimit: MAX_STEPS, callbacks: usage ? [usage] : [] }; let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] }; for (;;) { owner.busy?.(); const result = await agent.invoke(input, config); // Several rewriters can pause at once: each pause gets its own answer, by its id. const pending = result.__interrupt__ ?? []; if (pending.length === 0) { const last = result.messages.at(-1); return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? ''); } const answers: Record<string, unknown> = {}; for (const { id, value } of pending) { const paused = value as { question?: string; actionRequests?: ActionRequest[] }; if (paused.question) { answers[String(id)] = await owner.answer(paused.question); continue; } const decisions: Decision[] = []; for (const action of paused.actionRequests ?? []) { decisions.push(await review(action, owner)); } answers[String(id)] = { decisions }; } input = new Command({ resume: answers }); }}/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */async function review(action: ActionRequest, owner: Owner): Promise<Decision> { const changes = action.args.changes ?? []; const choice = await owner.decide({ kind: action.args.kind ?? 'page', title: action.args.title ?? '', changes }); switch (choice.type) { case 'all': return { type: 'approve' }; case 'some': { const kept = changes.filter((_, index) => choice.keep.includes(index)); if (kept.length === 0) { return { type: 'reject', message: 'The owner kept none of these changes. Do not try them again.' }; } return { type: 'edit', editedAction: { name: action.name, args: { ...action.args, changes: kept } } }; } case 'revise': return { type: 'reject', message: `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` + 'Revise your changes to match, then call the same tool again with every change.' }; case 'skip': return { type: 'reject', message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.` }; }}
import { stdout } from 'node:process';import { stripVTControlCharacters, styleText } from 'node:util';import * as prompt from '@clack/prompts';import { diffWordsWithSpace } from 'diff';import { Marked, type Tokens } from 'marked';import stringWidth from 'string-width';import yoctoSpinner from 'yocto-spinner';import type { Change } from './content.ts';import type { Choice, Draft, Owner } from './review.ts';import type { Usage } from './usage.ts';/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */export class Stopped extends Error {}/** * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is * doing and what the run has used so far; its words as formatted text. */export function terminalOwner(usage?: Usage): Owner & { step: (line: string) => void; report: (markdown: string) => void; failed: (error: unknown) => void;} { // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers. const spin = yoctoSpinner({ stream: stdout }); // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere // else each one would print a new line, so output sent to a file gets the start and the steps. const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env); let spinning = false; let started = 0; let clock: NodeJS.Timeout | undefined; let steps: string[] = []; let doing = 'The agent is working'; // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped. // On a narrow terminal the tokens and cost go first, then the time, before the task is cut. const line = () => { const seconds = Math.floor((Date.now() - started) / 1000); const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`; const width = (stdout.columns || 80) - 3; const tail = [[spent, time], [time]] .map((parts) => parts.filter(Boolean).join(' · ')) .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? ''; const room = tail ? width - stringWidth(tail) - 2 : width; return tail ? `${fit(doing, room)} ${styleText('dim', tail)}` : fit(doing, room); }; const withUsage = (text: string) => usage ? `${text} ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text; const refresh = () => { if (spinning && live) { spin.text = line(); } }; if (usage) { usage.onCall = refresh; } // The spinner sits on the run's guide line, like the rest of the terminal. const startSpinner = () => { started = Date.now(); spinning = true; stdout.write(`${styleText('gray', prompt.S_BAR)}\n`); spin.start(line()); if (live) { clock = setInterval(refresh, 1000); } }; const stopSpinner = (finish: () => void) => { clearInterval(clock); spin.stop(); spinning = false; finish(); }; // What the agent did since you last heard from it, as one dim line. const settle = () => { if (spinning) { const done = withUsage(styleText('dim', steps.join(' · ') || 'Done')); stopSpinner(() => prompt.log.step(done, { spacing: 0 })); } steps = []; }; return { busy() { if (!spinning) { doing = 'The agent is working'; startSpinner(); } }, step(text) { doing = text.replace(/\.$/, ''); steps.push(doing); refresh(); }, async answer(question) { settle(); say(markdown(question), 'cyan'); return ask(() => prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' }) ); }, async decide(draft) { settle(); say( [ styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`), ...draft.changes.map((change, index) => showChange(change, index)) ].join('\n\n'), 'cyan' ); return decide(draft); }, /** Shows the run's report. */ report(text) { settle(); say(markdown(text), 'green'); }, /** The run stopped on an error: the spinner says so, and the error follows. */ failed(error) { if (spinning) { stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 })); } prompt.log.error(error instanceof Error ? error.message : String(error)); } };}/** "183,204 tokens": read and written together. */function tokens(usage: Usage): string { return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;}/** * Text cut to a number of terminal cells, never through a character (an emoji counts as two). * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji * is 8), so the cut keeps both within the limit. */function fit(text: string, cells: number): string { const size = (part: string) => Math.max(stringWidth(part), part.length); if (size(text) <= cells) { return text; } let cut = ''; for (const { segment } of new Intl.Segmenter().segment(text)) { if (size(`${cut}${segment}…`) > cells) { break; } cut += segment; } return `${cut}…`;}/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */function say(text: string, color: 'cyan' | 'green') { prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });}// The terminal's color and style codes (ESC [ … m), as styleText writes them.const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */export function wrap(text: string, width: number): string { const visible = (line: string) => stripVTControlCharacters(line).length; return text .split('\n') .map((line) => { // A wrapped line sits under the text, past any list number or bullet. const lead = /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? ''; const indent = ' '.repeat(lead.length); const words = line.trimStart().split(' '); const lines: string[] = []; let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? ''); for (const word of words) { if (visible(`${current} ${word}`) > width && current.trim()) { // The colors in force so far carry onto the next line: the guide line resets them. const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? ''; lines.push(`${current}\x1b[0m`); current = indent + colors + word; } else { current = `${current} ${word}`; } } lines.push(current); return lines.join('\n'); }) .join('\n');}/** Starts and ends the run's frame in the terminal. */export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));export const outro = (line: string) => prompt.outro(line);export const note = (line: string) => prompt.log.info(line);async function decide(draft: Draft): Promise<Choice> { const many = draft.changes.length > 1; const choice = await ask(() => prompt.select({ message: 'Your call', options: [ { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' }, ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []), { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' }, { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' } ] }) ); if (choice === 'some') { const keep = await ask(() => prompt.multiselect({ message: 'Keep these (Space to toggle, Enter to save)', options: draft.changes.map((change, index) => ({ value: index, label: `${index + 1}. ${plain(change.after)}` })), initialValues: draft.changes.map((_, index) => index), required: false }) ); return { type: 'some', keep }; } if (choice === 'revise') { return { type: 'revise', feedback: await ask(() => prompt.text({ message: 'What should change?', validate: (value) => (value?.trim() ? undefined : 'Say what should change.') }) ) }; } if (choice === 'skip') { return { type: 'skip', reason: await ask(() => prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' }) ) }; } return { type: 'all' };}/** Waits for one prompt; Ctrl+C or Esc stops the run. */async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> { const value = await show(); if (prompt.isCancel(value)) { throw new Stopped('You stopped the run.'); } return value as Exclude<T, symbol>;}/** * One change, numbered: the text before and after, whole, with the words that change marked (red * and struck through before, green after). Still readable with the colors gone, pasted elsewhere. */export function showChange(change: Change, index: number): string { const parts = diffWordsWithSpace(change.before, change.after); const kept = parts.filter((part) => !part.added && !part.removed); const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`; // Mostly rewritten: the old text in red and the new in green, with no marks to read through. if ( kept .map((part) => part.value) .join('') .trim().length < change.after.length / 2 ) { return [ label, ` ${styleText('dim', 'before')} ${styleText('red', change.before)}`, ` ${styleText('dim', 'after ')} ${styleText('green', change.after)}` ].join('\n'); } const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) => parts .filter((part) => !part[drop]) .map((part) => part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value) ) .join(''); return [ label, ` ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`, ` ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}` ].join('\n');}/** Rich text's HTML tags, dimmed, so the words stand out. */const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));/** Text with its HTML tags taken out, on one line, for a short label. */function plain(text: string, max = 70): string { const flat = text .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;}// The agent writes Markdown. This renders it for the terminal, with a blank line after every// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.const terminal = new Marked({ renderer: { heading({ tokens }) { return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`; }, paragraph({ tokens }) { return `${this.parser.parseInline(tokens)}\n\n`; }, list(list: Tokens.List) { const start = typeof list.start === 'number' ? list.start : 1; return list.items .map((item, index) => { const bullet = list.ordered ? `${start + index}.` : '•'; const body = this.parser.parse(item.tokens).trim(); // Lines under the first are indented to sit under its text. const pad = ' '.repeat(bullet.length + 1); return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`; }) .join(''); }, text(token) { return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text; }, strong({ tokens }) { return styleText('bold', this.parser.parseInline(tokens)); }, em({ tokens }) { return styleText('italic', this.parser.parseInline(tokens)); }, codespan({ text }) { return styleText('cyan', text); }, code({ text }) { return `${styleText('cyan', text)}\n\n`; }, blockquote({ tokens }) { return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`; }, link({ href, tokens }) { const text = this.parser.parseInline(tokens); return text === href ? styleText('underline', href) : `${text} ${styleText('dim', `(${href})`)}`; }, hr() { return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`; }, br() { return '\n'; }, html({ text }) { return text; } }});/** The agent's Markdown, formatted for the terminal. */export function markdown(text: string): string { return String(terminal.parse(text, { async: false })).trim();}
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';import { AIMessage } from '@langchain/core/messages';import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';/** * Counts every model call in a run, the subagents' too: tokens read and written, and what * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call. */export class Usage extends BaseCallbackHandler { name = 'usage'; calls = 0; inputTokens = 0; outputTokens = 0; /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */ cost = 0; /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */ uncosted = 0; /** Called after each model call, e.g. to refresh a running total on screen. */ onCall?: () => void; override handleLLMEnd(output: LLMResult) { this.calls += 1; const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message; const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined; this.inputTokens += usage?.input_tokens ?? 0; this.outputTokens += usage?.output_tokens ?? 0; const cost = output.llmOutput?.tokenUsage?.cost; if (typeof cost === 'number') { this.cost += cost; } else { this.uncosted += 1; } this.onCall?.(); } /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */ line(): string { const count = new Intl.NumberFormat('en-US'); const parts = [ `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`, `${count.format(this.inputTokens)} tokens in`, `${count.format(this.outputTokens)} out`, `$${this.cost.toFixed(4)}` ]; const line = parts.join(' · '); return this.uncosted > 0 ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)` : line; } /** The run's usage, as a Markdown section for the report and its email. */ markdown(): string { return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`; }}
import type { SiteClient } from '@fetchski/attract-cms-sdk';import { previewLink } from './content.ts';import type { Usage } from './usage.ts';/** A draft the run saved, and the private link that previews it. */export interface WaitingDraft { title: string; link: string;}/** Every page the run saved a draft of (id → title), each with its preview link. */export async function draftsWaiting( site: SiteClient, saved: Map<string, string>): Promise<WaitingDraft[]> { const drafts: WaitingDraft[] = []; for (const [id, title] of saved) { drafts.push({ title, link: await previewLink(site, id) }); } return drafts;}/** * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then * what the run used (tokens and cost). */export function reportMarkdown( summary: string, drafts: readonly WaitingDraft[], usage?: Usage, date = new Date()) { // The site's host for the heading; a missing or mistyped URL still gets a report that says why. const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site'; const lines = [ `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`, '', summary.trim() ]; if (drafts.length > 0) { lines.push('', '## Drafts waiting for you', ''); lines.push('Publish each one in the CMS when it looks right.', ''); for (const draft of drafts) { lines.push(`- "${draft.title}", preview: ${draft.link}`); } } if (usage) { lines.push('', usage.markdown()); } return `${lines.join('\n')}\n`;}
import { createSiteClient } from '@fetchski/attract-cms-sdk';import { buildAgent, modelFromEnv } from './agent.ts';import { draftsWaiting, reportMarkdown } from './report.ts';import { runWithOwner } from './review.ts';import { intro, outro, Stopped, terminalOwner } from './terminal.ts';import { newRun } from './tools.ts';import { Usage } from './usage.ts';// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .envconst site = createSiteClient();const run = newRun();const usage = new Usage();const owner = terminalOwner(usage);const agent = buildAgent(site, modelFromEnv(), run, owner.step);intro('One design');let summary: string;try { summary = await runWithOwner( agent, 'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.', owner, usage );} catch (error) { if (!(error instanceof Stopped)) { owner.failed(error); throw error; } // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them. summary = 'You stopped the run before it finished.';}const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);owner.report(report);outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
import { deepStrictEqual, ok, strictEqual } from 'node:assert';import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { test } from 'node:test';import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';import { AIMessage } from '@langchain/core/messages';import type { LLMResult } from '@langchain/core/outputs';import { fakeModel } from 'langchain';import { buildAgent } from '../src/agent.ts';import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';import { newRun, siteTools } from '../src/tools.ts';import { Usage } from '../src/usage.ts';const HOME = 'page-home:en:draft';const ABOUT = 'page-about:en:draft';const MONDAY = '2026-10-26T09:00:00.000Z';const TUESDAY = '2026-10-27T09:00:00.000Z';// A fresh memory folder, per test.const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));function area(widget: Record<string, unknown>) { return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };}// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.const PAGES: Record<string, ReturnType<typeof factories.Page>> = { [HOME]: factories.Page({ id: HOME, title: 'Home', sections: area({ type: 'intro', introBg: 'raised', introEyebrowColor: '--fetchski-mint', introCtaRadius: 'slight' }) }), [ABOUT]: factories.Page({ id: ABOUT, title: 'About us', sections: area({ type: 'media-text', mediaTextBg: 'custom', mediaTextBgCustom: '#ffffff', mediaTextHeadingColor: '#777777', mediaTextCtaRadius: 'pill' }) })};function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) { return fakeSite({ 'pages.listPages': factories.ListPagesResponse({ results: Object.entries(updated).map(([id, updatedAt]) => ({ id, title: String(PAGES[id]?.title), type: 'default-page', updatedAt: new Date(updatedAt) })) }), 'pages.getPage': (args) => PAGES[String(args.id)], 'globalContent.listGlobalContent': factories.GlobalList({ results: [ { paletteSecondary: '#2dd4bf', fontDisplaySource: 'curated', fontDisplayCurated: 'fraunces' } ], pages: 1, currentPage: 1 }) });}const HEADING_FIX = { path: 'sections.items.0.mediaTextHeadingColor', before: '#777777', after: '--fetchski-ink'};const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.function scriptedModel() { return ( fakeModel() .respondWithTools([ { name: 'read_site_design', args: {} }, { name: 'list_pages', args: {} } ]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) // The style-collector subagent: .respondWithTools([ { name: 'read_page_style', args: { id: HOME } }, { name: 'read_page_style', args: { id: ABOUT } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-home.md', content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised` } }, { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777` } } ]) .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md')) // The main agent again: .respondWithTools([ { name: 'task', args: { subagent_type: 'contrast-checker', description: 'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md' } } ]) // The contrast-checker subagent: .respondWithTools([ { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } } ]) .respondWithTools([ { name: 'check_contrast', args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' } } ]) .respondWithTools([ { name: 'write_file', args: { file_path: '/findings/contrast-1.md', content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5' } } ]) .respond(new AIMessage('/findings/contrast-1.md')) // The main agent again: .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/site.md', content: '# Site design\n- Buttons: slight corners' } } ]) .respondWithTools([ { name: 'ask_owner', args: { question: 'Home has slight button corners, About us has pill. Which is yours?' } } ]) .respondWithTools([ { name: 'save_style_changes', args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] } } ]) .respond( new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.') ) );}// The person at the keyboard, scripted: their answers to questions, then their call on each draft.function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } { const drafts: Draft[] = []; return { drafts, answer: async () => answers.shift() ?? '', decide: async (draft) => { drafts.push(draft); return choices.shift() ?? { type: 'skip', reason: '' }; } };}test('a first run collects every page into memory, and saves an approved fix as a draft', async () => { const site = siteWithTwoDesigns(); const folder = memoryFolder(); const run = newRun(folder); const owner = scriptedOwner(['Slight'], [{ type: 'all' }]); const summary = await runWithOwner( buildAgent(site, scriptedModel(), run, () => {}), 'Check this whole website.', owner ); strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.'); site.assertCalled('pages.updatePage', (args) => { const section = (args.sections as { items: Record<string, unknown>[] }).items[0]; return ( args.id === ABOUT && section?.mediaTextHeadingColor === '--fetchski-ink' && section.mediaTextCtaRadius === 'slight' ); }); deepStrictEqual([...run.saved], [[ABOUT, 'About us']]); deepStrictEqual( owner.drafts.map((draft) => draft.title), ['About us'] ); ok(existsSync(join(folder, 'pages', 'page-home.md'))); ok(existsSync(join(folder, 'site.md'))); // The next run finds both pages unchanged, from their memory files alone. const next = newRun(folder); await siteTools(site, next, () => {}).listPages.invoke({}); deepStrictEqual( next.pages.map((page) => page.status), ['unchanged', 'unchanged'] );});test('a fix the owner skips is never written', async () => { const site = siteWithTwoDesigns(); await runWithOwner( buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}), 'Check this whole website.', scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }]) ); site.assertNotCalled('pages.updatePage');});test('a report-only second run collects only the changed page, and never asks or writes', async () => { // The last run scanned Home, About us and a page since deleted. Since then, only About us changed. const folder = memoryFolder(); mkdirSync(join(folder, 'pages')); for (const name of ['page-home', 'page-about', 'page-old']) { writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`); } writeFileSync( join(folder, 'site.md'), '# Site design\n- Buttons: slight corners, the owner chose' ); const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY }); const run = newRun(folder); const model = fakeModel() .respondWithTools([{ name: 'list_pages', args: {} }]) .respondWithTools([ { name: 'task', args: { subagent_type: 'style-collector', description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.` } } ]) .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }]) .respondWithTools([ { name: 'write_file', args: { file_path: '/memories/pages/page-about.md', content: `# About us, as it is now\nupdated: ${TUESDAY}\n` } } ]) .respond(new AIMessage('/memories/pages/page-about.md')) .respond(new AIMessage('## Contrast\nNo failures.')); const summary = await runWithOwner( buildAgent(site, model, run, () => {}, { reportOnly: true }), 'Check this whole website, and report what you find.', nobody ); strictEqual(summary, '## Contrast\nNo failures.'); deepStrictEqual( run.pages.map((page) => [page.title, page.status]), [ ['Home', 'unchanged'], ['About us', 'changed'] ] ); site.assertCalledTimes('pages.getPage', 1); site.assertCalled('pages.getPage', (args) => args.id === ABOUT); site.assertNotCalled('pages.updatePage'); strictEqual( readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'), `# About us, as it is now\nupdated: ${TUESDAY}\n` ); strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false); // site.md, with the owner's choice, was in the agent's instructions from the start. const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? ''; ok(instructions.includes('Buttons: slight corners, the owner chose'));});test('check_contrast measures with the WCAG formula, in code', async () => { const palette = resolvePalette({}); strictEqual(contrastRatio('#000000', '#ffffff'), 21); strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48); deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), { theme: 'light', text: '#777777', background: '#ffffff', ratio: 4.47, needs: 4.5, passes: false }); const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette); ok('passes' in large && large.passes); // Through the tool: palette tokens resolve with the site's own palette, in the theme asked. const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {}); const mint = JSON.parse( await tool.invoke({ text: '--fetchski-mint', background: 'page', theme: 'dark', size: 'normal' }) ); deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]); const overVideo = JSON.parse( await tool.invoke({ text: '#ffffff', background: 'image or video', theme: 'dark', size: 'large' }) ); deepStrictEqual(overVideo, { theme: 'dark', measured: false, why: 'background: the background is an image or video' });});test('usage adds up tokens and the cost OpenRouter reports for each call', () => { const usage = new Usage(); const call = (input: number, output: number, cost?: number) => usage.handleLLMEnd({ generations: [ [ { text: '', message: new AIMessage({ content: '', usage_metadata: { input_tokens: input, output_tokens: output, total_tokens: input + output } }) } ] ], // OpenRouter's own `usage` object, as ChatOpenRouter passes it on. llmOutput: { tokenUsage: cost === undefined ? {} : { cost } } } as unknown as LLMResult); call(1000, 200, 0.0012); call(3000, 50, 0.0021); call(10, 5); strictEqual( usage.line(), '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)' ); ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));});
# Your site, and the API key from "Make the API key"FETCHSKI_ATTRACT_URL=https://acme.exampleFETCHSKI_ATTRACT_API_KEY=fsk_acme_…FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.OPENROUTER_API_KEY=sk-or-v1-…OPENROUTER_MODEL=~google/gemini-flash-latest# How freely it words things (0 = the same way every time), and the most it writes in one answerOPENROUTER_TEMPERATURE=0OPENROUTER_MAX_TOKENS=16000# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)MAX_PAGES=80AGENT_MAX_STEPS=400