diff --git a/README.md b/README.md index 877a498..0e6a92c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ * Added ability to suffer immense pain, trying to use a UI created/modified by pifreak! * Added ability to upload list of SEEDS in TXT or CSV format! +## Assistant, WebMCP, and seed lists +* **Chat button (top right):** an in-app Balatro assistant powered by the Anthropic API. Paste your own API key in its settings (it stays in your browser's localStorage and goes straight to `api.anthropic.com`). It reads and drives the app through tools: analyze a seed, read any ante's shop queue and packs, find items across antes, mark purchases, navigate, load seed lists, edit the JAML filter, and `remember` notes about you so later sessions are personalized. +* **WebMCP:** the same tools are registered on `document.modelContext` (with the `@mcp-b/webmcp-polyfill` fallback), so browser agents and the Chrome DevTools WebMCP pane / Model Context Tool Inspector extension can call `analyze_seed`, `get_seed_overview`, `find_items`, `buy_item`, `load_seed_list`, `step_seed_queue`, `set_jaml_filter` and friends directly. +* **Seed lists:** paste hundreds or thousands of seeds (one per line, comma/space separated, or a CSV with seeds in the first column) into the *Seed list* box in the sidebar. `ArrowLeft` / `ArrowRight` step through them, `Home` / `End` jump to the first/last; neighbours are pre-analyzed so switching is instant. + ## pifreak's roadmap: * Continue testing motely-wasm * Continue unlocking performance using SharedArrayBuffer properly? diff --git a/___tests___/webmcp.spec.ts b/___tests___/webmcp.spec.ts new file mode 100644 index 0000000..b585ce0 --- /dev/null +++ b/___tests___/webmcp.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { analyzeSeed } from "../src/modules/GameEngine"; +import { describeAnte, findItems, summarizeSeed } from "../src/modules/webmcp/summary"; +import { parseSeedList } from "../src/modules/state/store"; + +const options = { buys: {}, sells: {}, showCardSpoilers: false, unlocks: [], events: [], maxMiscCardSource: 15 }; + +function analyze(seed: string, antes = 3) { + const results = analyzeSeed({ seed, deck: "Ghost Deck", stake: "White Stake", gameVersion: "10106", antes, cardsPerAnte: 20 }, options); + if (!results) throw new Error("analysis failed"); + return results; +} + +describe("seed summaries", () => { + it("summarizes every generated ante in order", () => { + const summary = summarizeSeed("WEEJOKER", analyze("WEEJOKER", 4), 5); + expect(summary.antes.map((a) => a.ante)).toEqual([0, 1, 2, 3, 4]); + for (const ante of summary.antes) { + expect(ante.shop.length).toBeLessThanOrEqual(5); + expect(ante.shopSize).toBeGreaterThan(0); + expect(typeof ante.boss).toBe("string"); + expect(ante.packs.length).toBeGreaterThan(0); + } + }); + + it("finds items where the ante details say they are", () => { + const results = analyze("WEEJOKER"); + const ante1 = describeAnte(results.antes[1]); + const first = ante1.shop[0]; + const bare = first.item.replace(/ \(.*\)$/, ""); + const hits = findItems(results, [bare])[bare]; + expect(hits.some((h) => h.ante === 1 && h.source === "shop" && h.index === 0)).toBe(true); + }); + + it("returns empty matches for unknown names", () => { + expect(findItems(analyze("WEEJOKER"), ["Definitely Not A Card"])).toEqual({ "Definitely Not A Card": [] }); + }); +}); + +describe("parseSeedList", () => { + it("accepts newline, comma and whitespace separated seeds and dedupes", () => { + expect(parseSeedList("aleeb\nPIROCKS weejoker\naleeb")).toEqual(["ALEEB", "PIROCKS", "WEEJOKER"]); + }); + + it("takes the first column of CSV rows, skips numeric tally columns and a seed header", () => { + expect(parseSeedList("seed,score\nFAJK8SAR, 11, 1, 0\n16661,2")).toEqual(["FAJK8SAR", "16661"]); + }); + + it("keeps every seed of a comma-separated list", () => { + expect(parseSeedList("ALEEB,PIROCKS, WEEJOKER;16661\tABC1")).toEqual(["ALEEB", "PIROCKS", "WEEJOKER", "ABC1"]); + }); + + it("includes ante 0 in item searches", () => { + const results = analyze("WEEJOKER", 2); + const name = describeAnte(results.antes[0]).shop[0].item.replace(/ \(.*\)$/, ""); + expect(findItems(results, [name])[name].some((h) => h.ante === 0)).toBe(true); + }); + + it("sanitizes zeros and junk", () => { + expect(parseSeedList("a0b-c!d 123456789ABC")).toEqual(["AOBCD", "12345678"]); + }); +}); diff --git a/package-lock.json b/package-lock.json index caf3f67..ac0dcfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "blueprint", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.125.0", "@mantine/carousel": "8.3.14", "@mantine/code-highlight": "8.3.14", "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "@mantine/spotlight": "8.3.14", + "@mcp-b/webmcp-polyfill": "^5.1.0", "@tabler/icons-react": "^3.41.1", "@tanstack/react-query": "^5.85.9", "embla-carousel-react": "^8.6.0", @@ -62,6 +64,27 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.125.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.125.0.tgz", + "integrity": "sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -1111,6 +1134,56 @@ "react": "^18.x || ^19.x" } }, + "node_modules/@mcp-b/webmcp-polyfill": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@mcp-b/webmcp-polyfill/-/webmcp-polyfill-5.1.0.tgz", + "integrity": "sha512-KL09hniFssPY8HBO3qzurWPqm+1JYRwV3wx6x2XSySgo+6BL1XHGsHGIoAO7U9zzigv7TA5keNBoZ+3P1jq18g==", + "license": "MIT", + "dependencies": { + "@mcp-b/webmcp-types": "5.1.0", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@mcp-b/webmcp-types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@mcp-b/webmcp-types/-/webmcp-types-5.1.0.tgz", + "integrity": "sha512-twOKDUr7al60K890HvRcNvoHSfQ2rnGeKJiQs6eUr+Leg+LQ6K+yz3F4K70jykMK1a09ZIgYb3Oc7mAypuKPig==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/server": "2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1760,6 +1833,18 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.6.1", "dev": true, @@ -3858,6 +3943,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fdir": { "version": "6.5.0", "license": "MIT", @@ -4733,6 +4824,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, @@ -6227,6 +6331,16 @@ "version": "0.0.2", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.9.0", "license": "MIT" @@ -6477,6 +6591,12 @@ "node": ">=14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "dev": true, diff --git a/package.json b/package.json index e10df94..5095d3b 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "lint": "eslint src" }, "dependencies": { + "@anthropic-ai/sdk": "^0.125.0", "@mantine/carousel": "8.3.14", "@mantine/code-highlight": "8.3.14", "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "@mantine/spotlight": "8.3.14", + "@mcp-b/webmcp-polyfill": "^5.1.0", "@tabler/icons-react": "^3.41.1", "@tanstack/react-query": "^5.85.9", "embla-carousel-react": "^8.6.0", diff --git a/src/App.tsx b/src/App.tsx index e833f58..da19e3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,11 @@ import { SeedOptionsProvider } from "./modules/state/optionsProvider.tsx"; import { DownloadSeedResultProvider } from "./modules/state/downloadProvider.tsx"; import { BlueprintThemeProvider, useBlueprintTheme } from "./modules/state/themeProvider.tsx"; import { JamlSearchProvider } from "./modules/state/jamlSearchContext.tsx"; +import AssistantDrawer from "./components/AssistantDrawer.tsx"; +import { SeedQueueHotkeys } from "./components/SeedQueue.tsx"; +import { startWebMcp } from "./modules/webmcp/register.ts"; + +startWebMcp(); const queryClient = new QueryClient() @@ -42,6 +47,8 @@ export default function App() { + + diff --git a/src/components/AssistantDrawer.tsx b/src/components/AssistantDrawer.tsx new file mode 100644 index 0000000..6b327b6 --- /dev/null +++ b/src/components/AssistantDrawer.tsx @@ -0,0 +1,371 @@ +import { useEffect, useRef, useState } from "react"; +import Anthropic from "@anthropic-ai/sdk"; +import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema"; +import { + ActionIcon, + Badge, + Button, + Code, + Drawer, + Group, + Paper, + PasswordInput, + ScrollArea, + Select, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { IconPlayerStop, IconSend, IconSettings, IconTrash } from "@tabler/icons-react"; +import { useCardStore } from "../modules/state/store.ts"; +import { listTools } from "../modules/webmcp/registry.ts"; +import { listMemory } from "../modules/webmcp/memory.ts"; +import { BALATRO_KNOWLEDGE } from "../modules/webmcp/knowledge.ts"; +import type { BlueprintTool } from "../modules/webmcp/registry.ts"; + +const KEY_STORAGE = "blueprint-anthropic-key"; +const MODEL_STORAGE = "blueprint-assistant-model"; +const HISTORY_STORAGE = "blueprint-assistant-history"; +const MODELS = [ + { value: "claude-opus-5", label: "Claude Opus 5" }, + { value: "claude-sonnet-5", label: "Claude Sonnet 5" }, + { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, +]; +const MAX_HISTORY_MESSAGES = 60; + +const SYSTEM_PROMPT = `You are the Blueprint assistant, embedded in Blueprint: a Balatro seed analyzer and router that runs in the user's browser. +You have tools that read and drive the app: analyze seeds, read per-ante shop queues, packs, tags, vouchers and bosses, search a seed for items, navigate the view, mark purchases, load seed lists, and remember notes about the user. +Use the tools instead of guessing: any question about a specific seed must be answered from get_seed_overview / get_ante_details / find_items. Call get_app_state first when you do not know what is loaded. +When the user shares what they like (decks, strategies, jokers, seeds they hunt), call remember so future sessions are personalized. +Answer like a sharp Balatro player talking to another one: concrete antes, shop indices, pack names. Short paragraphs, no filler.`; + +interface Turn { + role: "user" | "assistant"; + text: string; + tools: Array<{ name: string; ok: boolean }>; +} + +type History = Array; + +function readStorage(key: string): string { + try { + return localStorage.getItem(key) ?? ""; + } catch { + return ""; + } +} + +function writeStorage(key: string, value: string) { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + return; + } +} + +function loadHistory(): History { + try { + const parsed = JSON.parse(readStorage(HISTORY_STORAGE) || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function turnsFromHistory(history: History): Array { + const turns: Array = []; + for (const message of history) { + if (message.role === "system") continue; + if (typeof message.content === "string") { + turns.push({ role: message.role, text: message.content, tools: [] }); + continue; + } + const text = message.content + .filter((block): block is Anthropic.Beta.BetaTextBlockParam => block.type === "text") + .map((block) => block.text) + .join(""); + const toolUses = message.content + .filter((block): block is Anthropic.Beta.BetaToolUseBlockParam => block.type === "tool_use") + .map((block) => ({ name: block.name, ok: true })); + if (message.role === "assistant") { + const last = turns[turns.length - 1]; + if (last && last.role === "assistant") { + last.text += text; + last.tools.push(...toolUses); + } else { + turns.push({ role: "assistant", text, tools: toolUses }); + } + } else if (text) { + turns.push({ role: "user", text, tools: [] }); + } + } + return turns; +} + +function runnableTools(tools: Array, onCall: (name: string, ok: boolean) => void) { + return tools.map((tool) => + betaTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as Parameters[0]["inputSchema"], + run: async (args) => { + try { + const result = await tool.execute((args ?? {}) as Record); + onCall(tool.name, true); + return JSON.stringify(result); + } catch (error) { + onCall(tool.name, false); + throw error; + } + }, + }), + ); +} + +function contextBlock(): string { + const s = useCardStore.getState(); + const notes = listMemory(); + const lines = [ + `Loaded seed: ${s.engineState.seed || "(none)"} on ${s.engineState.deck}, ${s.engineState.stake}, antes 1-${s.engineState.maxAnte}, view ${s.applicationState.viewMode}.`, + `Seed queue: ${s.seedQueue.seeds.length} seeds, position ${s.seedQueue.index + 1}.`, + ]; + if (notes.length > 0) lines.push(`Notes about this user:\n- ${notes.join("\n- ")}`); + return lines.join("\n"); +} + +export default function AssistantDrawer() { + const opened = useCardStore(state => state.applicationState.assistantOpen); + const toggleAssistant = useCardStore(state => state.toggleAssistant); + + const [apiKey, setApiKey] = useState(() => readStorage(KEY_STORAGE)); + const [model, setModel] = useState(() => readStorage(MODEL_STORAGE) || MODELS[0].value); + const [showSettings, setShowSettings] = useState(() => !readStorage(KEY_STORAGE)); + const [history, setHistory] = useState(loadHistory); + const [turns, setTurns] = useState>(() => turnsFromHistory(loadHistory())); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const viewportRef = useRef(null); + + useEffect(() => { + viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight }); + }, [turns]); + + const persist = (next: History) => { + const trimmed = next.length > MAX_HISTORY_MESSAGES ? next.slice(next.length - MAX_HISTORY_MESSAGES) : next; + setHistory(trimmed); + writeStorage(HISTORY_STORAGE, JSON.stringify(trimmed)); + }; + + const clear = () => { + persist([]); + setTurns([]); + setError(null); + }; + + const stop = () => abortRef.current?.abort(); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + if (!apiKey) { + setShowSettings(true); + setError("Add your Anthropic API key first."); + return; + } + setDraft(""); + setError(null); + setBusy(true); + const userMessage: Anthropic.Beta.BetaMessageParam = { role: "user", content: text }; + const startingHistory: History = [...history, userMessage]; + setTurns((prev) => [...prev, { role: "user", text, tools: [] }, { role: "assistant", text: "", tools: [] }]); + + const patchAssistant = (patch: (turn: Turn) => Turn) => + setTurns((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant") next[next.length - 1] = patch(last); + return next; + }); + + const dropEmptyAssistant = () => + setTurns((prev) => { + const last = prev[prev.length - 1]; + return last?.role === "assistant" && !last.text && last.tools.length === 0 ? prev.slice(0, -1) : prev; + }); + + const controller = new AbortController(); + abortRef.current = controller; + const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }); + const runner = client.beta.messages.toolRunner( + { + model, + max_tokens: 16000, + stream: true, + max_iterations: 16, + betas: ["server-side-fallback-2026-07-01"], + fallbacks: "default", + output_config: { effort: "medium" }, + system: [ + { type: "text", text: `${SYSTEM_PROMPT}\n\n${BALATRO_KNOWLEDGE}`, cache_control: { type: "ephemeral" } }, + { type: "text", text: contextBlock() }, + ], + tools: runnableTools(listTools(), (name, ok) => + patchAssistant((turn) => ({ ...turn, tools: [...turn.tools, { name, ok }] })), + ), + messages: startingHistory, + }, + { signal: controller.signal }, + ); + + try { + for await (const stream of runner) { + stream.on("text", (delta) => patchAssistant((turn) => ({ ...turn, text: turn.text + delta }))); + const message = await stream.finalMessage(); + if (message.stop_reason === "pause_turn") { + runner.pushMessages({ role: "assistant", content: message.content }); + } + if (message.stop_reason === "refusal") { + setError(message.stop_details?.explanation ?? "The model declined this request."); + } + } + persist(runner.params.messages as History); + } catch (err) { + if (controller.signal.aborted) { + persist(runner.params.messages as History); + } else if (err instanceof Anthropic.AuthenticationError) { + setError("Anthropic rejected the API key."); + setShowSettings(true); + } else if (err instanceof Anthropic.RateLimitError) { + setError("Rate limited by Anthropic. Try again in a moment."); + } else if (err instanceof Anthropic.APIError) { + setError(`Anthropic API error ${err.status}: ${err.message}`); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + dropEmptyAssistant(); + abortRef.current = null; + setBusy(false); + } + }; + + return ( + + Blueprint assistant + {listTools().length} tools + + } + styles={{ body: { display: "flex", flexDirection: "column", height: "calc(100% - 60px)", gap: 8 } }} + > + + + + setShowSettings((v) => !v)} aria-label="Assistant settings"> + + + + + + + + + + Tools also exposed via WebMCP (document.modelContext) + + + {showSettings && ( + + + { + setApiKey(e.currentTarget.value); + writeStorage(KEY_STORAGE, e.currentTarget.value.trim()); + }} + /> +