| 1 | // Pattern generation, talking to the Anthropic API directly. |
| 2 | // |
| 3 | // The TUI is self-contained — it does not go through server/index.js, and does |
| 4 | // not need it running. What it does share is PROMPT.md: the system prompt is the |
| 5 | // product here, and having the terminal and the browser answer differently |
| 6 | // because their prompts drifted would be a bug, not a feature. |
| 7 | // |
| 8 | // The request shape deliberately mirrors server/index.js, including where the |
| 9 | // cache breakpoint sits. That placement is the whole reason a long session stays |
| 10 | // cheap: everything up to and including the user's message is byte-identical |
| 11 | // next request and gets cached, while the editor contents — which change on |
| 12 | // every keystroke — go *after* the breakpoint as their own turn. Folding them |
| 13 | // into the user's message instead would rewrite that turn every time and throw |
| 14 | // away the cache on each request. |
| 15 | import fs from 'node:fs'; |
| 16 | import path from 'node:path'; |
| 17 | import { fileURLToPath } from 'node:url'; |
| 18 | import Anthropic from '@anthropic-ai/sdk'; |
| 19 | |
| 20 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 21 | const PROMPT_PATH = path.join(HERE, '..', 'PROMPT.md'); |
| 22 | |
| 23 | export const MODELS = [ |
| 24 | { id: 'claude-opus-4-8', label: 'Opus 4.8' }, |
| 25 | { id: 'claude-opus-5', label: 'Opus 5', thinks: true }, |
| 26 | { id: 'claude-sonnet-5', label: 'Sonnet 5' }, |
| 27 | // Haiku 4.5 rejects output_config.effort outright — sending it is a 400. |
| 28 | { id: 'claude-haiku-4-5', label: 'Haiku 4.5', effort: false }, |
| 29 | ] as const; |
| 30 | |
| 31 | export type ModelId = (typeof MODELS)[number]['id']; |
| 32 | export const DEFAULT_MODEL: ModelId = 'claude-opus-4-8'; |
| 33 | |
| 34 | const EFFORT = process.env['STRUDEL_EFFORT'] ?? 'medium'; |
| 35 | |
| 36 | /** Keep history bounded, but cut in one step rather than continuously — a |
| 37 | * window that slides every turn invalidates the cached prefix every turn. */ |
| 38 | const HISTORY_LIMIT = 40; |
| 39 | const HISTORY_KEEP = 24; |
| 40 | |
| 41 | const OUTPUT_SCHEMA = { |
| 42 | type: 'object', |
| 43 | properties: { |
| 44 | message: { type: 'string', description: 'A short, friendly reply to the user (1-2 sentences).' }, |
| 45 | code: { |
| 46 | type: 'string', |
| 47 | description: 'The full runnable Strudel pattern, or an empty string if no music change is needed.', |
| 48 | }, |
| 49 | }, |
| 50 | required: ['message', 'code'], |
| 51 | additionalProperties: false, |
| 52 | }; |
| 53 | |
| 54 | export interface Turn { |
| 55 | role: 'user' | 'assistant'; |
| 56 | content: string; |
| 57 | /** The pattern an assistant turn produced, if any. */ |
| 58 | code?: string; |
| 59 | } |
| 60 | |
| 61 | export interface Reply { |
| 62 | message: string; |
| 63 | code: string; |
| 64 | } |
| 65 | |
| 66 | let system: string | null = null; |
| 67 | |
| 68 | function loadSystem(): string { |
| 69 | if (system !== null) return system; |
| 70 | const text = fs.readFileSync(PROMPT_PATH, 'utf8').trim(); |
| 71 | if (!text) throw new Error(`The system prompt at ${PROMPT_PATH} is empty.`); |
| 72 | system = text; |
| 73 | return system; |
| 74 | } |
| 75 | |
| 76 | function trimHistory(turns: readonly Turn[]): Turn[] { |
| 77 | if (turns.length <= HISTORY_LIMIT) return [...turns]; |
| 78 | const tail = turns.slice(-HISTORY_KEEP); |
| 79 | // The API requires the first message to be from the user. |
| 80 | const firstUser = tail.findIndex((t) => t.role === 'user'); |
| 81 | return firstUser <= 0 ? tail : tail.slice(firstUser); |
| 82 | } |
| 83 | |
| 84 | // How a stored turn is rendered for the API. Assistant turns carry the pattern |
| 85 | // they produced, in the same JSON shape the model emits — without it a long |
| 86 | // session shows the model nothing but its own content-free replies ("Added more |
| 87 | // sparkle.") with no record of what it actually wrote, and the patterns drift. |
| 88 | // |
| 89 | // This rendering must be stable: once a turn has been sent one way it has to |
| 90 | // keep being sent that way, or the cached prefix breaks on the next request. |
| 91 | const renderTurn = (t: Turn): string => |
| 92 | t.role === 'assistant' && t.code |
| 93 | ? JSON.stringify({ message: t.content || '', code: t.code }) |
| 94 | : t.content || ''; |
| 95 | |
| 96 | /** |
| 97 | * Whether an API key is in the environment. |
| 98 | * |
| 99 | * Only used to *show* whether one is set — never to decide whether to make the |
| 100 | * request. The SDK can also authenticate from an `ant auth login` profile, so a |
| 101 | * missing ANTHROPIC_API_KEY is not the same as being unable to call the API, and |
| 102 | * refusing to try would take away a path that works. |
| 103 | */ |
| 104 | export function hasApiKey(): boolean { |
| 105 | return Boolean(process.env['ANTHROPIC_API_KEY']); |
| 106 | } |
| 107 | |
| 108 | /** Turn an SDK failure into something worth putting in a 34-column pane. */ |
| 109 | function describe(err: unknown): string { |
| 110 | const status = (err as { status?: number }).status; |
| 111 | if (status === 401 || status === 403) { |
| 112 | return hasApiKey() |
| 113 | ? 'that API key was rejected — check ANTHROPIC_API_KEY' |
| 114 | : 'no credentials — set ANTHROPIC_API_KEY in .env, or run `ant auth login`'; |
| 115 | } |
| 116 | if (status === 429) return 'rate limited — give it a moment'; |
| 117 | return err instanceof Error ? err.message : String(err); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Ask Claude for a pattern. |
| 122 | * |
| 123 | * `turns` is the conversation so far, ending with the user's request; `code` is |
| 124 | * what's currently in the editor. |
| 125 | */ |
| 126 | export async function generate( |
| 127 | turns: readonly Turn[], |
| 128 | code: string, |
| 129 | modelId: ModelId = DEFAULT_MODEL, |
| 130 | ): Promise<Reply> { |
| 131 | const model = MODELS.find((m) => m.id === modelId) ?? MODELS[0]; |
| 132 | const client = new Anthropic(); |
| 133 | |
| 134 | const messages = trimHistory(turns).map((t) => ({ |
| 135 | role: t.role, |
| 136 | content: [{ type: 'text' as const, text: renderTurn(t) }], |
| 137 | })); |
| 138 | |
| 139 | // The cache breakpoint: everything above this is stable across requests. |
| 140 | const last = messages.at(-1); |
| 141 | if (last?.content[0]) { |
| 142 | Object.assign(last.content[0], { cache_control: { type: 'ephemeral' } }); |
| 143 | } |
| 144 | messages.push({ |
| 145 | role: 'user', |
| 146 | content: [{ type: 'text', text: `Current pattern in the editor:\n\`\`\`\n${code || '(empty)'}\n\`\`\`` }], |
| 147 | }); |
| 148 | |
| 149 | let response; |
| 150 | try { |
| 151 | response = await client.messages.create({ |
| 152 | model: model.id, |
| 153 | // Headroom so a big pattern + message can't truncate the JSON. Models that |
| 154 | // think spend the same budget on reasoning first, so they get more. |
| 155 | max_tokens: 'thinks' in model && model.thinks ? 8192 : 4096, |
| 156 | system: [{ type: 'text', text: loadSystem(), cache_control: { type: 'ephemeral' } }], |
| 157 | messages, |
| 158 | output_config: { |
| 159 | ...('effort' in model && model.effort === false ? {} : { effort: EFFORT }), |
| 160 | format: { type: 'json_schema', schema: OUTPUT_SCHEMA }, |
| 161 | }, |
| 162 | } as Anthropic.MessageCreateParamsNonStreaming); |
| 163 | } catch (err) { |
| 164 | throw new Error(describe(err)); |
| 165 | } |
| 166 | |
| 167 | // If the model hit the token cap the JSON is incomplete — never ship a |
| 168 | // half-parsed pattern to the editor. |
| 169 | if (response.stop_reason === 'max_tokens') { |
| 170 | return { message: 'That got a bit long and I ran out of room — try again, maybe a touch simpler.', code: '' }; |
| 171 | } |
| 172 | |
| 173 | const block = response.content.find((b) => b.type === 'text'); |
| 174 | let parsed: { message?: unknown; code?: unknown }; |
| 175 | try { |
| 176 | parsed = JSON.parse(block && 'text' in block ? block.text : '{}') as typeof parsed; |
| 177 | } catch { |
| 178 | return { message: "I couldn't format that cleanly — mind trying again?", code: '' }; |
| 179 | } |
| 180 | |
| 181 | // Defensively strip a markdown code fence if the model added one — a stray |
| 182 | // ``` at char 0 is exactly the "Unexpected token (1:0)" Strudel parse error. |
| 183 | const raw = typeof parsed.code === 'string' ? parsed.code : ''; |
| 184 | const cleaned = raw |
| 185 | .replace(/^\s*```[a-zA-Z]*\s*\n?/, '') |
| 186 | .replace(/\n?```\s*$/, '') |
| 187 | .trim(); |
| 188 | |
| 189 | return { |
| 190 | message: typeof parsed.message === 'string' ? parsed.message.trim() : '', |
| 191 | code: cleaned, |
| 192 | }; |
| 193 | } |