From 27a03e88fefbd3c0da1b54030842633564d44bc8 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 5 May 2026 20:56:21 -0600 Subject: [PATCH] feat(adapters): cherry-studio chat-client adapter (closes #481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry Studio is a desktop Electron chat client. It's the first non-CLI client to be onboarded — every existing adapter (OpenCode, Crush, Pi, ForgeCode, Droid, Claude Code) is for a coding agent with its own MCP tool runtime, but Cherry Studio is a pure chat UI that needs Claude's server-side tools (WebSearch / WebFetch) to work natively. Without an adapter, Cherry Studio falls through to OpenCode (the default), which blocks WebSearch in favor of OpenCode's MCP equivalent. The user sees "WebSearch tool not exposed in the session" and web search is silently broken. The new cherry-studio adapter: - Allows WebSearch / WebFetch (verified independently against Max OAuth: bundled claude binary + WebSearch returns real results). - Blocks filesystem / shell tools by default. A chat-style LLM shouldn't enumerate files on the proxy host unsupervised, even on localhost. Operators who genuinely want filesystem access can use a coding-agent adapter where tool calls are surveilled. - Blocks Claude-Code-only orchestration tools (cron, plan/worktree mode, etc.) — no chat-client equivalent. - usesPassthrough = false. The SDK runs tools internally and folds results into the assistant turn — exactly what a chat UI renders. - supportsThinking = true. Cherry Studio renders thinking blocks when the user enables them in its UI. - No subagent routing, no MCP server, no file-change tracking. Detection: Cherry Studio doesn't send a stable User-Agent (CherryHQ#10209 documents UA being overridden). Routes via: - x-meridian-agent: cherry-studio (per request — the documented path) - MERIDIAN_DEFAULT_AGENT=cherry-studio (global default) Bonus fix: single source of truth for the adapter list. The settings UI hardcoded ["opencode", "crush", "forgecode", "pi", "droid", "passthrough"] in two places — both drifted from ADAPTER_MAP. claude-code was registered for detection but never rendered in the settings UI, so its toggles were silently inaccessible. ADAPTER_LABELS / ADAPTER_NAMES exports in detect.ts become the single source; sdkFeatures.ts and settingsPage.ts consume them. Adding cherry-studio + claude-code to the UI is automatic. Audit test guards against future drift. Tests: 21 new in cherry-studio-adapter.test.ts covering identity, session/CWD, the load-bearing WebSearch/WebFetch unblocking, filesystem blocking, chat-client behavior toggles, x-meridian-agent header detection (incl. the cherrystudio alias and case-insensitivity), and the adapter-list audit. End-to-end verified by booting a test proxy on :3500 and POSTing /v1/messages with `x-meridian-agent: cherry-studio` and a "WebSearch the latest node.js version" prompt. Result: SDK invoked WebSearch, returned real search result with citation, thinking blocks forwarded. Full suite 1742/0. Build clean. Typecheck clean. Note: tested without Cherry Studio itself in the loop — the adapter correctness, detection, and SDK round-trip are verified server-side, but Cherry Studio's specific UI rendering of citations/streaming needs end-user verification. @BenIsLegit can validate before merge. --- README.md | 24 +++ src/__tests__/cherry-studio-adapter.test.ts | 171 ++++++++++++++++++++ src/proxy/adapters/cherrystudio.ts | 133 +++++++++++++++ src/proxy/adapters/detect.ts | 35 +++- src/proxy/sdkFeatures.ts | 11 +- src/telemetry/settingsPage.ts | 16 +- 6 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/cherry-studio-adapter.test.ts create mode 100644 src/proxy/adapters/cherrystudio.ts diff --git a/README.md b/README.md index 0a6b9249..a2392d40 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,30 @@ Requests flow through the Claude Code adapter which: - Leaves the SDK subprocess cwd on the proxy host (Claude Code's local paths don't exist there). - Runs in passthrough mode by default — Claude Code executes its own tools on the machine it runs on; Meridian just forwards tool_use blocks. +### Cherry Studio + +[Cherry Studio](https://github.com/CherryHQ/cherry-studio) is a desktop chat client (Electron) that talks to any Anthropic-compatible endpoint. It's the first **chat-client** adapter in Meridian — the existing adapters (OpenCode, Crush, Pi, ForgeCode, Claude Code, Droid) are all CLI coding agents with their own MCP tool runtimes, but Cherry Studio is a pure chat UI that wants Claude's server-side tools (especially `WebSearch` and `WebFetch`) to work natively. + +In Cherry Studio's provider settings, add a new Anthropic-compatible provider: + +- **API URL:** `http://127.0.0.1:3456` +- **API Key:** any string when `MERIDIAN_API_KEY` is unset, or your key value otherwise +- **Custom request headers:** `x-meridian-agent: cherry-studio` + +Cherry Studio doesn't send a stable User-Agent ([CherryHQ#10209](https://github.com/CherryHQ/cherry-studio/issues/10209)) so the header is required for the adapter to fire — without it, requests fall through to whatever `MERIDIAN_DEFAULT_AGENT` is (default OpenCode), which blocks `WebSearch`/`WebFetch` in favor of OpenCode's MCP equivalents and breaks Cherry Studio's web search. + +If Cherry Studio is the *only* tool pointing at this Meridian instance, you can skip the header and set the env var instead: + +```bash +MERIDIAN_DEFAULT_AGENT=cherry-studio meridian +``` + +The Cherry Studio adapter: +- **Allows** `WebSearch` and `WebFetch` (the whole point — chat clients have no MCP equivalent and need Claude's built-in web access). +- **Blocks** filesystem and shell tools by default (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`). A chat-style LLM shouldn't enumerate files on the proxy host unsupervised, even on localhost. If you want broader access, point at a coding-agent adapter where tool calls are surveilled by the calling tool. +- Runs the SDK's tools internally (no passthrough) so results land inline in the assistant turn — exactly what a chat UI wants to render. +- Supports `thinking` / `thinkingPassthrough` toggles via the settings UI at `/settings`, same as the coding-agent adapters. + ### Any Anthropic-compatible tool ```bash diff --git a/src/__tests__/cherry-studio-adapter.test.ts b/src/__tests__/cherry-studio-adapter.test.ts new file mode 100644 index 00000000..5e4b968a --- /dev/null +++ b/src/__tests__/cherry-studio-adapter.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for the Cherry Studio chat-client adapter. + * + * The load-bearing assertion below — that WebSearch / WebFetch are NOT in + * the adapter's blocked / incompatible lists — is the actual fix for the + * symptom Ben reported in #481 ("WebSearch tool not exposed in the + * session"). When this assertion regresses, Cherry Studio's web search + * silently breaks again. + */ +import { describe, it, expect } from "bun:test" +import { cherryStudioAdapter } from "../proxy/adapters/cherrystudio" + +describe("cherryStudioAdapter — identity", () => { + it("has name 'cherry-studio'", () => { + expect(cherryStudioAdapter.name).toBe("cherry-studio") + }) +}) + +describe("cherryStudioAdapter.getSessionId", () => { + it("always returns undefined — Cherry Studio has no session-affinity header", () => { + const ctx = { req: { header: () => "anything" } } + expect(cherryStudioAdapter.getSessionId(ctx as any)).toBeUndefined() + }) +}) + +describe("cherryStudioAdapter.extractWorkingDirectory", () => { + it("returns undefined for any body — chat client has no CWD concept", () => { + expect(cherryStudioAdapter.extractWorkingDirectory({})).toBeUndefined() + expect(cherryStudioAdapter.extractWorkingDirectory({ system: "anything" })).toBeUndefined() + }) +}) + +describe("cherryStudioAdapter — tool blocking (regression for #481)", () => { + // The whole point of this adapter: chat clients have no MCP equivalent for + // WebSearch / WebFetch and no client-side web access. If we block them, the + // user sees "tool not exposed" and the fix is silently undone. + const blocked = new Set([ + ...cherryStudioAdapter.getBlockedBuiltinTools(), + ...cherryStudioAdapter.getAgentIncompatibleTools(), + ]) + + it("does NOT block WebSearch", () => { + expect(blocked.has("WebSearch")).toBe(false) + }) + + it("does NOT block WebFetch", () => { + expect(blocked.has("WebFetch")).toBe(false) + }) + + it("blocks filesystem tools (Read/Write/Edit/Bash) — chat clients shouldn't poke the proxy host", () => { + expect(blocked.has("Read")).toBe(true) + expect(blocked.has("Write")).toBe(true) + expect(blocked.has("Edit")).toBe(true) + expect(blocked.has("Bash")).toBe(true) + expect(blocked.has("Glob")).toBe(true) + expect(blocked.has("Grep")).toBe(true) + }) + + it("blocks Claude-Code-only orchestration tools", () => { + for (const name of ["CronCreate", "EnterPlanMode", "EnterWorktree", "Skill", "Agent"]) { + expect(blocked.has(name)).toBe(true) + } + }) +}) + +describe("cherryStudioAdapter — chat-client behavior", () => { + it("usesPassthrough returns false — SDK runs tools, returns results inline", () => { + expect(cherryStudioAdapter.usesPassthrough?.()).toBe(false) + }) + + it("supportsThinking returns true — Cherry Studio renders thinking when enabled", () => { + expect(cherryStudioAdapter.supportsThinking?.()).toBe(true) + }) + + it("shouldTrackFileChanges returns false — chat clients don't render diff blocks", () => { + expect(cherryStudioAdapter.shouldTrackFileChanges?.()).toBe(false) + }) + + it("buildSdkAgents returns empty — no subagent routing", () => { + expect(cherryStudioAdapter.buildSdkAgents?.({}, [])).toEqual({}) + }) + + it("getAllowedMcpTools returns empty — no MCP server-side tools", () => { + expect(cherryStudioAdapter.getAllowedMcpTools()).toEqual([]) + }) + + it("buildSdkHooks returns undefined — no PreToolUse hook needed", () => { + expect(cherryStudioAdapter.buildSdkHooks?.({}, {})).toBeUndefined() + }) + + it("buildSystemContextAddendum returns empty string", () => { + expect(cherryStudioAdapter.buildSystemContextAddendum?.({}, {})).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// Detection — Cherry Studio has no stable User-Agent (CherryHQ#10209), so +// it must be selected via header or env var. These tests pin that contract. +// --------------------------------------------------------------------------- +describe("Cherry Studio detection via x-meridian-agent header", () => { + it("x-meridian-agent: cherry-studio routes to cherryStudioAdapter", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "cherry-studio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) + + it("x-meridian-agent: cherrystudio (no hyphen) also routes — alias", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "cherrystudio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) + + it("ignores case in x-meridian-agent value", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "Cherry-Studio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) +}) + +// --------------------------------------------------------------------------- +// Audit: every adapter registered for detection must also have a UI label. +// Without this, the next adapter we register for detection-only (like +// claude-code originally was) ends up invisible in the settings page — +// users can't see or change its feature toggles. This is the symptom Ben +// described as "It'd be nice to customize things like Client Prompt, +// Thinking Passthrough, Thinking like other harnesses." +// --------------------------------------------------------------------------- +describe("adapter list is single-sourced (regression guard)", () => { + it("every canonical adapter in ADAPTER_LABELS is reachable via ADAPTER_MAP", async () => { + const { ADAPTER_MAP, ADAPTER_LABELS } = await import("../proxy/adapters/detect") + for (const name of Object.keys(ADAPTER_LABELS)) { + expect(ADAPTER_MAP[name]).toBeDefined() + expect(ADAPTER_MAP[name]?.name).toBeDefined() + } + }) + + it("getAllFeatureConfigs returns one entry per ADAPTER_LABELS key", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + const cfg = getAllFeatureConfigs() + for (const name of Object.keys(ADAPTER_LABELS)) { + expect(cfg[name]).toBeDefined() + } + }) + + it("includes cherry-studio specifically (the new entry)", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + expect(ADAPTER_LABELS["cherry-studio"]).toBe("Cherry Studio") + expect(getAllFeatureConfigs()["cherry-studio"]).toBeDefined() + }) + + it("includes claude-code (latent gap fixed by this change)", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + expect(ADAPTER_LABELS["claude-code"]).toBe("Claude Code") + expect(getAllFeatureConfigs()["claude-code"]).toBeDefined() + }) +}) diff --git a/src/proxy/adapters/cherrystudio.ts b/src/proxy/adapters/cherrystudio.ts new file mode 100644 index 00000000..9f52e8d1 --- /dev/null +++ b/src/proxy/adapters/cherrystudio.ts @@ -0,0 +1,133 @@ +/** + * Cherry Studio chat-client adapter. + * + * Cherry Studio (CherryHQ/cherry-studio) is a desktop Electron chat client + * that talks to Anthropic-compatible APIs. Unlike Meridian's coding-agent + * adapters (OpenCode, Crush, ForgeCode, etc.), it has no local tool runtime + * and no MCP integration — it's a pure chat UI that wants Claude to use + * server-side tools natively, especially web search. + * + * Key differences from the coding-agent adapters: + * - WebSearch and WebFetch NOT blocked. Chat clients have no MCP equivalent + * and no client-side web access — Claude's built-ins are the whole point + * of using Meridian + Max OAuth here. Verified independently that Max + * OAuth runs WebSearch successfully when allowed. + * - Filesystem / shell tools blocked by default. Chat clients shouldn't + * read the proxy host's filesystem unsupervised — even on localhost, an + * unsuspecting user could let an LLM enumerate `~/.ssh` etc. Operators + * who genuinely want filesystem access can use a coding-agent adapter + * (where the agent supervises tool calls) or set MERIDIAN_DEFAULT_AGENT + * to one with broader permissions. + * - usesPassthrough = false. Cherry Studio has no tool-execution loop; + * the SDK executes tools internally and returns results inline. + * - No MCP server, no subagent routing. + * + * Detection: Cherry Studio doesn't send a stable User-Agent — see upstream + * issue CherryHQ/cherry-studio#10209 (custom UA gets overridden). Use: + * - `x-meridian-agent: cherry-studio` header (per request) + * - `MERIDIAN_DEFAULT_AGENT=cherry-studio` env var (global default) + * + * Closes #481. + */ + +import type { Context } from "hono" +import type { AgentAdapter } from "../adapter" +import { normalizeContent } from "../messages" + +const CHERRY_STUDIO_NAME = "cherry-studio" + +/** + * Tools the SDK should refuse to invoke for Cherry Studio. + * + * Two categories: + * 1. Filesystem / shell — kept off by default so an unsupervised chat-style + * LLM can't enumerate files on the proxy host. + * 2. Claude-Code-only orchestration tools (cron, plan/worktree mode + * toggles, etc.) that have no useful meaning outside the Claude Code + * CLI runtime. + * + * Web tools are deliberately absent. So is `TodoWrite` — chat clients are + * fine with the SDK's built-in todo view since they have no equivalent. + */ +const CHERRY_STUDIO_BLOCKED: readonly string[] = [ + "Read", "Write", "Edit", "MultiEdit", + "Bash", "Glob", "Grep", "NotebookEdit", + "CronCreate", "CronDelete", "CronList", + "EnterPlanMode", "ExitPlanMode", + "EnterWorktree", "ExitWorktree", + "Monitor", "PushNotification", "RemoteTrigger", "ScheduleWakeup", + "Skill", "Agent", "TaskOutput", "TaskStop", + "AskUserQuestion", +] + +export const cherryStudioAdapter: AgentAdapter = { + name: CHERRY_STUDIO_NAME, + + /** No session-affinity header from Cherry Studio — fingerprint-based resume. */ + getSessionId(_c: Context): string | undefined { + return undefined + }, + + /** Chat client runs on the same host as the proxy in the typical setup. */ + extractWorkingDirectory(_body: any): string | undefined { + return undefined + }, + + normalizeContent(content: any): string { + return normalizeContent(content) + }, + + /** + * No SDK built-in tools to block beyond the chat-client list. Returning [] + * here and putting the full list in getAgentIncompatibleTools() keeps the + * blocking story in one place — both lists land in the SDK's + * `--disallowedTools` arg either way. + */ + getBlockedBuiltinTools(): readonly string[] { + return [] + }, + + getAgentIncompatibleTools(): readonly string[] { + return CHERRY_STUDIO_BLOCKED + }, + + /** No MCP integration — chat client speaks plain Anthropic Messages API. */ + getMcpServerName(): string { + return CHERRY_STUDIO_NAME + }, + + getAllowedMcpTools(): readonly string[] { + return [] + }, + + buildSdkAgents(_body: any, _mcpToolNames: readonly string[]): Record { + return {} + }, + + buildSdkHooks(_body: any, _sdkAgents: Record): undefined { + return undefined + }, + + buildSystemContextAddendum(_body: any, _sdkAgents: Record): string { + return "" + }, + + /** + * Chat client has no client-side tool loop. The SDK runs tools and folds + * results into the assistant turn for us — that's exactly what a chat UI + * wants to render. + */ + usesPassthrough(): boolean { + return false + }, + + /** Cherry Studio renders thinking blocks when the user enables them. */ + supportsThinking(): boolean { + return true + }, + + /** No filesystem-edit summary; chat clients neither edit files nor render that block. */ + shouldTrackFileChanges(): boolean { + return false + }, +} diff --git a/src/proxy/adapters/detect.ts b/src/proxy/adapters/detect.ts index 3ad77209..71c6cf3a 100644 --- a/src/proxy/adapters/detect.ts +++ b/src/proxy/adapters/detect.ts @@ -14,8 +14,9 @@ import { passthroughAdapter } from "./passthrough" import { piAdapter } from "./pi" import { forgeCodeAdapter } from "./forgecode" import { claudeCodeAdapter } from "./claudecode" +import { cherryStudioAdapter } from "./cherrystudio" -const ADAPTER_MAP: Record = { +export const ADAPTER_MAP: Record = { opencode: openCodeAdapter, droid: droidAdapter, crush: crushAdapter, @@ -24,8 +25,34 @@ const ADAPTER_MAP: Record = { forgecode: forgeCodeAdapter, "claude-code": claudeCodeAdapter, claudecode: claudeCodeAdapter, + "cherry-studio": cherryStudioAdapter, + cherrystudio: cherryStudioAdapter, } +/** + * Canonical adapter names with their human-readable labels. The settings UI + * and per-adapter config code consume this so adding an adapter to + * ADAPTER_MAP automatically wires it everywhere — no hardcoded lists in + * sdkFeatures.ts or settingsPage.ts to drift out of sync. + * + * Aliases (e.g. `claudecode` → `claude-code`, `cherrystudio` → `cherry-studio`) + * exist in ADAPTER_MAP for detection convenience but are intentionally absent + * here: each adapter has exactly one canonical name in the UI. + */ +export const ADAPTER_LABELS: Record = { + opencode: "OpenCode", + crush: "Crush", + forgecode: "ForgeCode", + pi: "Pi", + droid: "Droid", + passthrough: "LiteLLM / Passthrough", + "claude-code": "Claude Code", + "cherry-studio": "Cherry Studio", +} + +/** Canonical adapter names — keys of ADAPTER_LABELS, in stable UI order. */ +export const ADAPTER_NAMES: readonly string[] = Object.keys(ADAPTER_LABELS) + const envDefault = process.env.MERIDIAN_DEFAULT_AGENT || "" if (envDefault && !ADAPTER_MAP[envDefault]) { console.warn( @@ -53,6 +80,7 @@ function isLiteLLMRequest(c: Context): boolean { * * Detection rules (evaluated in order): * 1. x-meridian-agent header → explicit adapter override + * e.g. "cherry-studio", "claude-code", "opencode", etc. * 2. x-opencode-session or x-session-affinity header → OpenCode adapter * 3. User-Agent starts with "opencode/" → OpenCode adapter * 4. User-Agent starts with "factory-cli/" → Droid adapter @@ -60,6 +88,11 @@ function isLiteLLMRequest(c: Context): boolean { * 6. User-Agent starts with "claude-cli/" → Claude Code adapter * 7. litellm/* UA or x-litellm-* headers → LiteLLM passthrough adapter * 8. Default → MERIDIAN_DEFAULT_AGENT env var, or OpenCode + * + * Cherry Studio (and other chat clients with no stable User-Agent) must use + * the explicit `x-meridian-agent: cherry-studio` header or set + * MERIDIAN_DEFAULT_AGENT — there is no auto-detection rule, by design. + * See cherrystudio.ts for the chat-client adapter shape. */ export function detectAdapter(c: Context): AgentAdapter { const agentOverride = c.req.header("x-meridian-agent")?.toLowerCase() diff --git a/src/proxy/sdkFeatures.ts b/src/proxy/sdkFeatures.ts index a1250ec3..5fac09d9 100644 --- a/src/proxy/sdkFeatures.ts +++ b/src/proxy/sdkFeatures.ts @@ -130,12 +130,17 @@ export function getFeaturesForAdapter(adapterName: string): AdapterFeatures { } /** - * Get the full config for all adapters (for the settings UI). + * Get the full config for all adapters (for the settings UI). Sources its + * adapter list from `ADAPTER_NAMES` so adding an entry in + * `adapters/detect.ts` automatically surfaces here. Previously a hardcoded + * list that drifted — `claude-code` was added to `ADAPTER_MAP` but never + * here, so its feature toggles had no UI representation. */ export function getAllFeatureConfigs(): Record { - const adapters = ["opencode", "crush", "forgecode", "pi", "droid", "passthrough"] + // Lazy require — avoids any chance of import cycle on the leaf module path. + const { ADAPTER_NAMES } = require("./adapters/detect") as typeof import("./adapters/detect") const result: Record = {} - for (const name of adapters) { + for (const name of ADAPTER_NAMES) { result[name] = getFeaturesForAdapter(name) } return result diff --git a/src/telemetry/settingsPage.ts b/src/telemetry/settingsPage.ts index 84cc54cd..c8803939 100644 --- a/src/telemetry/settingsPage.ts +++ b/src/telemetry/settingsPage.ts @@ -1,9 +1,16 @@ /** * SDK Features settings page — per-adapter toggle UI. * Same dark theme as the telemetry dashboard. No framework, no CDN. + * + * The list of adapters rendered here is interpolated from + * `proxy/adapters/detect.ADAPTER_LABELS` so adding an adapter to + * `ADAPTER_MAP` automatically surfaces its toggles in the UI. Previously + * this file held a hardcoded list that drifted (the `claude-code` adapter + * was registered for detection but never rendered its config here). */ import { profileBarCss, profileBarHtml, profileBarJs, themeCss } from "./profileBar" +import { ADAPTER_LABELS as ADAPTER_LABELS_SOURCE } from "../proxy/adapters/detect" export const settingsPageHtml = ` @@ -126,14 +133,7 @@ const FEATURES = [ { key: 'additionalDirectories', label: 'Additional Directories', desc: 'Comma-separated extra paths Claude can access (monorepo libs, etc.)', type: 'text' }, ]; -const ADAPTER_LABELS = { - opencode: 'OpenCode', - crush: 'Crush', - forgecode: 'ForgeCode', - pi: 'Pi', - droid: 'Droid', - passthrough: 'LiteLLM / Passthrough', -}; +const ADAPTER_LABELS = ${JSON.stringify(ADAPTER_LABELS_SOURCE)}; let currentConfig = {};