anvilsign in

collin/strudel-claude

pre-demo / node-tui / claude.ts
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.
15import fs from 'node:fs';
16import path from 'node:path';
17import { fileURLToPath } from 'node:url';
18import Anthropic from '@anthropic-ai/sdk';
19
20const HERE = path.dirname(fileURLToPath(import.meta.url));
21const PROMPT_PATH = path.join(HERE, '..', 'PROMPT.md');
22
23export 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
31export type ModelId = (typeof MODELS)[number]['id'];
32export const DEFAULT_MODEL: ModelId = 'claude-opus-4-8';
33
34const 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. */
38const HISTORY_LIMIT = 40;
39const HISTORY_KEEP = 24;
40
41const 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
54export interface Turn {
55 role: 'user' | 'assistant';
56 content: string;
57 /** The pattern an assistant turn produced, if any. */
58 code?: string;
59}
60
61export interface Reply {
62 message: string;
63 code: string;
64}
65
66let system: string | null = null;
67
68function 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
76function 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.
91const 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 */
104export 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. */
109function 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 */
126export 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}