diff --git a/.gitignore b/.gitignore index ab0d04b3..89482895 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ web node_modules dist dist-electron +dist-electron-win .test-dist apps/desktop/dist-electron *.tsbuildinfo @@ -10,3 +11,8 @@ apps/desktop/dist-electron local/ .slim/deepwork/ docs +*.diff +MERGE_NOTES.md +NEW_FILES.txt +PULL_REQUEST.md +README_OUR_CHANGES.md diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 00000000..d504d67d --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,299 @@ +# OpenPets Feature Inventory + +This file is the current feature roll-up for this working fork of OpenPets. +It combines: + +- the shipped baseline described in `README.md` +- the chat, memory, and prompt-window work from this implementation thread +- the curated MCP toolkit surface added in the desktop app + +> Looking for an isolated view of only our additions? See: +> - [`FEATURES_OUR_CHANGES.md`](FEATURES_OUR_CHANGES.md) +> - [`README_OUR_CHANGES.md`](README_OUR_CHANGES.md) +> - [`PULL_REQUEST.md`](PULL_REQUEST.md) + +## Desktop Companion Core + +- Tray-first desktop companion app +- Desktop pet that can idle, react, wave, move, and speak +- Default pet plus installed non-default pets +- Per-agent pet routing when integrations request a specific installed pet +- Manual pet dismissal with lease-aware recovery rules +- Native right-click pet context menu +- Always-on-top pet window with click-through background behavior +- Drag support with mouse passthrough recovery for Windows/Linux edge cases +- Adaptive pet scale setting (XXS through XXXL, plus drag-to-resize handle on the pet) +- Reset default pet position +- Launch at login support where the platform allows it +- Update checking against GitHub releases +- Open logs folder from the app + +## Pet Packs And Gallery + +- Built-in default pet +- Installed pet pack loading +- Local pet import from ZIP +- Local pet import from folder +- Codex pet import flow +- Pet catalog browsing +- Pet preview thumbnails and spritesheets +- Default pet selection +- Pet gallery linkout + +## Reactions And Speech + +- Explicit pet speech bubbles +- Reaction-only decorative bubble messages +- Reaction animation mapping +- User-configurable reaction-to-animation overrides +- Longer display duration for longer messages +- Separate handling for finite reaction animations vs visible bubble lifetime +- Safer speech rules for agent-driven bubble content +- Bubble behavior that avoids showing code, logs, URLs, paths, or secrets in normal integration speech + +## Floating Chat Surface + +- Double-click the pet to open a floating prompt window +- Floating prompt window stays always on top +- Prompt window is compact and resizable +- Prompt window can collapse down to prompt-first use +- Prompt window keeps a low desktop footprint +- Minimal top-bar controls with small symbol buttons +- Editor panel toggle +- History toggle +- New chat action +- Settings shortcut +- Close action +- Copy chat support +- Styled conversation history list with per-entry role badges and timestamps +- Scrollable message entries with distinct user/assistant/system/error theming +- Enter sends +- Ctrl+Enter or Cmd+Enter inserts newline +- Inline error presentation inside the prompt surface +- No terminal required for normal chat usage + +## Chat History + +- Full conversation history stored and persisted to disk across app restarts +- History survives closing and reopening the floating chat window +- History stays in the prompt/editor surface rather than in the pet bubble +- Pet bubble only shows one assistant reply at a time +- Per-entry styling with role badges (You / Pet / System / Error) and timestamps +- **Multi-conversation support**: each conversation is a separate thread with its own messages +- **Conversation list** accessible from the History button in the floating chat window +- **Editor button** shows the current conversation's message list +- Create, switch, and delete conversations from the floating chat window +- Auto-generated conversation titles from the first user message +- Old conversations are preserved when starting a new chat + +## OpenAPI And BYOK Chat + +- Main-process OpenAPI chat service +- User-entered prompt stays out of the pet renderer +- User-entered prompt stays out of logs unless logging is genuinely necessary elsewhere +- API key or token entry in Settings +- Secure storage with Electron `safeStorage` when available +- Plain local fallback when platform encryption is unavailable +- Model selection in Settings +- OpenAPI-compatible endpoint override in Settings +- Built-in OpenAI endpoint default +- BYOK endpoint support for other compatible providers or local gateways +- Endpoint normalization and validation +- Localhost-only allowance for plain `http` +- Automatic `responses` plus `chat/completions` route selection for broader provider compatibility +- Preset-based provider setup +- Compact single credential entry surface that adapts to the selected provider preset + +## Current Provider Presets + +- OpenAI +- OpenRouter +- Azure template +- LiteLLM local +- vLLM local +- Custom local template +- Generic HTTPS template + +## Pet Character And Personality + +- System-prompt style pet character field in General Settings +- Character prompt persists in app state +- Character prompt is applied on future assistant replies +- Base instructions toggle — optionally include or exclude the default OpenPets behavior instructions in every chat +- Theme setting shared between control center and floating chat window +- Auto theme mode +- Light theme mode +- Dark theme mode +- Dark theme tuned toward graphite-style surfaces instead of blue-heavy dark UI + +## Memory System + +- Local-first persistent OpenPets memory store +- Main-process memory writer +- In-memory retrieval during chat +- On-disk persistence for durable memories +- Human-inspectable memory mirror files +- Relevance-based retrieval into future prompts +- Explicit memory capture patterns such as `remember ...` +- Preference, identity, fact, and note style memory kinds +- Importance weighting +- Tags +- Forget/delete support +- In-app memory viewer in Settings with search, edit, and delete +- **Chat history memory**: cross-conversation message search injects relevant past conversation excerpts into the system prompt +- Two separate databases: explicit memories (`openpets-memory.json`) and chat history (`openpets-chat-history.json`) + +## OpenPets MCP Server + +- Local IPC discovery file and per-run token flow +- MCP server for OpenPets desktop control +- Explicit target-pet lease routing +- Default-pet fallback when a requested pet is unavailable +- `openpets_status` +- `openpets_react` +- `openpets_say` +- `openpets_memory_list` +- `openpets_memory_search` +- `openpets_memory_store` +- `openpets_memory_forget` + +## Agent Integrations + +- Claude Code integration +- OpenCode integration +- Cursor integration +- Pi extension guidance +- Global setup preview/state for Claude +- Global setup preview/state for OpenCode +- Global setup preview/state for Cursor +- Managed Claude memory instructions +- Managed Claude hook install/uninstall +- OpenCode instruction file support +- OpenCode plugin support +- Cursor MCP config preview +- Cursor rules preview +- Pet routing per supported integration +- Published package / bundled / local command modes for supported integrations +- Command path overrides for relevant integrations + +## Vanilla Chat MCP Tools + +- Multi-select MCP tool activation inside the floating chat window +- Tiered tool browser (Starter, Terminal & Systems, Advanced) +- Save & Activate / Deactivate All controls +- Tools run through an internal stdio MCP client in the main process +- Supported tools: filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite + +## Curated MCP Toolkit Surface + +The control-center Integrations page now also includes a curated **MCP Toolkit** panel. This does not pretend to be a full unsafe one-click installer for the whole MCP ecosystem. Instead, it provides a practical, permission-aware reference surface with copyable snippets and docs links. + +### Starter Stack In The Toolkit + +- Filesystem +- Git +- GitHub +- Playwright +- Browser Use +- SQLite / PostgreSQL guidance +- Memory +- Fetch / Web +- Sequential Thinking +- Docker MCP Toolkit + +### Terminal And Systems Layer In The Toolkit + +- Shell / Terminal guidance +- Process & Logs guidance +- System Info guidance +- SSH guidance +- Package Manager guidance +- Tmux guidance + +### Later / Advanced Layer In The Toolkit + +- Kubernetes / Cloud guidance +- CI / CD guidance +- Ghidra guidance +- Binary Analysis guidance +- Network Analysis guidance + +### Toolkit UX + +- Toolkit card in Integrations +- Explicit `Manual Setup` vs `Persistent Full Access` choice +- Command source labels clarify where tools come from (Stable release, Shipped with app, Local build) +- Install choice descriptions clarify manual vs persistent access +- Tiered MCP family browser +- Per-tool rationale +- Per-tool VectorShell-fit notes +- Per-tool permission-boundary notes +- Copyable install/config snippets where they are stable enough to recommend +- External docs opening through Electron +- Persistent host install bundles for supported hosts +- `Install Now` actions for supported Claude Code and Codex CLI baselines +- Installed/skipped MCP result reporting inside the toolkit UI +- Current automatic baseline: Filesystem, Playwright, Memory, Context7 / Docs, Sequential Thinking, plus Fetch / Web and Browser Use when `uvx` is available + +## Plugins + +- Plugin catalog +- Installed plugin inspection +- Enable/disable plugins +- Local plugin loading +- Catalog plugin install/update/remove +- Plugin configuration schema rendering +- Plugin command execution +- Plugin status display + +## Dashboard And Control Center + +- Dashboard overview +- Pet count +- Plugin health summary +- Update status summary +- Reaction mix charting +- Top companion activity +- Activity counters +- Settings view +- Pets view +- Plugins view +- Integrations view +- Persistent route handling inside the control center window + +## Logging And Diagnostics + +- App log file +- Previous log rollover +- Redaction of token-like values +- Verbose dev logging toggle through environment +- IPC request logging +- Lease acquisition logging +- Pet show lifecycle logging +- Renderer console logging capture + +## Packaging And Distribution + +- Desktop packaging through Electron Builder +- Windows packaging support +- Linux AppImage packaging support +- Additional Linux targets in config +- Packaged preload scripts for control center, pet window, prompt window, and plugin surfaces +- Packaged official plugins as extra resources +- Packaging contract checks + +## Safety And Boundary Rules + +- Prompt text is kept out of the pet renderer +- Automatic agent speech is decorative and best effort +- OpenPets speech bubbles are not meant to carry code, logs, URLs, file paths, or secrets +- Tooling guidance in the MCP Toolkit emphasizes minimal permissions +- Docker MCP Toolkit is recommended as an isolation layer when the third-party MCP ecosystem feels too uneven + +## Practical Summary + +This fork now gives you three layers at once: + +- a playful desktop pet +- a floating always-on-top chat surface with BYOK and memory +- a curated MCP/workbench surface for building a serious developer stack around the pet instead of only driving it from a terminal diff --git a/FEATURES_OUR_CHANGES.md b/FEATURES_OUR_CHANGES.md new file mode 100644 index 00000000..70d9c961 --- /dev/null +++ b/FEATURES_OUR_CHANGES.md @@ -0,0 +1,105 @@ +# OpenPets — Our Changes (Isolated Feature List) + +This document lists only the features, UI surfaces, and capabilities added by our working fork. It is intentionally separate from the upstream `README.md` and `FEATURES.md` so the original repo maintainer can choose whether to adopt these changes wholesale, partially, or not at all. + +## Floating Chat Surface + +- Double-click the pet to open a frameless, always-on-top floating prompt window. +- Compact prompt-first UX that opens at a minimal 168px height and expands to 456px when the editor/history is shown. +- Resizable with a corner grip; size is remembered per mode (compact/expanded). +- Theme-aware (system / light / dark) shared with Control Center. +- Inline error presentation and keyboard shortcuts (Enter sends, Ctrl+Enter newline, Escape closes). + +## Chat History List UI + +- Conversation history renders as a styled list of individual entries, not a raw textarea. +- Per-entry role badges: You, Pet, System, Error. +- Color-coded entry backgrounds per role. +- Timestamps shown per entry. +- Scrollable with automatic scroll-to-bottom on new messages. +- Persists to disk across app restarts (`openpets-chat-history.json`). + +## OpenAPI BYOK Chat + +- Main-process OpenAPI-compatible chat service. +- Supports both `responses` and `chat/completions` transports with automatic route selection. +- Secure credential storage via Electron `safeStorage` with plain fallback. +- Model selection and endpoint override in Settings. +- Built-in presets: OpenAI, OpenRouter, Azure template, LiteLLM, vLLM, Moonshot/Kimi, custom/local templates. +- Localhost-only allowance for plain `http`. + +## Pet Character & Base Instructions + +- User-editable system-prompt style pet character field in General Settings. +- Character prompt persists in app state and applies to future assistant replies. +- Base instructions toggle to include/exclude the default OpenPets behavior instructions in every chat. + +## Memory System + +- Local-first durable OpenPets memory store. +- Automatic memory capture from prompts (`remember that...`, `/remember`, `/memorize`, `/note`, `don't forget...`, `my name is...`, `my favorite...`, `I prefer...`, `I like/love/dislike/hate...`). +- Specific memory kinds (identity, preference) take precedence over generic notes when the prompt matches both. +- Relevance-based retrieval injected into future chat system prompts. +- Memory kinds: preference, identity, fact, note. +- Importance weighting and tags. +- In-app memory viewer in Settings with search, inline edit, and delete with confirmation. +- Human-inspectable markdown mirror files on disk. +- **Chat history memory**: cross-conversation message search that injects relevant past conversation excerpts into the system prompt. +- Separate databases for explicit memories (`openpets-memory.json`) and chat history (`openpets-chat-history.json`). + +## Vanilla Chat MCP Tools + +- Internal stdio MCP client in the desktop main process (`mcp-chat-client.ts`). +- Multi-select tool activation in the floating chat window. +- Tiered tool browser: Starter, Terminal & Systems, Advanced. +- Save & Activate / Deactivate All controls. +- ReAct-style tool-calling loop (max 5 iterations) with responses/chat-completions integration. +- Supported tools: filesystem, terminal/shell, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite. + +## Curated MCP Toolkit Integration + +- Curated MCP Toolkit panel inside Control Center > Integrations. +- Multi-select chips by tier (Starter, Terminal & Systems, Advanced). +- Command Source labels clarify where tools come from: + - Stable release (npm) + - Shipped with this app + - Local source build +- Install Choice descriptions: + - Manual Setup = copy-paste commands + - Persistent Full Access = auto-register into Claude/Codex +- Removed redundant Curated Stack section in favor of direct tiered selection. + +## Pet Scale & Sizing + +- Settings slider for pet scale from 0.16x up to 10x, large enough to fill the screen. +- Drag-to-resize handle on the pet window for interactive scale adjustment. +- Pet window dynamically resizes to fit larger scales without clipping. +- Speech bubbles grow with the pet and larger window sizes; long messages scroll instead of cutting off. + +## Multi-Screen Support + +- Pet positioning uses `screen.getDisplayNearestPoint()` instead of always clamping to the primary display. +- `clampToNearestWorkArea()` keeps the pet within the work area of the display it is currently on. + +## Multi-Conversation Chat + +- Per-conversation message history instead of a single global transcript. +- Conversation list UI in the prompt window History button. +- Auto-generated conversation titles from the first user message. +- Create, switch, and delete conversations from the floating chat window. +- Editor button shows the current conversation's message list (unchanged). +- New Chat button creates a fresh conversation (old conversations are preserved). +- Conversation metadata stored in `openpets-chat-conversations.json`. + +## Settings & Control Center + +- Memory viewer section in Settings. +- Vanilla Chat tool activation section in Integrations. +- OpenAPI chat settings: endpoint, model, credential, theme, base instructions toggle. +- Moonshot/Kimi API endpoint preset. + +## Build & Packaging + +- Sharp Windows native binary packaging resolved via `asarUnpack` + post-build copy. +- `prompt-window-preload.cjs` added for the floating chat renderer bridge. +- Additional preload scripts and assets packaged for the new surfaces. diff --git a/README.md b/README.md index 7a4c61d0..b7b457b9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.

+

+ This branch also includes a floating chat surface, local memory, vanilla MCP tools, and expanded pet sizing. See FEATURES.md and PULL_REQUEST.md for details. +

+

OpenPets reacting across multiple coding agent sessions

@@ -73,6 +77,8 @@ OpenPets is a tray-first desktop companion app. A pet appears on your desktop, k - **Bundled abilities** - first-party plugins can add ambient check-ins, break nudges, playful pet actions, focus timers, safe little walks, and optional developer notifications. - **Developer integrations** - advanced setup for Claude Code, OpenCode, Cursor, Pi, and MCP-capable tools when you want coding activity to drive the pet. - **MCP ready** - any MCP-capable agent can send short safe speech bubbles and reactions through the OpenPets MCP server. +- **Floating chat** - double-click the pet to open an always-on-top chat window with BYOK OpenAPI-compatible providers, conversation history, and optional MCP tool activation. +- **Local memory** - the pet remembers facts, preferences, and notes across sessions. - **Pet-pack friendly** - loads installed animated pet packs and can route a selected agent/project to its own pet window. - **Privacy-conscious by design** - automatic hook speech is static and local; prompts, code, logs, command output, URLs, paths, and secrets are not shown in bubbles. @@ -114,6 +120,7 @@ Use the desktop **Integrations** screen for global setup when available: - **Claude Code** - installs OpenPets MCP, Claude memory instructions, and optional Claude hooks. - **OpenCode** - installs OpenPets MCP, an OpenCode instruction file, and the `@open-pets/opencode` plugin. +- **MCP Toolkit** - curated setup guidance for Filesystem, Git, GitHub, Playwright, Browser Use, Memory, Fetch, Docker, and adjacent terminal/system MCP layers.

OpenPets desktop integrations screen @@ -213,9 +220,60 @@ Available MCP tools: - `openpets_status` - check whether OpenPets is reachable and which pet is targeted. - `openpets_react` - set a short reaction on the target pet. - `openpets_say` - show a short safe speech bubble, optionally with a reaction. +- `openpets_memory_list` - list recent long-term memories stored by OpenPets. +- `openpets_memory_search` - search pet memory for relevant facts, preferences, or notes. +- `openpets_memory_store` - store a durable memory for the pet. +- `openpets_memory_forget` - delete a stored memory by id. `openpets_say` messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths. +### Curated MCP toolkit + +The desktop app also includes a manual-but-curated **MCP Toolkit** panel under **Integrations**. It is there to help you build a practical broader stack without stuffing OpenPets full of unsafe one-click third-party installs. + +The toolkit now explicitly offers two paths: + +- `Manual Setup` - per-tool guidance, docs links, and copyable snippets +- `Persistent Full Access` - `Install Now` for the supported persistent baseline in hosts such as Claude Code or Codex CLI, plus a matching copyable bundle +- `Vanilla Chat Tools` - activate MCP tools directly inside the floating chat window for autonomous filesystem, terminal, web, and browser tasks + +The current supported `Install Now` baseline covers: + +- Filesystem +- Playwright +- Memory +- Context7 / Docs +- Fetch / Web when `uvx` is available +- Sequential Thinking +- Browser Use when `uvx` is available, with its own runtime credential still required + +The current recommended starter set is: + +- Filesystem +- Git +- GitHub +- Playwright +- Browser Use +- SQLite / PostgreSQL +- Memory +- Fetch / Web +- Sequential Thinking +- Docker MCP Toolkit + +It also maps the next terminal-oriented layers that usually matter once your workflow grows up a bit: + +- Shell / Terminal +- Process & Logs +- System Info +- SSH +- Package Manager +- Tmux +- Kubernetes / Cloud +- CI / CD +- Ghidra +- Binary Analysis +- Network Analysis + ## How it works ```text diff --git a/apps/desktop/control-center-preload.cjs b/apps/desktop/control-center-preload.cjs index 3c129667..bf674521 100644 --- a/apps/desktop/control-center-preload.cjs +++ b/apps/desktop/control-center-preload.cjs @@ -4,7 +4,10 @@ const api = { getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"), getDashboardSnapshot: () => ipcRenderer.invoke("openpets:get-dashboard-snapshot"), getSettingsState: () => ipcRenderer.invoke("openpets:get-settings-state"), + getOpenApiChatSettings: () => ipcRenderer.invoke("openpets:get-openapi-chat-settings"), updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch), + saveOpenApiCredential: (apiKey) => ipcRenderer.invoke("openpets:save-openapi-credential", apiKey), + clearOpenApiCredential: () => ipcRenderer.invoke("openpets:clear-openapi-credential"), getReactionAnimationSettings: () => ipcRenderer.invoke("openpets:get-reaction-animation-settings"), getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"), setLaunchAtLogin: (enabled) => ipcRenderer.invoke("openpets:set-launch-at-login", enabled), @@ -40,6 +43,15 @@ const api = { getIntegrationsState: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-snapshot", selectedPetId, commandMode), runIntegrationAction: (action, selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-action", action, selectedPetId, commandMode), updateIntegrationCommandPaths: (patch) => ipcRenderer.invoke("openpets:agent-setup-command-paths", patch), + copyText: (text) => ipcRenderer.invoke("openpets:copy-text", text), + openExternalUrl: (url) => ipcRenderer.invoke("openpets:open-external-url", url), + installMcpToolkit: (target) => ipcRenderer.invoke("openpets:install-mcp-toolkit", target), + getVanillaChatMcpTools: () => ipcRenderer.invoke("openpets:get-vanilla-chat-mcp-tools"), + setVanillaChatMcpTools: (toolIds) => ipcRenderer.invoke("openpets:set-vanilla-chat-mcp-tools", toolIds), + getMemories: (query, limit) => ipcRenderer.invoke("openpets:get-memories", query, limit), + storeMemory: (text, kind, tags, importance) => ipcRenderer.invoke("openpets:store-memory", text, kind, tags, importance), + updateMemory: (id, text, kind, tags, importance) => ipcRenderer.invoke("openpets:update-memory", id, text, kind, tags, importance), + deleteMemory: (id) => ipcRenderer.invoke("openpets:delete-memory", id), }; contextBridge.exposeInMainWorld("openPetsControlCenter", api); diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 2a6676d4..7a02f0c4 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -12,6 +12,7 @@ files: - dist/** - control-center-preload.cjs - pet-preload.cjs + - prompt-window-preload.cjs - plugin-sdk-preload.cjs - plugin-command-form-preload.cjs - assets/** diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 73ae98fd..8de686d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,7 +21,7 @@ "package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder", "package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output", "test": "node scripts/run-tests.mjs", - "build:deps": "pnpm --filter @open-pets/desktop^... build", + "build:deps": "cd ../.. && pnpm --filter './packages/*' build", "test:build": "pnpm build:deps && tsc -p tsconfig.tests.json", "check": "pnpm typecheck && pnpm build && pnpm test", "typecheck": "pnpm build:deps && tsc --noEmit && tsc -p tsconfig.renderer.json --noEmit", @@ -48,6 +48,7 @@ "wait-on": "^9.0.10" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@open-pets/agent-events": "workspace:*", "@open-pets/claude": "workspace:*", "@open-pets/cli": "workspace:*", diff --git a/apps/desktop/pet-preload.cjs b/apps/desktop/pet-preload.cjs index 52811325..cabd9675 100644 --- a/apps/desktop/pet-preload.cjs +++ b/apps/desktop/pet-preload.cjs @@ -4,6 +4,10 @@ const allowedMotionStates = new Set(["idle", "run-left", "run-right"]); const allowedReactionStates = new Set(["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]); let lastInteractiveHit = null; let dragging = false; +let scaling = false; +let scaleStartY = 0; +let scaleStartValue = 0.56; +let scalePreviewTimer = null; const dismissBubble = (event) => { if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; @@ -23,12 +27,22 @@ const dismissBubble = (event) => { bubble.remove(); const newTarget = document.elementFromPoint(event.clientX, event.clientY); - const stillInteractive = Boolean(newTarget && newTarget.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging; + const stillInteractive = Boolean(newTarget && newTarget.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging || scaling; reportInteractiveHit(stillInteractive, "bubble-dismiss", true); ipcRenderer.send("openpets:bubble-dismissed", dismissToken); }; +const requestPromptWindow = (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest(".bubble")) return; + if (!target.closest(".pet-hitbox, .pet-shell")) return; + event.preventDefault(); + event.stopPropagation(); + ipcRenderer.send("openpets:pet-open-prompt"); +}; + ipcRenderer.on("openpets:pet-motion", (_event, state) => { if (!allowedMotionStates.has(state)) { return; @@ -80,7 +94,7 @@ ipcRenderer.on("openpets:pet-content-state", (_event, state) => { const getInteractiveTarget = (event) => { const target = document.elementFromPoint(event.clientX, event.clientY); - return target && target.closest(".pet-hitbox, .pet-shell, .bubble"); + return target && target.closest(".pet-hitbox, .pet-shell, .bubble, .scale-handle"); }; const reportInteractiveHit = (interactive, source, force = false) => { @@ -95,7 +109,30 @@ const setInteractiveHit = (interactive, source = "mouse") => { }; const updateInteractiveHit = (event) => { - setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging); + setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging || scaling); +}; + +const getCurrentSpriteScale = () => { + const sprite = document.querySelector(".sprite, .installed-sprite"); + if (!sprite) return 0.56; + const style = sprite.getAttribute("style") || ""; + const match = style.match(/transform:\s*scale\(([\d.]+)\)/); + return match ? parseFloat(match[1]) : 0.56; +}; + +const applyScalePreview = (scale) => { + const clamped = Math.min(Math.max(scale, 0.16), 10); + const sprite = document.querySelector(".sprite, .installed-sprite"); + if (sprite) sprite.style.transform = `scale(${clamped})`; + return clamped; +}; + +const sendScalePreview = (scale) => { + if (scalePreviewTimer) return; + scalePreviewTimer = setTimeout(() => { + scalePreviewTimer = null; + }, 120); + ipcRenderer.send("openpets:pet-scale-preview", { scale }); }; ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => { @@ -103,23 +140,43 @@ ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => { const clientX = point.clientX; const clientY = point.clientY; const target = document.elementFromPoint(clientX, clientY); - reportInteractiveHit(Boolean(target && target.closest(".pet-hitbox, .pet-shell, .bubble")) || dragging, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true); + reportInteractiveHit(Boolean(target && target.closest(".pet-hitbox, .pet-shell, .bubble, .scale-handle")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true); }); const installMouseInterop = () => { lastInteractiveHit = null; dragging = false; + scaling = false; document.addEventListener("click", dismissBubble); + document.addEventListener("dblclick", requestPromptWindow); document.addEventListener("mousemove", (event) => { updateInteractiveHit(event); if (dragging) ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY }); + if (scaling) { + const newScale = scaleStartValue + (event.clientY - scaleStartY) * 0.003; + const clamped = applyScalePreview(newScale); + sendScalePreview(clamped); + } }, { passive: true }); document.addEventListener("mousedown", (event) => { const target = getInteractiveTarget(event); setInteractiveHit(Boolean(target)); + + const scaleHandle = target && target.closest(".scale-handle"); + if (scaleHandle) { + if (event.button !== 0) return; + event.preventDefault(); + scaling = true; + scaleStartY = event.clientY; + scaleStartValue = getCurrentSpriteScale(); + setInteractiveHit(true); + ipcRenderer.send("openpets:pet-scale-start"); + return; + } + if (event.button !== 0 || !target?.closest(".pet-hitbox, .pet-shell")) return; event.preventDefault(); dragging = true; @@ -128,13 +185,20 @@ const installMouseInterop = () => { }); document.addEventListener("mouseup", () => { - if (!dragging) return; - dragging = false; - ipcRenderer.send("openpets:pet-drag-end"); + if (dragging) { + dragging = false; + ipcRenderer.send("openpets:pet-drag-end"); + } + if (scaling) { + scaling = false; + const sprite = document.querySelector(".sprite, .installed-sprite"); + const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue; + ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale }); + } }); document.addEventListener("mouseleave", () => { - if (!dragging) setInteractiveHit(false); + if (!dragging && !scaling) setInteractiveHit(false); }, { passive: true }); setInteractiveHit(false, "ready"); diff --git a/apps/desktop/prompt-window-preload.cjs b/apps/desktop/prompt-window-preload.cjs new file mode 100644 index 00000000..10875da4 --- /dev/null +++ b/apps/desktop/prompt-window-preload.cjs @@ -0,0 +1,15 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +const api = { + getState: () => ipcRenderer.invoke("openpets:prompt-window-state"), + submitPrompt: (prompt) => ipcRenderer.invoke("openpets:prompt-window-submit", prompt), + resetConversation: () => ipcRenderer.invoke("openpets:prompt-window-reset-conversation"), + createConversation: () => ipcRenderer.invoke("openpets:prompt-window-create-conversation"), + switchConversation: (conversationId) => ipcRenderer.invoke("openpets:prompt-window-switch-conversation", conversationId), + deleteConversation: (conversationId) => ipcRenderer.invoke("openpets:prompt-window-delete-conversation", conversationId), + openSettings: () => ipcRenderer.invoke("openpets:prompt-window-open-settings"), + resizeWindow: (bounds) => ipcRenderer.invoke("openpets:prompt-window-resize", bounds), + close: () => ipcRenderer.invoke("openpets:prompt-window-close"), +}; + +contextBridge.exposeInMainWorld("openPetsPromptWindow", api); diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index f81bb307..9482ac2f 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -20,6 +20,7 @@ const behaviorTests = [ ".test-dist/tests/zip-safety.test.js", ".test-dist/tests/codex-pets.test.js", ".test-dist/tests/claude-memory.test.js", + ".test-dist/tests/prompt-memory-extraction.test.js", ".test-dist/tests/plugin-config.test.js", ".test-dist/tests/plugin-state.test.js", ".test-dist/tests/plugin-runtime.test.js", diff --git a/apps/desktop/src/agent-pet-controller.ts b/apps/desktop/src/agent-pet-controller.ts index d75d7d9a..f3be3617 100644 --- a/apps/desktop/src/agent-pet-controller.ts +++ b/apps/desktop/src/agent-pet-controller.ts @@ -132,6 +132,7 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow { badge, onCloseRequested: () => dismissAgentPetForActiveLease(petId), onBubbleDismissed: (token) => handleBubbleDismissed(petId, token), + onScaleChanged: handleAgentScaleChanged, }, getCurrentDismissToken(petId, display, badge)); const windowId = window.id; @@ -252,6 +253,15 @@ function getCurrentDismissToken(petId: string, display: PetTransientDisplay | nu return display?.dismissToken ?? (badge ? String(displayGenerations.get(petId) ?? 0) : undefined); } +function handleAgentScaleChanged(scale: number): void { + debug("pet.agent", "scale changed", { scale }); + import("./app-state.js").then(({ updatePreferences }) => { + updatePreferences({ petScale: scale }); + }).catch((error) => { + console.error("Failed to update pet scale preference.", error); + }); +} + function getPreferredPetScale(): PetScaleValue { return getAppStateSnapshot().preferences.petScale as PetScaleValue; } diff --git a/apps/desktop/src/app-state-core.ts b/apps/desktop/src/app-state-core.ts index 356838d9..46bffbdd 100644 --- a/apps/desktop/src/app-state-core.ts +++ b/apps/desktop/src/app-state-core.ts @@ -3,15 +3,25 @@ export interface OnboardingPreferenceLike { } export const petScaleOptions = [ + { label: "XXS", value: 0.24 }, + { label: "XS", value: 0.32 }, { label: "Small", value: 0.44 }, { label: "Medium", value: 0.56 }, { label: "Large", value: 0.72 }, + { label: "XL", value: 0.88 }, + { label: "XXL", value: 1.04 }, + { label: "XXXL", value: 1.20 }, ] as const; -export type PetScaleValue = typeof petScaleOptions[number]["value"]; +export type PetScaleValue = number; export const defaultPetScale: PetScaleValue = 0.56; +export const minPetScale = 0.16; +export const maxPetScale = 10; export function normalizePetScale(value: unknown): PetScaleValue { - return petScaleOptions.find((option) => option.value === value)?.value ?? defaultPetScale; + if (typeof value === "number" && Number.isFinite(value)) { + return Math.min(Math.max(value, minPetScale), maxPetScale); + } + return defaultPetScale; } export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): boolean { diff --git a/apps/desktop/src/app-state.ts b/apps/desktop/src/app-state.ts index d1bea1e9..3bc271b2 100644 --- a/apps/desktop/src/app-state.ts +++ b/apps/desktop/src/app-state.ts @@ -37,11 +37,17 @@ export interface OpenPetsStateV1 { readonly openDefaultPetOnLaunch: boolean; readonly speechBubblesEnabled: boolean; readonly petScale: number; + readonly openApiChatModel?: string; + readonly openApiChatSystemPrompt?: string; + readonly openApiChatEndpoint?: string; + readonly openApiChatTheme?: "system" | "light" | "dark"; readonly reactionAnimationOverrides?: ReactionAnimationOverrides; readonly onboardingCompleted: boolean; readonly claudeCommandPath?: string; readonly nodeCommandPath?: string; readonly opencodeCommandPath?: string; + readonly vanillaChatMcpTools?: readonly string[]; + readonly openApiChatBaseInstructionsEnabled?: boolean; }; readonly pets: { readonly installed: readonly InstalledPetState[]; @@ -66,6 +72,8 @@ export type OpenPetsActivityRecord = export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue }; +export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses"; + const stateFileName = "openpets-state.json"; const directInstallLockName = ".install-pet.lock"; const directInstallLockStaleMs = 10 * 60 * 1000; @@ -369,6 +377,12 @@ function normalizeTimestamp(value: unknown): number | undefined { function normalizePreferences(value: Partial): OpenPetsStateV1["preferences"] { const defaultState = createDefaultState(); + const legacyValue = value as Partial<{ + readonly openAiChatModel?: unknown; + readonly openAiChatSystemPrompt?: unknown; + readonly openAiChatEndpoint?: unknown; + readonly openAiChatTheme?: unknown; + }>; return { defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id, @@ -377,14 +391,26 @@ function normalizePreferences(value: Partial): O : defaultState.preferences.openDefaultPetOnLaunch, speechBubblesEnabled: true, petScale: normalizePetScale(value.petScale), + openApiChatModel: normalizeOpenApiChatModel(value.openApiChatModel ?? legacyValue.openAiChatModel), + openApiChatSystemPrompt: normalizeOpenApiChatSystemPrompt(value.openApiChatSystemPrompt ?? legacyValue.openAiChatSystemPrompt), + openApiChatEndpoint: normalizeOpenApiChatEndpoint(value.openApiChatEndpoint ?? legacyValue.openAiChatEndpoint), + openApiChatTheme: normalizeOpenApiChatTheme(value.openApiChatTheme ?? legacyValue.openAiChatTheme), reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides), onboardingCompleted: normalizeOnboardingCompleted(value), claudeCommandPath: normalizeCommandPath(value.claudeCommandPath), nodeCommandPath: normalizeCommandPath(value.nodeCommandPath), opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath), + vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools), + openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true, }; } +function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) return undefined; + const valid = value.filter((v): v is string => typeof v === "string" && /^[a-z0-9-]+$/.test(v)); + return valid.length > 0 ? valid : undefined; +} + function normalizeCommandPath(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); @@ -398,6 +424,57 @@ function normalizeCommandPath(value: unknown): string | undefined { return trimmed; } +function normalizeOpenApiChatModel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined; + return trimmed; +} + +function normalizeOpenApiChatSystemPrompt(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.replaceAll("\r\n", "\n").trim(); + if (!normalized || normalized.length > 8_000 || /[\0]/.test(normalized)) return undefined; + return normalized; +} + +export function normalizeOpenApiChatEndpoint(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 2_048 || /[\0\r\n]/.test(trimmed)) return undefined; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return undefined; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1") { + return undefined; + } + if (!url.hostname || url.username || url.password || url.search || url.hash) return undefined; + + const path = url.pathname.replace(/\/+$/, ""); + if (!path || path === "/") { + url.pathname = "/v1"; + } else if (path.endsWith("/responses") || path.endsWith("/chat/completions")) { + url.pathname = path; + } else if (path.endsWith("/v1")) { + url.pathname = path; + } else { + return undefined; + } + + const normalized = url.toString(); + return normalized === defaultOpenApiChatEndpoint ? undefined : normalized; +} + +function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark" { + return value === "light" || value === "dark" ? value : "system"; +} + function normalizeInstalledPets(value: Record): InstalledPetState[] { const installed = isRecord(value.pets) && Array.isArray(value.pets.installed) ? value.pets.installed @@ -444,11 +521,17 @@ function createDefaultState(): OpenPetsStateV1 { openDefaultPetOnLaunch: true, speechBubblesEnabled: true, petScale: defaultPetScale, + openApiChatModel: undefined, + openApiChatSystemPrompt: undefined, + openApiChatEndpoint: undefined, + openApiChatTheme: "system", reactionAnimationOverrides: undefined, onboardingCompleted: false, claudeCommandPath: undefined, nodeCommandPath: undefined, opencodeCommandPath: undefined, + vanillaChatMcpTools: undefined, + openApiChatBaseInstructionsEnabled: true, }, pets: { installed: [builtInPet], diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 98f02801..80f018ea 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -47,6 +47,7 @@ assert.match(builderConfig, /node_modules\/\*\*/); assert.match(builderConfig, /dist\/\*\*/); assert.match(builderConfig, /control-center-preload\.cjs/); assert.match(builderConfig, /pet-preload\.cjs/); +assert.match(builderConfig, /prompt-window-preload\.cjs/); assert.match(builderConfig, /plugin-sdk-preload\.cjs/); assert.match(builderConfig, /plugin-command-form-preload\.cjs/); assert.match(builderConfig, /assets\/\*\*/); @@ -55,6 +56,7 @@ assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/); assert.ok(existsSync(join(appDir, "control-center-preload.cjs")), "control-center-preload.cjs must exist for Control Center IPC."); assert.ok(existsSync(join(appDir, "pet-preload.cjs")), "pet-preload.cjs must exist for pet window motion state updates."); +assert.ok(existsSync(join(appDir, "prompt-window-preload.cjs")), "prompt-window-preload.cjs must exist for the floating prompt window IPC."); assert.ok(existsSync(join(appDir, "plugin-sdk-preload.cjs")), "plugin-sdk-preload.cjs must exist for JavaScript plugin SDK hosting."); assert.ok(existsSync(join(appDir, "plugin-command-form-preload.cjs")), "plugin-command-form-preload.cjs must exist for plugin command forms."); assert.ok(existsSync(join(appDir, "assets", "tray-icon.png")), "tray icon must exist for packaging."); @@ -70,11 +72,13 @@ const petWindowSource = readFileSync(join(appDir, "src", "pet-window.ts"), "utf8 const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-preload.cjs"), "utf8"); const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8"); const petPreloadSource = readFileSync(join(appDir, "pet-preload.cjs"), "utf8"); +const promptWindowPreloadSource = readFileSync(join(appDir, "prompt-window-preload.cjs"), "utf8"); const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8"); const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8"); const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"), "utf8"); const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8"); const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8"); +const promptWindowSource = readFileSync(join(appDir, "src", "prompt-window.ts"), "utf8"); const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8"); const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8"); const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8"); @@ -150,6 +154,7 @@ assert.match(petWindowSource, /\.pet-shell[\s\S]*?-webkit-app-region: no-drag; c assert.match(petPreloadSource, /openpets:pet-hit-test/, "pet preload must report visible pet and bubble hit testing for passthrough."); assert.match(petPreloadSource, /openpets:pet-ready/, "pet preload must report readiness after installing mouse handlers."); assert.match(petPreloadSource, /openpets:pet-drag-start/, "pet preload must start controlled pet dragging from the sprite."); +assert.match(petPreloadSource, /openpets:pet-open-prompt/, "pet preload must request the floating prompt window on double-click."); assert.match(defaultPetControllerSource, /powerMonitor\.on\("resume", recoverDefaultPetWindowAfterResume\)/, "default pet must recover mouse interop after Windows sleep or resume."); assert.match(defaultPetControllerSource, /recoverDefaultPetMouseInterop\("display-change"\)/, "default pet must recover mouse interop after monitor topology changes."); assert.match(windowsSource, /recoverDefaultPetMouseInterop\("default-pet-changed"\)/, "changing default pet must recover mouse interop for dragging without app restart."); @@ -177,11 +182,16 @@ assert.match(windowsSource, /openpets-pet-preview/, "settings reaction preview m assert.match(windowsSource, /openpets:open-update-release-page/, "settings window must be able to open the release page."); assert.match(controlCenterPreloadSource, /checkForUpdates/, "Control Center preload must expose update checks."); assert.match(controlCenterPreloadSource, /getReactionAnimationSettings/, "Control Center preload must expose reaction animation settings metadata."); -assert.match(controlCenterRendererSource, /function SettingsView\(\)/, "Control Center must include the settings page."); +assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Center preload must expose OpenAPI chat credential management."); +assert.match(promptWindowPreloadSource, /submitPrompt/, "Prompt window preload must expose prompt submission."); +assert.match(promptWindowSource, /sendOpenApiChatPrompt/, "Prompt window must submit prompts through the main-process OpenAPI chat service."); +assert.match(promptWindowSource, /openControlCenterWindow\("settings"\)/, "Prompt window must be able to open Settings when the API key is missing."); +assert.match(controlCenterRendererSource, /API key or token|OpenAI API key/, "Control Center settings must expose OpenAPI chat credential setup."); +assert.match(controlCenterRendererSource, /function SettingsView\(/, "Control Center must include the settings page."); assert.match(controlCenterRendererSource, /getPetsState/, "Control Center must include the pets page data bridge."); assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include the integrations page."); -assert.match(petWindowSource, /max-width:\s*min\(220px/, "very long message bubbles must stay capped within the tight pet window."); -assert.match(petWindowSource, /-webkit-line-clamp:\s*8/, "very long message bubbles must allow enough visible lines."); +assert.match(petWindowSource, /max-width:\s*min\(440px/, "very long message bubbles must stay capped within the pet window while allowing larger pets."); +assert.match(petWindowSource, /\.bubble-body \{[\s\S]*?overflow-y:\s*auto/, "bubble bodies must be scrollable for long messages."); assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state."); assert.match(petWindowSource, /createSpriteStateCss\("\.installed-sprite"\)/, "installed sprite CSS must react to reaction state."); assert.match(petWindowSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state."); diff --git a/apps/desktop/src/codex-pets.ts b/apps/desktop/src/codex-pets.ts index 83ed91ac..a3f1189d 100644 --- a/apps/desktop/src/codex-pets.ts +++ b/apps/desktop/src/codex-pets.ts @@ -3,8 +3,6 @@ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } import { homedir } from "node:os"; import { basename, join, resolve, sep } from "node:path"; -import sharp from "sharp"; - import { getAppStateSnapshot, installPetState, type OpenPetsStateV1 } from "./app-state.js"; import { maxCodexPetJsonBytes, maxCodexPets, maxCodexSpritesheetBytes, maxCodexThumbnailSourceBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-pets-core.js"; import { withPetOperation } from "./pet-installation.js"; @@ -12,6 +10,7 @@ import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } fr const codexPetsRoot = join(homedir(), ".codex", "pets"); const codexThumbnailCache = new Map(); +let sharpFactoryPromise: Promise | null = null; export interface CodexPetUiState { readonly source: "codex"; @@ -150,6 +149,9 @@ async function createCodexThumbnailDataUrl(path: string): Promise { const cached = codexThumbnailCache.get(cacheKey); if (cached !== undefined) return cached; + const sharp = await loadSharpFactory(); + if (!sharp) return ""; + const image = sharp(path, { limitInputPixels: 50_000_000 }); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) return ""; @@ -167,6 +169,15 @@ async function createCodexThumbnailDataUrl(path: string): Promise { return dataUrl; } +async function loadSharpFactory(): Promise { + if (!sharpFactoryPromise) { + sharpFactoryPromise = import("sharp") + .then((module) => module.default) + .catch(() => null); + } + return sharpFactoryPromise; +} + async function validateCodexRoot(): Promise { const root = resolve(codexPetsRoot); const rootStats = await lstat(root); diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts index cd68e178..32eb12aa 100644 --- a/apps/desktop/src/default-pet-controller.ts +++ b/apps/desktop/src/default-pet-controller.ts @@ -19,9 +19,15 @@ const maxPluginMoveDistance = 160; const minPluginMoveDurationMs = 250; const maxPluginMoveDurationMs = 1_500; let movementInProgress = false; +let promptRequestHandler: (() => void) | undefined; export type PetMoveOptions = { readonly x: number; readonly y: number; readonly durationMs?: number }; export type PetWanderOptions = { readonly distance?: number; readonly durationMs?: number }; +export type InternalPetMessageOptions = { + readonly reaction?: OpenPetsReaction; + readonly fullMessage?: boolean; + readonly sticky?: boolean; +}; export function showDefaultPet(): void { updatePreferences({ openDefaultPetOnLaunch: true }); @@ -108,6 +114,22 @@ export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction return { shown: isDefaultPetVisible() }; } +export function applyInternalPetMessage(message: string, options: InternalPetMessageOptions = {}): { readonly shown: boolean; readonly reason?: string } { + if (paused) { + return { shown: false, reason: "paused" }; + } + + if (!options.reaction) clearStatusBadge(); + setTransientDisplay({ + message, + reaction: options.reaction, + fullMessage: options.fullMessage, + sticky: options.sticky, + }); + showDefaultPet(); + return { shown: isDefaultPetVisible() }; +} + export function applyExternalPetMoveBy(options: PetMoveOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> { return moveDefaultPetBy(Number(options.x), Number(options.y), options.durationMs); } @@ -142,13 +164,19 @@ export function destroyDefaultPet(): void { window.destroy(); } -export function installDefaultPetDisplayHandlers(): void { +export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void { + promptRequestHandler = options.onPromptRequested; screen.on("display-added", reclampDefaultPetWindow); screen.on("display-removed", reclampDefaultPetWindow); screen.on("display-metrics-changed", reclampDefaultPetWindow); powerMonitor.on("resume", recoverDefaultPetWindowAfterResume); } +export function getDefaultPetWindowBounds(): Electron.Rectangle | null { + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return null; + return defaultPetWindow.getBounds(); +} + function handleBubbleDismissed(dismissToken: string): void { debug("pet.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayGeneration }); if (dismissToken !== String(displayGeneration)) { @@ -161,6 +189,15 @@ function handleBubbleDismissed(dismissToken: string): void { } } +function handleScaleChanged(scale: number): void { + debug("pet.default", "scale changed", { scale }); + import("./app-state.js").then(({ updatePreferences }) => { + updatePreferences({ petScale: scale }); + }).catch((error) => { + console.error("Failed to update pet scale preference.", error); + }); +} + function getOrCreateDefaultPetWindow(): BrowserWindow { if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { return defaultPetWindow; @@ -176,6 +213,8 @@ function getOrCreateDefaultPetWindow(): BrowserWindow { onPositionChanged: setDefaultPetPosition, onHideRequested: hideDefaultPet, onBubbleDismissed: handleBubbleDismissed, + onPromptRequested: () => promptRequestHandler?.(), + onScaleChanged: handleScaleChanged, }, getCurrentDismissToken()); const windowId = defaultPetWindow.id; info("pet.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId }); @@ -213,15 +252,17 @@ function setTransientDisplay(display: PetTransientDisplay): void { }, animationMs); } - transientDisplayTimeout = setTimeout(() => { - transientDisplay = null; - transientDisplayTimeout = null; - if (transientAnimationTimeout) { - clearTimeout(transientAnimationTimeout); - transientAnimationTimeout = null; - } - refreshDefaultPetContent(); - }, displayDurationMs); + if (!transientDisplay.sticky) { + transientDisplayTimeout = setTimeout(() => { + transientDisplay = null; + transientDisplayTimeout = null; + if (transientAnimationTimeout) { + clearTimeout(transientAnimationTimeout); + transientAnimationTimeout = null; + } + refreshDefaultPetContent(); + }, displayDurationMs); + } refreshDefaultPetContent(); } diff --git a/apps/desktop/src/display.ts b/apps/desktop/src/display.ts index cb7d2061..baf3bfa9 100644 --- a/apps/desktop/src/display.ts +++ b/apps/desktop/src/display.ts @@ -39,6 +39,20 @@ export function clampToPrimaryWorkArea(position: Point, size: WindowSize = defau }; } +export function clampToNearestWorkArea(position: Point, size: WindowSize = defaultPetWindowSize): Point { + const display = screen.getDisplayNearestPoint({ x: Math.round(position.x), y: Math.round(position.y) }); + const workArea = display.workArea; + const minX = workArea.x; + const minY = workArea.y; + const maxX = workArea.x + Math.max(0, workArea.width - size.width); + const maxY = workArea.y + Math.max(0, workArea.height - size.height); + + return { + x: clamp(Math.round(position.x), minX, maxX), + y: clamp(Math.round(position.y), minY, maxY), + }; +} + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index dd4b2fba..3f2d2ad5 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -3,6 +3,7 @@ import { app } from "electron"; import { closeAllAgentPets } from "./agent-pet-controller.js"; import { destroyDefaultPet } from "./default-pet-controller.js"; import { info } from "./logger.js"; +import { destroyMcpChatClientManager } from "./mcp-chat-client.js"; import { stopLocalIpcServer } from "./local-ipc.js"; import { stopPluginService } from "./plugin-service.js"; import { focusOpenTaskWindows } from "./windows.js"; @@ -35,6 +36,7 @@ export function installAppLifecycle(): void { scheduleHardExitFallback("before-quit"); stopPluginService(); stopLocalIpcServer(); + destroyMcpChatClientManager(); closeAllAgentPets(); destroyDefaultPet(); }); diff --git a/apps/desktop/src/local-ipc-protocol.ts b/apps/desktop/src/local-ipc-protocol.ts index 96026272..1ab97367 100644 --- a/apps/desktop/src/local-ipc-protocol.ts +++ b/apps/desktop/src/local-ipc-protocol.ts @@ -19,8 +19,16 @@ export const allowedReactions = [ "celebrating", ] as const; +export const allowedMemoryKinds = [ + "identity", + "preference", + "fact", + "note", +] as const; + export type OpenPetsReaction = typeof allowedReactions[number]; -export type OpenPetsIpcMethod = "hello" | "status" | "pets.list" | "pets.install" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "pet.react" | "pet.say"; +export type OpenPetsMemoryKind = typeof allowedMemoryKinds[number]; +export type OpenPetsIpcMethod = "hello" | "status" | "pets.list" | "pets.install" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "pet.react" | "pet.say" | "memory.list" | "memory.search" | "memory.store" | "memory.delete"; export interface OpenPetsIpcRequest { readonly id: string; @@ -55,7 +63,7 @@ export function parseIpcRequest(raw: string, expectedToken: string): OpenPetsIpc if (typeof parsed.id !== "string" || parsed.id.length < 1 || parsed.id.length > 120) throw new IpcProtocolError("invalid_request", "IPC request id is invalid."); if (parsed.version !== openPetsIpcVersion) throw new IpcProtocolError("invalid_version", "Unsupported IPC protocol version."); if (parsed.token !== expectedToken) throw new IpcProtocolError("invalid_token", "Invalid IPC token."); - if (parsed.method !== "hello" && parsed.method !== "status" && parsed.method !== "pets.list" && parsed.method !== "pets.install" && parsed.method !== "lease.acquire" && parsed.method !== "lease.heartbeat" && parsed.method !== "lease.release" && parsed.method !== "pet.react" && parsed.method !== "pet.say") { + if (parsed.method !== "hello" && parsed.method !== "status" && parsed.method !== "pets.list" && parsed.method !== "pets.install" && parsed.method !== "lease.acquire" && parsed.method !== "lease.heartbeat" && parsed.method !== "lease.release" && parsed.method !== "pet.react" && parsed.method !== "pet.say" && parsed.method !== "memory.list" && parsed.method !== "memory.search" && parsed.method !== "memory.store" && parsed.method !== "memory.delete") { throw new IpcProtocolError("unknown_method", "Unknown IPC method."); } @@ -113,6 +121,83 @@ export function validateRequestedPetId(value: unknown): string | undefined { return trimmed; } +export function validateMemoryKind(value: unknown): OpenPetsMemoryKind { + if (typeof value !== "string" || !allowedMemoryKinds.includes(value as OpenPetsMemoryKind)) { + throw new IpcProtocolError("invalid_params", "Invalid memory kind."); + } + return value as OpenPetsMemoryKind; +} + +export function validateOptionalMemoryKind(value: unknown): OpenPetsMemoryKind | undefined { + if (value === undefined) return undefined; + return validateMemoryKind(value); +} + +export function validateMemoryText(value: unknown): string { + if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Memory text must be a string."); + const text = value.replace(/\s+/g, " ").trim(); + if (text.length < 1) throw new IpcProtocolError("invalid_params", "Memory text cannot be empty."); + if (text.length > 480) throw new IpcProtocolError("invalid_params", "Memory text is too long."); + if (/[\0]/.test(text)) throw new IpcProtocolError("invalid_params", "Memory text is invalid."); + return text; +} + +export function validateMemoryTags(value: unknown): readonly string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new IpcProtocolError("invalid_params", "Memory tags must be an array."); + const tags: string[] = []; + for (const tagValue of value) { + if (typeof tagValue !== "string") throw new IpcProtocolError("invalid_params", "Memory tags must be strings."); + const tag = tagValue.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!tag || tag.length > 32) throw new IpcProtocolError("invalid_params", "Memory tags are invalid."); + if (!tags.includes(tag)) tags.push(tag); + if (tags.length > 8) throw new IpcProtocolError("invalid_params", "Too many memory tags."); + } + return tags; +} + +export function validateMemoryImportance(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new IpcProtocolError("invalid_params", "Memory importance must be a number."); + } + const rounded = Math.round(value); + if (rounded < 1 || rounded > 5) { + throw new IpcProtocolError("invalid_params", "Memory importance must be between 1 and 5."); + } + return rounded; +} + +export function validateMemoryId(value: unknown): string { + if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Memory id must be a string."); + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 80 || /[\0\r\n]/.test(trimmed)) { + throw new IpcProtocolError("invalid_params", "Memory id is invalid."); + } + return trimmed; +} + +export function validateMemoryQuery(value: unknown): string { + if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Memory query must be a string."); + const trimmed = value.replace(/\s+/g, " ").trim(); + if (!trimmed || trimmed.length > 240 || /[\0]/.test(trimmed)) { + throw new IpcProtocolError("invalid_params", "Memory query is invalid."); + } + return trimmed; +} + +export function validateMemoryLimit(value: unknown, fallback: number, max = 25): number { + if (value === undefined) return fallback; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new IpcProtocolError("invalid_params", "Memory limit must be a number."); + } + const rounded = Math.round(value); + if (rounded < 1 || rounded > max) { + throw new IpcProtocolError("invalid_params", `Memory limit must be between 1 and ${max}.`); + } + return rounded; +} + export function okResponse(id: string | null, result: unknown): OpenPetsIpcResponse { return { id, ok: true, result }; } diff --git a/apps/desktop/src/local-ipc.ts b/apps/desktop/src/local-ipc.ts index 9804bbe0..5cd1614a 100644 --- a/apps/desktop/src/local-ipc.ts +++ b/apps/desktop/src/local-ipc.ts @@ -8,7 +8,8 @@ import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isD import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js"; import { debug, error as logError, info } from "./logger.js"; import { cleanupUnixSocket, getDiscoveryFilePath, getIpcEndpointConfig, parseIpcEndpoint, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type IpcEndpoint, type IpcEndpointConfig, type OpenPetsDiscoveryFile } from "./local-ipc-paths.js"; -import { errorResponse, IpcProtocolError, isRecord, maxIpcMessageBytes, okResponse, parseIpcRequest, validateInstallPetId, validateOptionalLeaseId, validateReaction, validateRequestedPetId, validateSayMessage, type OpenPetsIpcRequest } from "./local-ipc-protocol.js"; +import { errorResponse, IpcProtocolError, isRecord, maxIpcMessageBytes, okResponse, parseIpcRequest, validateInstallPetId, validateMemoryId, validateMemoryImportance, validateMemoryLimit, validateMemoryQuery, validateMemoryTags, validateMemoryText, validateOptionalLeaseId, validateOptionalMemoryKind, validateReaction, validateRequestedPetId, validateSayMessage, type OpenPetsIpcRequest } from "./local-ipc-protocol.js"; +import { forgetOpenPetsMemory, listOpenPetsMemories, searchOpenPetsMemories, storeOpenPetsMemory } from "./openpets-memory.js"; import { installPet } from "./pet-installation.js"; let ipcServer: net.Server | null = null; @@ -302,6 +303,58 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise { return leaseManager.release(leaseId); } + if (request.method === "memory.list") { + const params = isRecord(request.params) ? request.params : {}; + const limit = validateMemoryLimit(params.limit, 12, 25); + debug("ipc", "memory list requested", { requestId: request.id, limit }); + return { + ok: true, + memories: listOpenPetsMemories(limit), + }; + } + + if (request.method === "memory.search") { + const params = isRecord(request.params) ? request.params : {}; + const query = validateMemoryQuery(params.query); + const limit = validateMemoryLimit(params.limit, 8, 12); + debug("ipc", "memory search requested", { requestId: request.id, limit }); + return { + ok: true, + query, + memories: searchOpenPetsMemories(query, limit), + }; + } + + if (request.method === "memory.store") { + const params = isRecord(request.params) ? request.params : {}; + const text = validateMemoryText(params.text); + const kind = validateOptionalMemoryKind(params.kind); + const tags = validateMemoryTags(params.tags); + const importance = validateMemoryImportance(params.importance); + debug("ipc", "memory store requested", { requestId: request.id, kind, hasTags: Array.isArray(tags) && tags.length > 0, importance }); + return { + ok: true, + memory: storeOpenPetsMemory({ + text, + kind, + tags, + importance, + source: "mcp", + }), + }; + } + + if (request.method === "memory.delete") { + const params = isRecord(request.params) ? request.params : {}; + const id = validateMemoryId(params.id); + debug("ipc", "memory delete requested", { requestId: request.id, id }); + return { + ok: true, + deleted: forgetOpenPetsMemory(id), + id, + }; + } + if (request.method === "pet.react") { const params = isRecord(request.params) ? request.params : {}; const reaction = validateReaction(params.reaction); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 36355ea1..03f7feb0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -11,6 +11,7 @@ import { startLocalIpcServer } from "./local-ipc.js"; import { defaultPluginPetApi } from "./plugin-pet-api.js"; import { ElectronPluginJsHost } from "./plugin-js-host.js"; import { initializePluginService } from "./plugin-service.js"; +import { installPromptWindowHandlers, openPromptWindow } from "./prompt-window.js"; import { createAppTray, refreshTrayMenu } from "./tray.js"; import { checkForGitHubReleaseUpdate } from "./update-checker.js"; import { installInternalUiHandlers, installInternalUiProtocol } from "./windows.js"; @@ -53,8 +54,9 @@ if (!gotSingleInstanceLock) { initializeAppState(); installInternalUiProtocol(); installInternalUiHandlers(); + installPromptWindowHandlers(); createAppTray(); - installDefaultPetDisplayHandlers(); + installDefaultPetDisplayHandlers({ onPromptRequested: openPromptWindow }); await startLocalIpcServer(); releaseStartupInstallLock(); const roots = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_ROOTS); diff --git a/apps/desktop/src/mcp-chat-client.ts b/apps/desktop/src/mcp-chat-client.ts new file mode 100644 index 00000000..cee934ce --- /dev/null +++ b/apps/desktop/src/mcp-chat-client.ts @@ -0,0 +1,244 @@ +import { spawn } from "node:child_process"; +import { app } from "electron"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { CallToolResultSchema, type Tool } from "@modelcontextprotocol/sdk/types.js"; + +import { info, warn, error as logError } from "./logger.js"; + +export interface McpChatToolDefinition { + readonly type: "function"; + readonly function: { + readonly name: string; + readonly description: string; + readonly parameters: Record; + }; +} + +interface ActiveServer { + readonly id: string; + readonly client: Client; + readonly transport: StdioClientTransport; + readonly tools: Tool[]; +} + +interface ServerSpawnConfig { + readonly command: string; + readonly args: readonly string[]; + readonly env?: Record; +} + +// Map of catalog entry IDs to spawn configurations for vanilla chat. +// These are the servers OpenPets can spawn and manage internally. +function getVanillaChatServerConfigs(homeDir: string): Record { + return { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", homeDir], + }, + shell: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-terminal"], + }, + memory: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + }, + "fetch-web": { + command: "uvx", + args: ["mcp-server-fetch"], + }, + "sequential-thinking": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sequential-thinking"], + }, + playwright: { + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + }, + git: { + command: "uvx", + args: ["mcp-server-git", "--repository", homeDir], + }, + github: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_PERSONAL_ACCESS_TOKEN ?? "" }, + }, + docker: { + command: "uvx", + args: ["mcp-server-docker"], + }, + sqlite: { + command: "uvx", + args: ["mcp-server-sqlite"], + }, + }; +} + +class McpChatClientManager { + private activeServers = new Map(); + private serverConfigs: Record; + private homeDir: string; + + constructor() { + this.homeDir = app.getPath("home"); + this.serverConfigs = getVanillaChatServerConfigs(this.homeDir); + } + + async startEnabledServers(enabledIds: readonly string[]): Promise { + const toStop = new Set(this.activeServers.keys()); + const toStart: string[] = []; + + for (const id of enabledIds) { + if (this.activeServers.has(id)) { + toStop.delete(id); + } else { + toStart.push(id); + } + } + + for (const id of toStop) { + await this.stopServer(id); + } + + for (const id of toStart) { + await this.startServer(id).catch((err) => { + logError("app", `Failed to start MCP server ${id}`, { error: err instanceof Error ? err.message : String(err) }); + }); + } + } + + async startServer(id: string): Promise { + if (this.activeServers.has(id)) return; + + const config = this.serverConfigs[id]; + if (!config) { + throw new Error(`No spawn configuration for MCP server: ${id}`); + } + + info("app", `Starting MCP server`, { id, command: config.command }); + + const transport = new StdioClientTransport({ + command: config.command, + args: [...config.args], + env: config.env, + }); + + const client = new Client({ name: "openpets-vanilla-chat", version: app.getVersion() }); + await client.connect(transport); + + const toolsResult = await client.listTools(); + const tools = toolsResult.tools ?? []; + + this.activeServers.set(id, { id, client, transport, tools }); + info("app", `MCP server ready`, { id, toolCount: tools.length }); + } + + async stopServer(id: string): Promise { + const server = this.activeServers.get(id); + if (!server) return; + + info("app", `Stopping MCP server`, { id }); + try { + await server.client.close(); + } catch { + // ignore + } + this.activeServers.delete(id); + } + + async stopAll(): Promise { + for (const id of Array.from(this.activeServers.keys())) { + await this.stopServer(id); + } + } + + listTools(): McpChatToolDefinition[] { + const openAiTools: McpChatToolDefinition[] = []; + const seenNames = new Set(); + + for (const server of this.activeServers.values()) { + for (const tool of server.tools) { + let name = tool.name; + // De-duplicate by prefixing with server id if needed + if (seenNames.has(name)) { + name = `${server.id}_${name}`; + } + seenNames.add(name); + + openAiTools.push({ + type: "function", + function: { + name, + description: tool.description ?? `${server.id} tool`, + parameters: (tool.inputSchema ?? { type: "object" }) as Record, + }, + }); + } + } + + return openAiTools; + } + + async callTool(name: string, args: unknown): Promise { + // Find which server owns this tool + for (const server of this.activeServers.values()) { + const hasTool = server.tools.some((t) => t.name === name || `${server.id}_${t.name}` === name); + if (!hasTool) continue; + + const actualName = server.tools.some((t) => t.name === name) ? name : name.replace(`${server.id}_`, ""); + + info("app", `Calling tool`, { serverId: server.id, tool: actualName }); + const result = await server.client.callTool({ name: actualName, arguments: args as Record }, CallToolResultSchema); + + if (result.isError) { + return `Error: ${this.extractResultText(result)}`; + } + return this.extractResultText(result); + } + + throw new Error(`Tool not found in any active MCP server: ${name}`); + } + + getActiveServerIds(): string[] { + return Array.from(this.activeServers.keys()); + } + + private extractResultText(result: unknown): string { + const r = result as { content?: Array>; isError?: boolean } | undefined; + if (!r?.content || r.content.length === 0) { + return r?.isError ? "The tool returned an error with no details." : ""; + } + return r.content + .map((item) => { + if (item.type === "text") return item.text ?? ""; + if (item.type === "image") return `[image: ${item.mimeType ?? "unknown"}]`; + if (item.type === "audio") return `[audio: ${item.mimeType ?? "unknown"}]`; + return JSON.stringify(item); + }) + .join("\n"); + } +} + +let globalManager: McpChatClientManager | null = null; + +export function getMcpChatClientManager(): McpChatClientManager { + if (!globalManager) { + globalManager = new McpChatClientManager(); + } + return globalManager; +} + +export function destroyMcpChatClientManager(): void { + if (globalManager) { + globalManager.stopAll().catch(() => undefined); + globalManager = null; + } +} + +export function listMcpChatVanillaServerIds(): string[] { + const homeDir = app.getPath("home"); + return Object.keys(getVanillaChatServerConfigs(homeDir)); +} diff --git a/apps/desktop/src/mcp-toolkit-installer.ts b/apps/desktop/src/mcp-toolkit-installer.ts new file mode 100644 index 00000000..d1d3c95e --- /dev/null +++ b/apps/desktop/src/mcp-toolkit-installer.ts @@ -0,0 +1,395 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { app } from "electron"; + +import { getAppStateSnapshot } from "./app-state.js"; + +export type McpToolkitPersistentTarget = "claude-user" | "codex-global"; + +export interface McpToolkitBundle { + readonly label: string; + readonly language: "bash"; + readonly value: string; + readonly description: string; +} + +export interface McpToolkitInstallItemResult { + readonly id: string; + readonly name: string; + readonly status: "installed" | "skipped"; + readonly detail: string; +} + +export interface McpToolkitInstallResult { + readonly target: McpToolkitPersistentTarget; + readonly label: string; + readonly installed: readonly McpToolkitInstallItemResult[]; + readonly skipped: readonly McpToolkitInstallItemResult[]; + readonly notes: readonly string[]; +} + +interface McpToolkitServerDefinition { + readonly id: string; + readonly name: string; + readonly description: string; + readonly command: readonly string[]; + readonly requiredRuntime: "npx" | "uvx"; + readonly note?: string; +} + +interface CommandResult { + readonly ok: boolean; + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly error?: string; +} + +const commandTimeoutMs = 20_000; +const maxOutputChars = 6_000; + +export function buildPersistentToolkitBundle(target: McpToolkitPersistentTarget): McpToolkitBundle { + const label = target === "claude-user" + ? "Claude Code user-scope install bundle" + : "Codex CLI global install bundle"; + const description = target === "claude-user" + ? "Run once to install the supported persistent baseline into Claude Code at user scope." + : "Run once to install the supported persistent baseline into Codex CLI globally."; + const value = buildInstallCommands(target) + .map((command) => command.map(quoteShellArg).join(" ")) + .join("\n"); + return { + label, + language: "bash", + value, + description, + }; +} + +export async function installPersistentToolkit(target: McpToolkitPersistentTarget): Promise { + const label = target === "claude-user" ? "Claude Code user scope" : "Codex CLI global"; + const homeDir = app.getPath("home"); + const notes = [ + `Filesystem access is scoped to ${formatUserPath(homeDir)} by default.`, + "Git, GitHub, databases, Docker, shell, SSH, and reverse-engineering lanes still stay manual because they need repo paths, auth, or stronger trust decisions.", + ]; + + const hostCommand = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; + const hostCheck = await runCommandWithCandidates(getHostCommandCandidates(target)); + if (!hostCheck.ok) { + throw new Error(`${target === "claude-user" ? "Claude Code" : "Codex CLI"} was not found. Install it first or use Manual Setup. ${summarizeCommandFailure(hostCheck)}`); + } + + const npxCheck = await runCommandWithCandidates(getRuntimeCommandCandidates("npx")); + if (!npxCheck.ok) { + throw new Error(`Node.js and npx are required for the supported persistent baseline. Install Node.js first or use Manual Setup. ${summarizeCommandFailure(npxCheck)}`); + } + + const uvxCheck = await runCommandWithCandidates(getRuntimeCommandCandidates("uvx")); + const installableServers = getSupportedPersistentServers(); + const results: McpToolkitInstallItemResult[] = []; + + for (const server of installableServers) { + if (server.requiredRuntime === "uvx" && !uvxCheck.ok) { + results.push({ + id: server.id, + name: server.name, + status: "skipped", + detail: "Skipped because `uvx` is not available on PATH.", + }); + continue; + } + + await removeManagedServer(target, server.id); + const addResult = await runCommand(hostCommand, buildAddArgs(target, server)); + if (!addResult.ok) { + results.push({ + id: server.id, + name: server.name, + status: "skipped", + detail: `Install failed: ${summarizeCommandFailure(addResult)}`, + }); + continue; + } + + results.push({ + id: server.id, + name: server.name, + status: "installed", + detail: server.note ?? server.description, + }); + } + + return { + target, + label, + installed: results.filter((entry) => entry.status === "installed"), + skipped: results.filter((entry) => entry.status === "skipped"), + notes: uvxCheck.ok + ? notes + : [...notes, "Browser Use and Fetch / Web were skipped because `uvx` is not installed on this machine."], + }; +} + +function buildInstallCommands(target: McpToolkitPersistentTarget): readonly (readonly string[])[] { + return getSupportedPersistentServers().map((server) => { + const command = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; + return [command, ...buildAddArgs(target, server)]; + }); +} + +function getSupportedPersistentServers(): readonly McpToolkitServerDefinition[] { + const homeDir = app.getPath("home"); + return [ + { + id: "openpets-filesystem", + name: "Filesystem", + description: "Persistent filesystem access scoped to your home directory.", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", homeDir], + requiredRuntime: "npx", + note: "Scoped to your home directory by default. Narrow it later if you want a tighter boundary.", + }, + { + id: "openpets-playwright", + name: "Playwright", + description: "Browser automation and browser validation tools.", + command: ["npx", "@playwright/mcp@latest"], + requiredRuntime: "npx", + }, + { + id: "openpets-memory", + name: "Memory", + description: "Persistent project-memory tools for the host agent.", + command: ["npx", "-y", "@modelcontextprotocol/server-memory"], + requiredRuntime: "npx", + }, + { + id: "openpets-context7", + name: "Context7 / Docs", + description: "Up-to-date library and framework docs lookup.", + command: ["npx", "-y", "@upstash/context7-mcp"], + requiredRuntime: "npx", + }, + { + id: "openpets-fetch", + name: "Fetch / Web", + description: "Lightweight web retrieval without full browser automation.", + command: ["uvx", "mcp-server-fetch"], + requiredRuntime: "uvx", + }, + { + id: "openpets-sequential-thinking", + name: "Sequential Thinking", + description: "Structured planning and branching reasoning tools.", + command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"], + requiredRuntime: "npx", + }, + { + id: "openpets-browser-use", + name: "Browser Use", + description: "Higher-level browser task execution for agentic web work.", + command: ["uvx", "--from", "browser-use[cli]", "browser-use", "--mcp"], + requiredRuntime: "uvx", + note: "The entry is installed, but Browser Use still needs its own runtime credential in the host environment.", + }, + ]; +} + +function buildAddArgs(target: McpToolkitPersistentTarget, server: McpToolkitServerDefinition): readonly string[] { + const hostCommand = target === "claude-user"; + return hostCommand + ? ["mcp", "add", "--scope", "user", server.id, "--", ...server.command] + : ["mcp", "add", server.id, "--", ...server.command]; +} + +async function removeManagedServer(target: McpToolkitPersistentTarget, serverId: string): Promise { + const command = target === "claude-user" ? getPreferredClaudeCommand() : "codex"; + const args = target === "claude-user" + ? ["mcp", "remove", "--scope", "user", serverId] + : ["mcp", "remove", serverId]; + await runCommand(command, args).catch(() => undefined); +} + +function getPreferredClaudeCommand(): string { + return getAppStateSnapshot().preferences.claudeCommandPath || "claude"; +} + +function getHostCommandCandidates(target: McpToolkitPersistentTarget): readonly string[] { + if (target === "claude-user") { + return getCommandCandidates(getPreferredClaudeCommand(), "claude"); + } + return getCommandCandidates("codex", "codex"); +} + +function getRuntimeCommandCandidates(command: "npx" | "uvx"): readonly string[] { + return getCommandCandidates(command, command); +} + +function getCommandCandidates(preferred: string, baseName: string): readonly string[] { + if (preferred !== baseName) { + return preferred.toLowerCase().endsWith(".cmd") ? [preferred] : [preferred, ...(process.platform === "win32" ? [`${preferred}.cmd`] : [])]; + } + if (process.platform === "win32") return [baseName, `${baseName}.cmd`]; + return [baseName]; +} + +async function runCommandWithCandidates(candidates: readonly string[]): Promise { + let lastResult: CommandResult = { ok: false, exitCode: null, stdout: "", stderr: "", error: "Command was not found." }; + for (const candidate of candidates) { + const result = await runCommand(candidate, ["--version"]); + if (result.ok || !looksLikeMissingCommand(result)) { + return result; + } + lastResult = result; + } + return lastResult; +} + +function runCommand(command: string, args: readonly string[]): Promise { + return new Promise((resolve) => { + const shellCommand = process.platform === "win32" && command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : command; + const shellArgs = process.platform === "win32" && command.toLowerCase().endsWith(".cmd") + ? ["/d", "/s", "/c", command, ...args] + : [...args]; + + let child; + try { + child = spawn(shellCommand, shellArgs, { + cwd: app.getPath("home"), + env: createCommandEnv(), + windowsHide: true, + shell: false, + }); + } catch (error) { + resolve({ + ok: false, + exitCode: null, + stdout: "", + stderr: "", + error: error instanceof Error ? error.message : "Command failed to start.", + }); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + resolve({ + ok: false, + exitCode: null, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + error: "Command timed out.", + }); + }, commandTimeoutMs); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout = truncateOutput(stdout + chunk.toString("utf8")); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = truncateOutput(stderr + chunk.toString("utf8")); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: false, + exitCode: null, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + error: error.message, + }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: code === 0, + exitCode: code, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + }); + }); + }); +} + +function createCommandEnv(): NodeJS.ProcessEnv { + const separator = process.platform === "win32" ? ";" : ":"; + const existingPath = process.env.PATH ?? ""; + return { + ...process.env, + PATH: dedupePathEntries([existingPath, ...getExtraCommandPaths()], separator).join(separator), + }; +} + +function getExtraCommandPaths(): readonly string[] { + if (process.platform === "win32") return []; + const home = app.getPath("home"); + const env = process.env; + return filterExistingPaths([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + join(home, "bin"), + join(home, ".local", "bin"), + join(home, ".opencode", "bin"), + join(env.VOLTA_HOME || join(home, ".volta"), "bin"), + join(env.BUN_INSTALL || join(home, ".bun"), "bin"), + join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"), + join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"), + env.PNPM_HOME, + join(home, ".local", "share", "pnpm"), + join(home, "Library", "pnpm"), + join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"), + ]); +} + +function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] { + return paths.filter((path): path is string => Boolean(path && existsSync(path))); +} + +function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] { + const seen = new Set(); + const entries: string[] = []; + for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) { + if (seen.has(path)) continue; + seen.add(path); + entries.push(path); + } + return entries; +} + +function looksLikeMissingCommand(result: CommandResult): boolean { + return Boolean(result.error && /ENOENT|not found/i.test(result.error)); +} + +function summarizeCommandFailure(result: CommandResult): string { + const detail = result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`; + return truncateOutput(detail).trim() || "command failed."; +} + +function truncateOutput(value: string): string { + return value.length > maxOutputChars ? value.slice(value.length - maxOutputChars) : value; +} + +function formatUserPath(path: string): string { + return path.replace(app.getPath("home"), "~"); +} + +function quoteShellArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return JSON.stringify(value); +} diff --git a/apps/desktop/src/openapi-chat.ts b/apps/desktop/src/openapi-chat.ts new file mode 100644 index 00000000..25673c1b --- /dev/null +++ b/apps/desktop/src/openapi-chat.ts @@ -0,0 +1,1150 @@ +import { app, safeStorage } from "electron"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js"; +import { applyExternalPetReaction, applyInternalPetMessage, showDefaultPet } from "./default-pet-controller.js"; +import { info, warn } from "./logger.js"; +import type { OpenPetsReaction } from "./local-ipc-protocol.js"; +import { buildRelevantMemoryContext, capturePromptMemories } from "./openpets-memory.js"; +import { getMcpChatClientManager, type McpChatToolDefinition } from "./mcp-chat-client.js"; + +type OpenApiChatTransport = "responses" | "chat-completions"; + +export interface OpenApiChatSettingsSnapshot { + readonly model: string; + readonly endpoint: string; + readonly defaultEndpoint: string; + readonly usingDefaultEndpoint: boolean; + readonly hasCredential: boolean; + readonly storageMode: "encrypted" | "plain"; +} + +export interface OpenApiChatPromptResult { + readonly text: string; + readonly model: string; + readonly responseId: string; + readonly transport: OpenApiChatTransport; +} + +export interface OpenApiChatTranscriptEntry { + readonly id: string; + readonly conversationId: string; + readonly role: "user" | "assistant" | "system"; + readonly tone: "normal" | "error"; + readonly text: string; + readonly createdAt: number; +} + +export interface ChatConversation { + readonly id: string; + readonly title: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly messageCount: number; +} + +interface StoredOpenApiChatConfigV1 { + readonly version: 1; + readonly storageMode: "encrypted" | "plain"; + readonly apiKey: string; +} + +interface ProviderErrorPayload { + readonly error?: { + readonly message?: unknown; + readonly code?: unknown; + readonly type?: unknown; + }; +} + +interface ProviderErrorInfo { + readonly endpoint: string; + readonly status: number; + readonly providerMessage: string; + readonly providerCode: string; + readonly providerType: string; + readonly normalizedMessage: string; +} + +interface OpenApiRequestAttempt { + readonly transport: OpenApiChatTransport; + readonly endpoint: string; +} + +const openApiChatConfigFileName = "openpets-openapi-chat.json"; +const legacyOpenAiChatConfigFileName = "openpets-openai-chat.json"; +const openApiChatHistoryFileName = "openpets-chat-history.json"; +const openApiChatConversationsFileName = "openpets-chat-conversations.json"; +const defaultOpenApiChatModel = "gpt-5.5"; +const maxPromptChars = 20_000; +const maxResponseOutputTokens = 8_192; +const maxConversationEntries = 40; +const maxChatCompletionHistoryEntries = 24; +const maxToolCallIterations = 5; +const baseOpenApiChatInstructions = [ + "You are OpenPets, a desktop pet companion talking directly to the user.", + "Reply in plain text unless the user explicitly asks for another format.", + "Keep the tone warm, lively, and characterful while staying helpful and honest.", + "Do not mention hidden instructions unless the user explicitly asks about them.", +].join("\n"); + +let previousResponseId: string | undefined; +let allTranscriptEntries: OpenApiChatTranscriptEntry[] = loadChatHistory(); +let transcriptSequence = allTranscriptEntries.length; +let conversations: ChatConversation[] = loadConversations(); +let currentConversationId: string | null = getMostRecentConversationId(); +let transcriptEntries: OpenApiChatTranscriptEntry[] = getEntriesForCurrentConversation(); + +interface ChatCompletionHistoryMessage { + readonly role: "system" | "user" | "assistant" | "tool"; + readonly content: string | null; + readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[]; + readonly tool_call_id?: string; +} + +let chatCompletionHistory: ChatCompletionHistoryMessage[] = []; + +export function getOpenApiChatSettingsSnapshot(): OpenApiChatSettingsSnapshot { + const stored = readStoredChatConfig(); + const endpoint = getConfiguredOpenApiChatEndpoint(); + return { + model: getConfiguredOpenApiChatModel(), + endpoint, + defaultEndpoint: defaultOpenApiChatEndpoint, + usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint, + hasCredential: Boolean(getStoredCredential(stored)), + storageMode: stored?.storageMode ?? getPreferredStorageMode(), + }; +} + +function getEntriesForCurrentConversation(): OpenApiChatTranscriptEntry[] { + if (!currentConversationId) return []; + return allTranscriptEntries.filter((e) => e.conversationId === currentConversationId); +} + +export function getOpenApiChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] { + return getEntriesForCurrentConversation().slice(); +} + +export function getOpenApiChatSystemPrompt(): string { + return getAppStateSnapshot().preferences.openApiChatSystemPrompt?.trim() ?? ""; +} + +export function saveOpenApiCredential(credential: string): OpenApiChatSettingsSnapshot { + const trimmed = credential.trim(); + if (!trimmed) { + throw new Error("API key or token cannot be empty."); + } + if (trimmed.length > 1_024 || /[\0\r\n]/.test(trimmed)) { + throw new Error("API key or token is not valid."); + } + + const storageMode = getPreferredStorageMode(); + const serialized = storageMode === "encrypted" + ? safeStorage.encryptString(trimmed).toString("base64") + : trimmed; + + writeStoredChatConfig({ + version: 1, + storageMode, + apiKey: serialized, + }); + previousResponseId = undefined; + info("app", "openapi chat credential saved", { storageMode }); + return getOpenApiChatSettingsSnapshot(); +} + +export function clearOpenApiCredential(): OpenApiChatSettingsSnapshot { + writeStoredChatConfig({ + version: 1, + storageMode: getPreferredStorageMode(), + apiKey: "", + }); + previousResponseId = undefined; + info("app", "openapi chat credential cleared"); + return getOpenApiChatSettingsSnapshot(); +} + +export function resetOpenApiConversation(): void { + previousResponseId = undefined; + chatCompletionHistory = []; + const newConversation = createConversation("New conversation"); + currentConversationId = newConversation.id; + transcriptEntries = []; + info("app", "openapi conversation reset", { conversationId: newConversation.id }); +} + +export function getAllChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] { + return allTranscriptEntries.slice(); +} + +export function resetOpenApiConversationContext(): void { + previousResponseId = undefined; +} + +export function listConversations(): readonly ChatConversation[] { + return conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function getCurrentConversationId(): string | null { + return currentConversationId; +} + +export function createConversation(title?: string): ChatConversation { + const conversation: ChatConversation = { + id: `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: title?.trim() || "New conversation", + createdAt: Date.now(), + updatedAt: Date.now(), + messageCount: 0, + }; + conversations = [conversation, ...conversations].slice(0, 100); + saveConversations(); + return conversation; +} + +export function switchConversation(conversationId: string): void { + if (!conversations.some((c) => c.id === conversationId)) { + throw new Error("Conversation not found."); + } + currentConversationId = conversationId; + transcriptEntries = getEntriesForCurrentConversation(); + chatCompletionHistory = []; + previousResponseId = undefined; + info("app", "openapi conversation switched", { conversationId }); +} + +export function deleteConversation(conversationId: string): void { + const before = conversations.length; + conversations = conversations.filter((c) => c.id !== conversationId); + if (conversations.length === before) { + throw new Error("Conversation not found."); + } + // Remove messages for this conversation + allTranscriptEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId); + saveChatHistory(); + saveConversations(); + if (currentConversationId === conversationId) { + currentConversationId = conversations[0]?.id ?? null; + transcriptEntries = getEntriesForCurrentConversation(); + chatCompletionHistory = []; + previousResponseId = undefined; + } + info("app", "openapi conversation deleted", { conversationId }); +} + +function updateConversationMessageCount(conversationId: string): void { + const count = allTranscriptEntries.filter((e) => e.conversationId === conversationId).length; + conversations = conversations.map((c) => + c.id === conversationId ? { ...c, messageCount: count, updatedAt: Date.now() } : c + ); + saveConversations(); +} + +function getMostRecentConversationId(): string | null { + const sorted = conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt); + return sorted[0]?.id ?? null; +} + +function autoTitleConversation(conversationId: string, text: string): void { + const conversation = conversations.find((c) => c.id === conversationId); + if (!conversation || conversation.title !== "New conversation") return; + const trimmed = text.trim(); + if (!trimmed) return; + const title = trimmed.length > 36 ? `${trimmed.slice(0, 36).trim()}…` : trimmed; + conversations = conversations.map((c) => (c.id === conversationId ? { ...c, title } : c)); + saveConversations(); +} + +function getVanillaChatMcpToolsEnabled(): readonly string[] { + return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? []; +} + +function isVanillaChatMcpEnabled(): boolean { + return getVanillaChatMcpToolsEnabled().length > 0; +} + +export async function sendOpenApiChatPrompt(prompt: string): Promise { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + throw new Error("Type a prompt before sending."); + } + if (trimmedPrompt.length > maxPromptChars) { + throw new Error(`Prompt is too long. Keep it under ${maxPromptChars} characters.`); + } + + capturePromptMemories(trimmedPrompt); + appendTranscriptEntry("user", trimmedPrompt); + + if (isVanillaChatMcpEnabled()) { + return sendOpenApiChatPromptWithTools(trimmedPrompt); + } + + return sendOpenApiChatPromptPlain(trimmedPrompt); +} + +async function sendOpenApiChatPromptPlain(prompt: string): Promise { + const credential = getRequiredCredential(); + const model = getConfiguredOpenApiChatModel(); + const endpoint = getConfiguredOpenApiChatEndpoint(); + const previousId = previousResponseId; + + showOpenApiPendingState(); + info("app", "openapi prompt submitted", { + model, + endpointHost: getEndpointHost(endpoint), + usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint, + promptLength: prompt.length, + hasPreviousResponse: Boolean(previousId), + }); + + const attempts = buildRequestAttempts(endpoint); + let lastProviderError: ProviderErrorInfo | undefined; + let lastResponseTextError: string | undefined; + + for (let index = 0; index < attempts.length; index += 1) { + const attempt = attempts[index]; + let response: Response; + try { + response = await fetch(attempt.endpoint, { + method: "POST", + headers: buildOpenApiRequestHeaders(attempt.endpoint, credential), + body: JSON.stringify(buildRequestBody(attempt.transport, { + model, + prompt, + previousResponseId: attempt.transport === "responses" ? previousId : undefined, + })), + }); + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : "The OpenAPI chat request failed before a response was received."; + handleOpenApiFailure(message, { + model, + endpointHost: getEndpointHost(attempt.endpoint), + transport: attempt.transport, + hasPreviousResponse: Boolean(previousId), + failureKind: "network", + }); + throw new Error(message); + } + + if (!response.ok) { + const providerError = await readProviderError(response, attempt.endpoint); + if (shouldTryChatCompletionsFallback(attempt, providerError) && hasNextAttempt(attempts, index)) { + lastProviderError = providerError; + continue; + } + + handleOpenApiFailure(providerError.normalizedMessage, { + model, + endpointHost: getEndpointHost(attempt.endpoint), + transport: attempt.transport, + status: response.status, + hasPreviousResponse: Boolean(previousId), + failureKind: "http", + }); + throw new Error(providerError.normalizedMessage); + } + + const payload = await response.json() as Record; + const parsed = attempt.transport === "responses" + ? parseResponsesPayload(payload) + : parseChatCompletionsPayload(payload); + + if (!parsed.text) { + if (attempt.transport === "responses" && payloadHasChatCompletionsShape(payload) && hasNextAttempt(attempts, index)) { + lastResponseTextError = "The provider returned a chat-completions style payload from the responses route."; + continue; + } + + const message = attempt.transport === "responses" + ? "The provider returned an empty chat response." + : "The provider returned an empty chat-completions response."; + handleOpenApiFailure(message, { + model, + endpointHost: getEndpointHost(attempt.endpoint), + transport: attempt.transport, + hasPreviousResponse: Boolean(previousId), + failureKind: "empty", + }); + throw new Error(message); + } + + const responseId = parsed.responseId ?? `response-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + previousResponseId = attempt.transport === "responses" ? parsed.responseId : undefined; + appendTranscriptEntry("assistant", parsed.text); + showOpenApiSuccessState(parsed.text); + info("app", "openapi prompt completed", { + model, + endpointHost: getEndpointHost(attempt.endpoint), + transport: attempt.transport, + textLength: parsed.text.length, + responseId, + carriedResponseId: Boolean(previousResponseId), + hadFallbackProviderError: Boolean(lastProviderError), + hadFallbackPayloadMismatch: Boolean(lastResponseTextError), + }); + return { + text: parsed.text, + model, + responseId, + transport: attempt.transport, + }; + } + + const fallbackMessage = lastProviderError?.normalizedMessage + ?? lastResponseTextError + ?? "The provider could not produce a usable reply."; + handleOpenApiFailure(fallbackMessage, { + model, + endpointHost: getEndpointHost(endpoint), + hasPreviousResponse: Boolean(previousId), + failureKind: "exhausted-attempts", + }); + throw new Error(fallbackMessage); +} + +function getConfiguredOpenApiChatModel(): string { + return normalizeChatModel(getAppStateSnapshot().preferences.openApiChatModel) ?? defaultOpenApiChatModel; +} + +function getConfiguredOpenApiChatEndpoint(): string { + return normalizeOpenApiChatEndpoint(getAppStateSnapshot().preferences.openApiChatEndpoint) ?? defaultOpenApiChatEndpoint; +} + +function buildOpenApiInstructions(prompt: string): string { + const parts: string[] = []; + if (getAppStateSnapshot().preferences.openApiChatBaseInstructionsEnabled !== false) { + parts.push(baseOpenApiChatInstructions); + } + const customCharacterPrompt = getOpenApiChatSystemPrompt(); + if (customCharacterPrompt) { + parts.push(`User-defined pet character instructions:\n${customCharacterPrompt}`); + } + const chatHistoryForMemory = allTranscriptEntries + .filter((e) => e.conversationId !== currentConversationId && e.role !== "system") + .slice(-60) + .map((e) => ({ text: e.text, role: e.role, createdAt: e.createdAt })); + const memoryContext = buildRelevantMemoryContext(prompt, chatHistoryForMemory); + if (memoryContext) { + parts.push(memoryContext); + } + return parts.join("\n\n"); +} + +export function normalizeChatModel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined; + return trimmed; +} + +function buildRequestAttempts(endpoint: string): readonly OpenApiRequestAttempt[] { + const url = safeParseUrl(endpoint); + if (!url) { + return [{ transport: "responses", endpoint }]; + } + + const normalizedPath = url.pathname.replace(/\/+$/, ""); + if (normalizedPath.endsWith("/chat/completions")) { + return [{ transport: "chat-completions", endpoint: url.toString() }]; + } + if (normalizedPath.endsWith("/responses")) { + if (isOfficialOpenAiEndpoint(endpoint) || isAzureOpenAiEndpoint(endpoint)) { + return [{ transport: "responses", endpoint: url.toString() }]; + } + return dedupeAttempts([ + { transport: "responses", endpoint: url.toString() }, + { transport: "chat-completions", endpoint: withPathname(url, replaceTransportPath(normalizedPath, "chat-completions")) }, + ]); + } + if (normalizedPath.endsWith("/v1")) { + return dedupeAttempts([ + { transport: "responses", endpoint: withPathname(url, `${normalizedPath}/responses`) }, + { transport: "chat-completions", endpoint: withPathname(url, `${normalizedPath}/chat/completions`) }, + ]); + } + + return [{ transport: "responses", endpoint: url.toString() }]; +} + +function dedupeAttempts(attempts: readonly OpenApiRequestAttempt[]): readonly OpenApiRequestAttempt[] { + const seen = new Set(); + const deduped: OpenApiRequestAttempt[] = []; + for (const attempt of attempts) { + const key = `${attempt.transport}:${attempt.endpoint}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(attempt); + } + return deduped; +} + +function withPathname(url: URL, pathname: string): string { + const clone = new URL(url.toString()); + clone.pathname = pathname; + return clone.toString(); +} + +function replaceTransportPath(pathname: string, transport: OpenApiChatTransport): string { + const suffix = transport === "responses" ? "/responses" : "/chat/completions"; + return pathname.replace(/\/(?:responses|chat\/completions)$/, suffix); +} + +function buildRequestBody(transport: OpenApiChatTransport, input: { + readonly model: string; + readonly prompt: string; + readonly previousResponseId?: string; +}): Record { + if (transport === "responses") { + return { + model: input.model, + instructions: buildOpenApiInstructions(input.prompt), + previous_response_id: input.previousResponseId, + max_output_tokens: maxResponseOutputTokens, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: input.prompt, + }, + ], + }, + ], + }; + } + + return { + model: input.model, + max_tokens: maxResponseOutputTokens, + messages: buildChatCompletionsMessages(input.prompt), + }; +} + +function buildChatCompletionsMessages(prompt: string): readonly { readonly role: "system" | "user" | "assistant"; readonly content: string }[] { + const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [ + { + role: "system", + content: buildOpenApiInstructions(prompt), + }, + ]; + + const history = transcriptEntries + .filter((entry) => entry.role === "user" || entry.role === "assistant") + .slice(-maxChatCompletionHistoryEntries); + + for (const entry of history) { + messages.push({ + role: entry.role, + content: entry.text, + }); + } + + return messages; +} + +function parseResponsesPayload(payload: Record): { readonly text: string; readonly responseId?: string } { + const text = extractResponsesOutputText(payload).trim(); + const responseId = typeof payload.id === "string" && payload.id ? payload.id : undefined; + return { text, responseId }; +} + +function parseChatCompletionsPayload(payload: Record): { readonly text: string; readonly responseId: string } { + const text = extractChatCompletionsText(payload).trim(); + const responseId = typeof payload.id === "string" && payload.id + ? payload.id + : `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return { text, responseId }; +} + +function buildOpenApiRequestHeaders(endpoint: string, credential: string): Record { + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${credential}`, + }; + if (isAzureOpenAiEndpoint(endpoint)) { + headers["api-key"] = credential; + } else if (!isOfficialOpenAiEndpoint(endpoint)) { + headers["x-api-key"] = credential; + } + return headers; +} + +function isAzureOpenAiEndpoint(endpoint: string): boolean { + const url = safeParseUrl(endpoint); + return Boolean(url && url.hostname.endsWith(".openai.azure.com")); +} + +function isOfficialOpenAiEndpoint(endpoint: string): boolean { + const url = safeParseUrl(endpoint); + return Boolean(url && url.hostname === "api.openai.com"); +} + +function safeParseUrl(endpoint: string): URL | undefined { + try { + return new URL(endpoint); + } catch { + return undefined; + } +} + +function getEndpointHost(endpoint: string): string { + return safeParseUrl(endpoint)?.host ?? "unknown"; +} + +function getRequiredCredential(): string { + const credential = getStoredCredential(readStoredChatConfig()); + if (!credential) { + throw new Error("Add your API key or token in Settings before sending a prompt."); + } + return credential; +} + +function getStoredCredential(stored: StoredOpenApiChatConfigV1 | null): string | undefined { + if (!stored?.apiKey) return undefined; + if (stored.storageMode === "encrypted") { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("Encrypted chat credentials are unavailable on this machine right now. Re-save the key in Settings."); + } + return safeStorage.decryptString(Buffer.from(stored.apiKey, "base64")); + } + return stored.apiKey; +} + +function getPreferredStorageMode(): "encrypted" | "plain" { + return safeStorage.isEncryptionAvailable() ? "encrypted" : "plain"; +} + +function getChatConfigPath(fileName = openApiChatConfigFileName): string { + return join(app.getPath("userData"), fileName); +} + +function readStoredChatConfig(): StoredOpenApiChatConfigV1 | null { + for (const path of [getChatConfigPath(), getChatConfigPath(legacyOpenAiChatConfigFileName)]) { + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(raw) || raw.version !== 1) continue; + const storageMode = raw.storageMode === "encrypted" || raw.storageMode === "plain" ? raw.storageMode : "plain"; + const apiKey = typeof raw.apiKey === "string" ? raw.apiKey : ""; + return { + version: 1, + storageMode, + apiKey, + }; + } catch { + // Keep checking fallbacks. + } + } + return null; +} + +function writeStoredChatConfig(config: StoredOpenApiChatConfigV1): void { + const path = getChatConfigPath(); + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + renameSync(tempPath, path); + + const legacyPath = getChatConfigPath(legacyOpenAiChatConfigFileName); + if (existsSync(legacyPath)) { + rmSync(legacyPath, { force: true }); + } +} + +async function readProviderError(response: Response, endpoint: string): Promise { + let providerMessage = ""; + let providerCode = ""; + let providerType = ""; + try { + const payload = await response.json() as ProviderErrorPayload; + providerMessage = typeof payload?.error?.message === "string" ? payload.error.message : ""; + providerCode = typeof payload?.error?.code === "string" ? payload.error.code : ""; + providerType = typeof payload?.error?.type === "string" ? payload.error.type : ""; + } catch { + // Fall through to a generic message. + } + return { + endpoint, + status: response.status, + providerMessage, + providerCode, + providerType, + normalizedMessage: normalizeProviderErrorMessage({ + endpoint, + status: response.status, + providerMessage, + providerCode, + providerType, + }), + }; +} + +function shouldTryChatCompletionsFallback(attempt: OpenApiRequestAttempt, errorInfo: ProviderErrorInfo): boolean { + if (attempt.transport !== "responses") return false; + if (isOfficialOpenAiEndpoint(attempt.endpoint) || isAzureOpenAiEndpoint(attempt.endpoint)) return false; + + const normalizedProviderMessage = sanitizeProviderMessage(errorInfo.providerMessage); + const haystack = `${errorInfo.providerCode} ${errorInfo.providerType} ${normalizedProviderMessage}`.toLowerCase(); + if (errorInfo.status === 401 || errorInfo.status === 403 || errorInfo.status === 429) return false; + if (haystack.includes("authentication header") || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) return false; + if (haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) return false; + + if (errorInfo.status === 404 || errorInfo.status === 405) return true; + if (errorInfo.status === 400 || errorInfo.status === 415 || errorInfo.status === 422 || errorInfo.status === 501) { + return [ + "unknown parameter", + "unknown url", + "unsupported", + "unsupported route", + "not found", + "no route", + "responses", + "input", + "instructions", + "previous_response_id", + "max_output_tokens", + "invalid_request_error", + "chat/completions", + ].some((needle) => haystack.includes(needle)); + } + return false; +} + +function hasNextAttempt(attempts: readonly OpenApiRequestAttempt[], index: number): boolean { + return index < attempts.length - 1; +} + +function normalizeProviderErrorMessage(input: { + readonly endpoint: string; + readonly status: number; + readonly providerMessage: string; + readonly providerCode: string; + readonly providerType: string; +}): string { + const providerName = getEndpointProviderLabel(input.endpoint); + const normalizedProviderMessage = sanitizeProviderMessage(input.providerMessage); + const haystack = `${input.providerCode} ${input.providerType} ${normalizedProviderMessage}`.toLowerCase(); + + if (haystack.includes("missing authentication header") || haystack.includes("authentication header")) { + return `${providerName} says the request arrived without usable authentication. Re-save the credential in Settings after choosing the matching preset or endpoint.`; + } + + if (input.status === 401 || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) { + return `${providerName} rejected the saved credential. Re-save it in Settings and confirm the preset matches the endpoint.`; + } + + if (isOfficialOpenAiEndpoint(input.endpoint) && (haystack.includes("insufficient_quota") || haystack.includes("current quota") || haystack.includes("billing"))) { + return "OpenAI is reporting an API billing or quota problem for this key. ChatGPT app subscriptions do not automatically cover API usage."; + } + + if (haystack.includes("tokens exhausted") || haystack.includes("insufficient_quota") || haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) { + return `${providerName} is reporting an account quota, credit, or billing problem for this credential, not a prompt-length issue.`; + } + + if (input.status === 429 || haystack.includes("rate limit")) { + return `${providerName} rate-limited this request. Wait a moment and try again.`; + } + + if (normalizedProviderMessage) { + return normalizedProviderMessage; + } + + return `${providerName} request failed with HTTP ${input.status}.`; +} + +function getEndpointProviderLabel(endpoint: string): string { + if (isAzureOpenAiEndpoint(endpoint)) return "Azure OpenAI"; + + const url = safeParseUrl(endpoint); + if (!url) return "OpenAPI provider"; + if (url.hostname === "api.openai.com") return "OpenAI"; + if (url.hostname === "openrouter.ai") return "OpenRouter"; + if (url.hostname === "api.moonshot.cn") return "Moonshot"; + if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1") return "Local OpenAPI provider"; + return "OpenAPI provider"; +} + +function sanitizeProviderMessage(message: string): string { + return message + .replace(/\s+/g, " ") + .replace(/\s*For more information on this error, read the docs:.*$/i, "") + .trim(); +} + +function extractResponsesOutputText(payload: Record): string { + if (typeof payload.output_text === "string") return payload.output_text; + const output = Array.isArray(payload.output) ? payload.output : []; + const parts: string[] = []; + for (const item of output) { + if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue; + for (const contentPart of item.content) { + if (!isRecord(contentPart) || contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue; + parts.push(contentPart.text); + } + } + return parts.join(""); +} + +function extractChatCompletionsText(payload: Record): string { + const choices = Array.isArray(payload.choices) ? payload.choices : []; + const parts: string[] = []; + + for (const choice of choices) { + if (!isRecord(choice) || !isRecord(choice.message)) continue; + const content = choice.message.content; + if (typeof content === "string") { + parts.push(content); + continue; + } + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!isRecord(part)) continue; + if (typeof part.text === "string") { + parts.push(part.text); + } else if (isRecord(part.text) && typeof part.text.value === "string") { + parts.push(part.text.value); + } + } + } + + return parts.join(""); +} + +function payloadHasChatCompletionsShape(payload: Record): boolean { + return Array.isArray(payload.choices); +} + +function showOpenApiPendingState(): void { + showDefaultPet(); + applyExternalPetReaction("thinking"); +} + +function showOpenApiSuccessState(message: string): void { + applyOpenApiPetMessage(message, "success"); +} + +function showOpenApiFailureState(message: string): void { + applyOpenApiPetMessage(message, "error"); +} + +function handleOpenApiFailure(message: string, fields: Record): void { + appendTranscriptEntry("system", message, "error"); + showOpenApiFailureState(message); + warn("app", "openapi prompt failed", fields); +} + +function applyOpenApiPetMessage(message: string, reaction: OpenPetsReaction): void { + applyInternalPetMessage(message, { + reaction, + fullMessage: true, + sticky: true, + }); +} + +function appendTranscriptEntry(role: OpenApiChatTranscriptEntry["role"], text: string, tone: OpenApiChatTranscriptEntry["tone"] = "normal"): void { + if (!currentConversationId) { + const conv = createConversation(); + currentConversationId = conv.id; + } + const conversationId = currentConversationId; + const entry: OpenApiChatTranscriptEntry = { + id: `chat-${Date.now()}-${++transcriptSequence}`, + conversationId, + role, + tone, + text, + createdAt: Date.now(), + }; + // Add entry and cap per-conversation + const otherEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId); + const convEntries = allTranscriptEntries.filter((e) => e.conversationId === conversationId); + allTranscriptEntries = [...otherEntries, ...convEntries.slice(-(maxConversationEntries - 1)), entry]; + transcriptEntries = getEntriesForCurrentConversation(); + saveChatHistory(); + updateConversationMessageCount(conversationId); + if (role === "user") { + autoTitleConversation(conversationId, text); + } +} + +async function sendOpenApiChatPromptWithTools(prompt: string): Promise { + const credential = getRequiredCredential(); + const model = getConfiguredOpenApiChatModel(); + const endpoint = getConfiguredOpenApiChatEndpoint(); + + showOpenApiPendingState(); + info("app", "openapi prompt with tools submitted", { + model, + endpointHost: getEndpointHost(endpoint), + promptLength: prompt.length, + }); + + const manager = getMcpChatClientManager(); + const enabledTools = getVanillaChatMcpToolsEnabled(); + + try { + await manager.startEnabledServers(enabledTools); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn("app", "Failed to start some MCP servers for vanilla chat", { message }); + } + + const mcpTools = manager.listTools(); + if (mcpTools.length === 0) { + warn("app", "No MCP tools available; falling back to plain chat"); + return sendOpenApiChatPromptPlain(prompt); + } + + const chatEndpoint = forceChatCompletionsEndpoint(endpoint); + + const systemContent = buildOpenApiInstructionsWithTools(prompt, mcpTools); + if (chatCompletionHistory.length === 0 || chatCompletionHistory[0].role !== "system") { + chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory]; + } else { + chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory.slice(1)]; + } + chatCompletionHistory.push({ role: "user", content: prompt }); + chatCompletionHistory = chatCompletionHistory.slice(-maxChatCompletionHistoryEntries); + + let finalText = ""; + let responseId = ""; + let iteration = 0; + + while (iteration < maxToolCallIterations) { + iteration += 1; + const messages = chatCompletionHistory.map((m) => ({ + role: m.role, + content: m.content, + ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + })); + + let response: Response; + try { + response = await fetch(chatEndpoint, { + method: "POST", + headers: buildOpenApiRequestHeaders(chatEndpoint, credential), + body: JSON.stringify({ + model, + max_tokens: maxResponseOutputTokens, + messages, + tools: mcpTools, + tool_choice: "auto", + }), + }); + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : "The OpenAPI chat request failed before a response was received."; + handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "network" }); + throw new Error(message); + } + + if (!response.ok) { + const providerError = await readProviderError(response, chatEndpoint); + handleOpenApiFailure(providerError.normalizedMessage, { + model, + endpointHost: getEndpointHost(chatEndpoint), + status: response.status, + failureKind: "http", + }); + throw new Error(providerError.normalizedMessage); + } + + const payload = await response.json() as Record; + const choice = extractChatCompletionsChoice(payload); + + if (!choice) { + const message = "The provider returned an empty chat-completions response."; + handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" }); + throw new Error(message); + } + + responseId = typeof payload.id === "string" && payload.id ? payload.id : `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { + chatCompletionHistory.push({ + role: "assistant", + content: choice.message.content ?? null, + tool_calls: choice.message.tool_calls, + }); + + for (const toolCall of choice.message.tool_calls) { + let toolResultText: string; + try { + const args = JSON.parse(toolCall.function.arguments) as unknown; + toolResultText = await manager.callTool(toolCall.function.name, args); + } catch (err) { + toolResultText = `Error executing tool ${toolCall.function.name}: ${err instanceof Error ? err.message : String(err)}`; + } + chatCompletionHistory.push({ + role: "tool", + tool_call_id: toolCall.id, + content: toolResultText, + }); + } + continue; + } + + finalText = choice.message.content ?? ""; + chatCompletionHistory.push({ + role: "assistant", + content: choice.message.content ?? null, + }); + break; + } + + if (!finalText) { + const message = "The provider could not produce a usable reply after tool execution."; + handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" }); + throw new Error(message); + } + + appendTranscriptEntry("assistant", finalText); + showOpenApiSuccessState(finalText); + info("app", "openapi prompt with tools completed", { + model, + endpointHost: getEndpointHost(chatEndpoint), + textLength: finalText.length, + responseId, + toolIterations: iteration, + }); + + return { + text: finalText, + model, + responseId, + transport: "chat-completions", + }; +} + +function forceChatCompletionsEndpoint(endpoint: string): string { + if (endpoint.endsWith("/chat/completions")) return endpoint; + if (endpoint.endsWith("/responses")) return endpoint.replace(/\/responses$/, "/chat/completions"); + if (endpoint.endsWith("/v1")) return `${endpoint}/chat/completions`; + return `${endpoint.replace(/\/+$/, "")}/chat/completions`; +} + +function buildOpenApiInstructionsWithTools(prompt: string, tools: McpChatToolDefinition[]): string { + const toolNames = tools.map((t) => t.function.name).join(", "); + const base = buildOpenApiInstructions(prompt); + return `${base}\n\nYou have access to the following tools: ${toolNames}. When you need to use a tool, respond with a tool call. The system will execute it and return the result. Do not make up tool results.`; +} + +interface ChatCompletionChoice { + readonly message: { + readonly content?: string | null; + readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[]; + }; +} + +function extractChatCompletionsChoice(payload: Record): ChatCompletionChoice | undefined { + const choices = Array.isArray(payload.choices) ? payload.choices : []; + const first = choices[0]; + if (!first || typeof first !== "object") return undefined; + const message = (first as Record).message; + if (!message || typeof message !== "object") return undefined; + const msg = message as Record; + const content = typeof msg.content === "string" ? msg.content : null; + const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + type ToolCallItem = NonNullable extends readonly (infer T)[] ? T : never; + const tool_calls = rawToolCalls.map((tc): ToolCallItem | null => { + if (!tc || typeof tc !== "object") return null; + const t = tc as Record; + const id = typeof t.id === "string" ? t.id : ""; + const type: "function" = t.type === "function" ? "function" : "function"; + const fn = t.function; + if (!fn || typeof fn !== "object") return null; + const f = fn as Record; + const name = typeof f.name === "string" ? f.name : ""; + const args = typeof f.arguments === "string" ? f.arguments : ""; + if (!id || !name) return null; + return { id, type, function: { name, arguments: args } }; + }).filter((tc): tc is NonNullable => tc !== null); + + return { message: { content, ...(tool_calls.length > 0 ? { tool_calls } : {}) } }; +} + +function loadChatHistory(): OpenApiChatTranscriptEntry[] { + try { + const path = getChatConfigPath(openApiChatHistoryFileName); + if (!existsSync(path)) return []; + const raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!Array.isArray(raw)) return []; + const hasConversationId = raw.some((entry) => isRecord(entry) && typeof entry.conversationId === "string"); + if (!hasConversationId && raw.length > 0) { + // Migrate old format: wrap all entries in a default conversation + const defaultConv = createConversation("Previous chats"); + return raw.filter((entry): entry is OpenApiChatTranscriptEntry => + isRecord(entry) && + typeof entry.id === "string" && + (entry.role === "user" || entry.role === "assistant" || entry.role === "system") && + typeof entry.text === "string" && + (entry.tone === "normal" || entry.tone === "error") && + typeof entry.createdAt === "number" + ).map((entry) => ({ ...entry, conversationId: defaultConv.id })); + } + return raw.filter((entry): entry is OpenApiChatTranscriptEntry => + isRecord(entry) && + typeof entry.id === "string" && + typeof entry.conversationId === "string" && + (entry.role === "user" || entry.role === "assistant" || entry.role === "system") && + typeof entry.text === "string" && + (entry.tone === "normal" || entry.tone === "error") && + typeof entry.createdAt === "number" + ); + } catch { + return []; + } +} + +function saveChatHistory(): void { + try { + const path = getChatConfigPath(openApiChatHistoryFileName); + const data = allTranscriptEntries.slice(-1000); // Cap global store + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8"); + renameSync(tempPath, path); + } catch { + // ignore + } +} + +function loadConversations(): ChatConversation[] { + try { + const path = getChatConfigPath(openApiChatConversationsFileName); + if (!existsSync(path)) return []; + const raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(raw) || !Array.isArray(raw.conversations)) return []; + return raw.conversations.filter((c): c is ChatConversation => + isRecord(c) && + typeof c.id === "string" && + typeof c.title === "string" && + typeof c.createdAt === "number" && + typeof c.updatedAt === "number" && + typeof c.messageCount === "number" + ); + } catch { + return []; + } +} + +function saveConversations(): void { + try { + const path = getChatConfigPath(openApiChatConversationsFileName); + const data = { version: 1, conversations }; + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8"); + renameSync(tempPath, path); + } catch { + // ignore + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/openpets-memory.ts b/apps/desktop/src/openpets-memory.ts new file mode 100644 index 00000000..d4b161f5 --- /dev/null +++ b/apps/desktop/src/openpets-memory.ts @@ -0,0 +1,564 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { app } from "electron"; + +import type { OpenPetsMemoryKind } from "./local-ipc-protocol.js"; +import { extractPromptMemoryCandidates } from "./prompt-memory-extraction.js"; + +export interface OpenPetsMemoryEntry { + readonly id: string; + readonly text: string; + readonly kind: OpenPetsMemoryKind; + readonly tags: readonly string[]; + readonly importance: number; + readonly createdAt: number; + readonly updatedAt: number; + readonly lastAccessedAt: number; + readonly accessCount: number; + readonly source: "chat" | "mcp"; +} + +export interface OpenPetsMemorySearchHit { + readonly entry: OpenPetsMemoryEntry; + readonly score: number; +} + +interface StoredOpenPetsMemoryV1 { + readonly version: 1; + readonly entries: readonly OpenPetsMemoryEntry[]; +} + +interface StoreMemoryInput { + readonly text: string; + readonly kind?: OpenPetsMemoryKind; + readonly tags?: readonly string[]; + readonly importance?: number; + readonly source?: "chat" | "mcp"; +} + +interface ExtractedMemoryCandidate { + readonly text: string; + readonly kind: OpenPetsMemoryKind; + readonly tags: readonly string[]; + readonly importance: number; +} + +const memoryStoreFileName = "openpets-memory.json"; +const memoryMarkdownMirrorFileName = "openpets-memory.md"; +const maxMemoryEntries = 200; +const maxMemoryTextChars = 480; +const maxMemoryTags = 8; +const defaultMemoryImportance = 3; +const maxRelevantMemoryChars = 1_600; +const maxRelevantMemoryItems = 6; + +let cachedMemoryStore: StoredOpenPetsMemoryV1 | null = null; + +export function listOpenPetsMemories(limit = 20): readonly OpenPetsMemoryEntry[] { + const nextLimit = clampMemoryLimit(limit, 20); + const entries = [...getMemoryStore().entries] + .sort((left, right) => right.lastAccessedAt - left.lastAccessedAt || right.updatedAt - left.updatedAt || right.importance - left.importance || left.id.localeCompare(right.id)) + .slice(0, nextLimit); + return entries.map(cloneMemoryEntry); +} + +export function searchOpenPetsMemories(query: string, limit = 8): readonly OpenPetsMemorySearchHit[] { + const trimmedQuery = normalizeMemoryText(query); + const nextLimit = clampMemoryLimit(limit, 8); + if (!trimmedQuery) { + return listOpenPetsMemories(nextLimit).map((entry) => ({ entry, score: 0 })); + } + + const now = Date.now(); + const queryTokens = tokenize(trimmedQuery); + const normalizedQuery = normalizeMemoryComparisonValue(trimmedQuery); + const hits = getMemoryStore().entries + .map((entry) => ({ entry, score: scoreMemoryEntry(entry, queryTokens, normalizedQuery, now) })) + .filter((hit) => hit.score > 0) + .sort((left, right) => right.score - left.score || right.entry.updatedAt - left.entry.updatedAt || left.entry.id.localeCompare(right.entry.id)) + .slice(0, nextLimit); + + if (hits.length > 0) { + touchMemoryEntries(hits.map((hit) => hit.entry.id), now); + } + + return hits.map((hit) => ({ + entry: cloneMemoryEntry(getMemoryEntryById(hit.entry.id) ?? hit.entry), + score: Math.round(hit.score * 100) / 100, + })); +} + +interface ChatHistorySearchHit { + readonly entry: ChatHistorySearchEntry; + readonly score: number; +} + +function searchChatHistory(query: string, entries: readonly ChatHistorySearchEntry[], limit = 6): readonly ChatHistorySearchHit[] { + const trimmedQuery = normalizeMemoryText(query); + if (!trimmedQuery) return []; + const queryTokens = tokenize(trimmedQuery); + const normalizedQuery = normalizeMemoryComparisonValue(trimmedQuery); + const now = Date.now(); + + return entries + .map((entry) => ({ + entry, + score: scoreChatHistoryEntry(entry, queryTokens, normalizedQuery, now), + })) + .filter((hit) => hit.score > 0) + .sort((left, right) => right.score - left.score || right.entry.createdAt - left.entry.createdAt) + .slice(0, limit) + .map((hit) => ({ entry: hit.entry, score: Math.round(hit.score * 100) / 100 })); +} + +function scoreChatHistoryEntry(entry: ChatHistorySearchEntry, queryTokens: Set, normalizedQuery: string, now: number): number { + const normalizedText = normalizeMemoryComparisonValue(entry.text); + if (!normalizedText) return 0; + const textTokens = Array.from(tokenize(entry.text)); + const queryTokensArray = Array.from(queryTokens); + const commonTokens = textTokens.filter((token) => queryTokensArray.includes(token)); + if (commonTokens.length === 0) return 0; + const tokenOverlap = commonTokens.length / Math.max(1, queryTokensArray.length); + const exactMatchBonus = normalizedText.includes(normalizedQuery) ? 0.4 : 0; + const lengthPenalty = Math.max(0, 1 - entry.text.length / 400); + const ageMs = now - entry.createdAt; + const recencyBonus = Math.max(0, 1 - ageMs / (30 * 24 * 60 * 60 * 1000)) * 0.2; + return tokenOverlap * 0.6 + exactMatchBonus + lengthPenalty * 0.2 + recencyBonus; +} + +export function storeOpenPetsMemory(input: StoreMemoryInput): OpenPetsMemoryEntry { + const text = normalizeMemoryText(input.text); + if (!text) { + throw new Error("Memory text cannot be empty."); + } + + const kind = normalizeMemoryKind(input.kind); + const tags = normalizeMemoryTags(input.tags); + const importance = normalizeMemoryImportance(input.importance); + const source = input.source === "mcp" ? "mcp" : "chat"; + const now = Date.now(); + const normalizedText = normalizeMemoryComparisonValue(text); + const store = getMemoryStore(); + const existing = store.entries.find((entry) => normalizeMemoryComparisonValue(entry.text) === normalizedText); + if (existing) { + const mergedEntry: OpenPetsMemoryEntry = { + ...existing, + text, + kind: pickPreferredMemoryKind(existing.kind, kind), + tags: mergeMemoryTags(existing.tags, tags), + importance: Math.max(existing.importance, importance), + updatedAt: now, + lastAccessedAt: now, + accessCount: existing.accessCount + 1, + source, + }; + commitMemoryStore({ + version: 1, + entries: pruneMemoryEntries(store.entries.map((entry) => entry.id === existing.id ? mergedEntry : entry)), + }); + return cloneMemoryEntry(mergedEntry); + } + + const nextEntry: OpenPetsMemoryEntry = { + id: `mem-${randomUUID()}`, + text, + kind, + tags, + importance, + createdAt: now, + updatedAt: now, + lastAccessedAt: now, + accessCount: 1, + source, + }; + commitMemoryStore({ + version: 1, + entries: pruneMemoryEntries([...store.entries, nextEntry]), + }); + return cloneMemoryEntry(nextEntry); +} + +export function updateOpenPetsMemory(id: string, input: StoreMemoryInput): OpenPetsMemoryEntry | null { + const trimmedId = typeof id === "string" ? id.trim() : ""; + if (!trimmedId) { + throw new Error("Memory id cannot be empty."); + } + const text = normalizeMemoryText(input.text); + if (!text) { + throw new Error("Memory text cannot be empty."); + } + const kind = normalizeMemoryKind(input.kind); + const tags = normalizeMemoryTags(input.tags); + const importance = normalizeMemoryImportance(input.importance); + const store = getMemoryStore(); + const index = store.entries.findIndex((entry) => entry.id === trimmedId); + if (index < 0) return null; + const existing = store.entries[index]; + const updated: OpenPetsMemoryEntry = { + ...existing, + text, + kind, + tags, + importance, + updatedAt: Date.now(), + }; + commitMemoryStore({ + version: 1, + entries: store.entries.map((entry) => entry.id === trimmedId ? updated : entry), + }); + return cloneMemoryEntry(updated); +} + +export function forgetOpenPetsMemory(id: string): boolean { + const trimmedId = typeof id === "string" ? id.trim() : ""; + if (!trimmedId) { + throw new Error("Memory id cannot be empty."); + } + const store = getMemoryStore(); + const nextEntries = store.entries.filter((entry) => entry.id !== trimmedId); + if (nextEntries.length === store.entries.length) { + return false; + } + commitMemoryStore({ + version: 1, + entries: nextEntries, + }); + return true; +} + +export interface ChatHistorySearchEntry { + readonly text: string; + readonly role: string; + readonly createdAt: number; +} + +export function buildRelevantMemoryContext(prompt: string, chatHistory?: readonly ChatHistorySearchEntry[]): string | undefined { + const memoryHits = searchOpenPetsMemories(prompt, maxRelevantMemoryItems); + const chatHits = chatHistory && chatHistory.length > 0 + ? searchChatHistory(prompt, chatHistory, maxRelevantMemoryItems) + : []; + + if (memoryHits.length === 0 && chatHits.length === 0) { + return undefined; + } + + const lines: string[] = []; + let usedChars = 0; + + // Include memory hits first + for (const hit of memoryHits) { + const tagSuffix = hit.entry.tags.length > 0 ? ` tags=${hit.entry.tags.join(",")}` : ""; + const line = `- [${hit.entry.kind} importance=${hit.entry.importance}${tagSuffix}] ${hit.entry.text}`; + if (usedChars > 0 && usedChars + line.length + 1 > maxRelevantMemoryChars) { + break; + } + lines.push(line); + usedChars += line.length + 1; + } + + // Include relevant chat history excerpts + for (const hit of chatHits) { + const prefix = hit.entry.role === "user" ? "User said" : "Assistant said"; + const line = `- [chat history] ${prefix}: ${hit.entry.text}`; + if (usedChars > 0 && usedChars + line.length + 1 > maxRelevantMemoryChars) { + break; + } + lines.push(line); + usedChars += line.length + 1; + } + + if (lines.length === 0) { + return undefined; + } + + return [ + "Relevant long-term memory:", + ...lines, + "Use these only when they help with the current request. Prefer newer user instructions if memory conflicts with the live conversation.", + ].join("\n"); +} + +export { extractPromptMemoryCandidates }; + +export function capturePromptMemories(prompt: string): readonly OpenPetsMemoryEntry[] { + const candidates = extractPromptMemoryCandidates(prompt); + if (candidates.length === 0) { + return []; + } + return candidates.map((candidate) => storeOpenPetsMemory({ ...candidate, source: "chat" })); +} + +function getMemoryStore(): StoredOpenPetsMemoryV1 { + if (cachedMemoryStore) { + return cachedMemoryStore; + } + + try { + const raw = JSON.parse(readFileSync(getMemoryStorePath(), "utf8")) as unknown; + cachedMemoryStore = normalizeStoredMemory(raw); + } catch { + cachedMemoryStore = { version: 1, entries: [] }; + } + return cachedMemoryStore; +} + +function commitMemoryStore(store: StoredOpenPetsMemoryV1): void { + cachedMemoryStore = { + version: 1, + entries: store.entries.map(cloneMemoryEntry), + }; + writeMemoryStoreToDisk(cachedMemoryStore); +} + +function getMemoryStorePath(): string { + return join(app.getPath("userData"), memoryStoreFileName); +} + +function getMemoryMarkdownMirrorPath(): string { + return join(app.getPath("userData"), memoryMarkdownMirrorFileName); +} + +function writeMemoryStoreToDisk(store: StoredOpenPetsMemoryV1): void { + const path = getMemoryStorePath(); + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); + renameSync(tempPath, path); + + const markdownPath = getMemoryMarkdownMirrorPath(); + const markdownTempPath = `${markdownPath}.${process.pid}.tmp`; + writeFileSync(markdownTempPath, buildMemoryMarkdownMirror(store), "utf8"); + renameSync(markdownTempPath, markdownPath); +} + +function buildMemoryMarkdownMirror(store: StoredOpenPetsMemoryV1): string { + const lines = [ + "# OpenPets Memory", + "", + "This file mirrors the local OpenPets memory store for inspection and backup.", + "", + ]; + + if (store.entries.length === 0) { + lines.push("No stored memories yet.", ""); + return `${lines.join("\n")}\n`; + } + + for (const entry of [...store.entries].sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id))) { + lines.push(`## ${entry.id}`); + lines.push(`- kind: ${entry.kind}`); + lines.push(`- importance: ${entry.importance}`); + lines.push(`- tags: ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}`); + lines.push(`- source: ${entry.source}`); + lines.push(`- updated: ${new Date(entry.updatedAt).toISOString()}`); + lines.push(`- accessed: ${new Date(entry.lastAccessedAt).toISOString()}`); + lines.push(""); + lines.push(entry.text); + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function normalizeStoredMemory(value: unknown): StoredOpenPetsMemoryV1 { + const record = isRecord(value) ? value : {}; + const entries = Array.isArray(record.entries) + ? record.entries.map((entry) => normalizeStoredMemoryEntry(entry)).filter((entry): entry is OpenPetsMemoryEntry => Boolean(entry)) + : []; + return { + version: 1, + entries: pruneMemoryEntries(entries), + }; +} + +function normalizeStoredMemoryEntry(value: unknown): OpenPetsMemoryEntry | undefined { + if (!isRecord(value) || typeof value.id !== "string") { + return undefined; + } + const text = normalizeMemoryText(value.text); + if (!text) { + return undefined; + } + const createdAt = normalizeTimestamp(value.createdAt); + const updatedAt = normalizeTimestamp(value.updatedAt) ?? createdAt; + const lastAccessedAt = normalizeTimestamp(value.lastAccessedAt) ?? updatedAt; + return { + id: value.id, + text, + kind: normalizeMemoryKind(value.kind), + tags: normalizeMemoryTags(Array.isArray(value.tags) ? value.tags : undefined), + importance: normalizeMemoryImportance(value.importance), + createdAt, + updatedAt, + lastAccessedAt, + accessCount: normalizeAccessCount(value.accessCount), + source: value.source === "mcp" ? "mcp" : "chat", + }; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return Date.now(); +} + +function normalizeAccessCount(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return 1; +} + +function normalizeMemoryText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.replaceAll("\r\n", "\n").replace(/\s+/g, " ").trim(); + if (!normalized || normalized.length > maxMemoryTextChars || /[\0]/.test(normalized)) { + return undefined; + } + return normalized; +} + +function normalizeMemoryKind(value: unknown): OpenPetsMemoryKind { + return value === "identity" || value === "preference" || value === "fact" ? value : "note"; +} + +function normalizeMemoryTags(value: readonly string[] | undefined): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + const tags: string[] = []; + for (const rawTag of value) { + if (typeof rawTag !== "string") { + continue; + } + const tag = rawTag + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!tag || tag.length > 32 || tags.includes(tag)) { + continue; + } + tags.push(tag); + if (tags.length >= maxMemoryTags) { + break; + } + } + return tags; +} + +function normalizeMemoryImportance(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.min(Math.max(Math.round(value), 1), 5); + } + return defaultMemoryImportance; +} + +function clampMemoryLimit(value: number, fallback: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(Math.max(Math.round(value), 1), 25); +} + +function pruneMemoryEntries(entries: readonly OpenPetsMemoryEntry[]): readonly OpenPetsMemoryEntry[] { + if (entries.length <= maxMemoryEntries) { + return [...entries]; + } + const now = Date.now(); + return [...entries] + .sort((left, right) => scoreRetentionPriority(right, now) - scoreRetentionPriority(left, now) || right.updatedAt - left.updatedAt || left.id.localeCompare(right.id)) + .slice(0, maxMemoryEntries); +} + +function scoreRetentionPriority(entry: OpenPetsMemoryEntry, now: number): number { + const ageDays = Math.max(0, (now - entry.updatedAt) / 86_400_000); + return entry.importance * 5 + Math.min(entry.accessCount, 10) * 1.5 - ageDays / 21; +} + +function scoreMemoryEntry(entry: OpenPetsMemoryEntry, queryTokens: ReadonlySet, normalizedQuery: string, now: number): number { + const normalizedText = normalizeMemoryComparisonValue(entry.text); + const entryTokens = tokenize(`${entry.text} ${entry.tags.join(" ")}`); + let overlapCount = 0; + for (const token of queryTokens) { + if (entryTokens.has(token)) { + overlapCount += 1; + } + } + const phraseBonus = normalizedText.includes(normalizedQuery) ? 4 : 0; + const ageDays = Math.max(0, (now - entry.updatedAt) / 86_400_000); + const decayPenalty = ageDays / 45; + const recencyBonus = Math.max(0, 2.5 - ageDays / 18); + const accessBonus = Math.min(entry.accessCount, 6) * 0.35; + const tagBonus = entry.tags.some((tag) => normalizedQuery.includes(tag) || tag.includes(normalizedQuery)) ? 1.5 : 0; + const score = overlapCount * 4.5 + phraseBonus + entry.importance * 1.8 + recencyBonus + accessBonus + tagBonus - decayPenalty; + return overlapCount > 0 || phraseBonus > 0 || tagBonus > 0 ? score : 0; +} + +function touchMemoryEntries(ids: readonly string[], now: number): void { + if (ids.length === 0) { + return; + } + const idSet = new Set(ids); + const store = getMemoryStore(); + let changed = false; + const nextEntries = store.entries.map((entry) => { + if (!idSet.has(entry.id)) { + return entry; + } + changed = true; + return { + ...entry, + lastAccessedAt: now, + accessCount: entry.accessCount + 1, + }; + }); + if (changed) { + commitMemoryStore({ + version: 1, + entries: nextEntries, + }); + } +} + +function getMemoryEntryById(id: string): OpenPetsMemoryEntry | undefined { + return getMemoryStore().entries.find((entry) => entry.id === id); +} + +function cloneMemoryEntry(entry: OpenPetsMemoryEntry): OpenPetsMemoryEntry { + return { + ...entry, + tags: [...entry.tags], + }; +} + +function mergeMemoryTags(left: readonly string[], right: readonly string[]): readonly string[] { + return normalizeMemoryTags([...left, ...right]); +} + +function pickPreferredMemoryKind(left: OpenPetsMemoryKind, right: OpenPetsMemoryKind): OpenPetsMemoryKind { + const rank = { note: 0, fact: 1, preference: 2, identity: 3 } satisfies Record; + return rank[right] >= rank[left] ? right : left; +} + +function normalizeMemoryComparisonValue(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function tokenize(value: string): Set { + return new Set( + normalizeMemoryComparisonValue(value) + .split(" ") + .map((token) => token.trim()) + .filter((token) => token.length >= 2), + ); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index e0f870f2..bc2a5f5d 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js"; -import { clampToPrimaryWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js"; +import { clampToNearestWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js"; import { builtInPet } from "./built-in-pet.js"; import { getInstalledPetDir } from "./pet-paths.js"; import type { OpenPetsReaction } from "./local-ipc-protocol.js"; @@ -22,6 +22,8 @@ export interface DefaultPetWindowOptions { readonly onPositionChanged: (position: Point) => void; readonly onHideRequested: () => void; readonly onBubbleDismissed?: (dismissToken: string) => void; + readonly onPromptRequested?: () => void; + readonly onScaleChanged?: (scale: PetScaleValue) => void; } export interface AgentPetWindowOptions { @@ -33,6 +35,7 @@ export interface AgentPetWindowOptions { readonly badge: PetStatusBadgeReaction | null; readonly onCloseRequested: () => void; readonly onBubbleDismissed?: (dismissToken: string) => void; + readonly onScaleChanged?: (scale: PetScaleValue) => void; } export interface PetTransientDisplay { @@ -40,6 +43,8 @@ export interface PetTransientDisplay { readonly message?: string; readonly reactionMessage?: string; readonly dismissToken?: string; + readonly fullMessage?: boolean; + readonly sticky?: boolean; } export type PetStatusBadgeReaction = Exclude; @@ -57,15 +62,20 @@ const windowLoadChains = new WeakMap>(); const windowLoadSequences = new WeakMap(); const petMouseInteropRecovery = new WeakMap void>(); const petWindowDragging = new WeakMap(); +const petWindowScaling = new WeakMap(); export function isPetWindowDragging(window: BrowserWindow): boolean { return petWindowDragging.get(window) === true; } +export function isPetWindowScaling(window: BrowserWindow): boolean { + return petWindowScaling.get(window) === true; +} + export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismissToken?: string): BrowserWindow { const window = createBasePetWindow("OpenPets — Default Pet", options.position); info("pet.window", "default window create", { windowId: window.id, position: options.position, paused: options.paused, hasDisplay: Boolean(options.display), badge: options.badge }); - installMousePassthroughAndDrag(window, options.onBubbleDismissed); + installMousePassthroughAndDrag(window, options.onBubbleDismissed, options.onPromptRequested, options.onScaleChanged); installMotionStatePublisher(window); installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested, defaultPet: true }); @@ -92,7 +102,7 @@ export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismiss export function createAgentPetWindow(options: AgentPetWindowOptions, dismissToken?: string): BrowserWindow { const window = createBasePetWindow(`OpenPets — ${options.displayName}`, options.position); info("pet.window", "agent window create", { windowId: window.id, petId: options.petId, displayName: options.displayName, position: options.position, hasDisplay: Boolean(options.display), badge: options.badge }); - installMousePassthroughAndDrag(window, options.onBubbleDismissed); + installMousePassthroughAndDrag(window, options.onBubbleDismissed, undefined, options.onScaleChanged); installMotionStatePublisher(window); installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested }); void loadExplicitPetContent(window, options.petId, options.display, options.badge, dismissToken, options.scale); @@ -182,7 +192,7 @@ function buildPluginCommandFormUrl(title: string, form: PluginCommandForm, chann function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed?: (dismissToken: string) => void): void { +function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed?: (dismissToken: string) => void, onPromptRequested?: () => void, onScaleChanged?: (scale: PetScaleValue) => void): void { let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number; readonly width: number; readonly height: number } | null = null; let rendererReady = false; let listenersRemoved = false; @@ -361,6 +371,37 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed if (typeof dismissToken === "string") onBubbleDismissed?.(dismissToken); }; + const handlePromptRequested = (event: IpcMainEvent): void => { + if (!isFromWindow(event)) return; + debug("pet.window", "prompt requested", { windowId }); + onPromptRequested?.(); + }; + + const handleScaleStart = (event: IpcMainEvent): void => { + if (!isFromWindow(event) || window.isDestroyed()) return; + petWindowScaling.set(window, true); + debug("pet.window", "scale start", { windowId }); + setPassthrough(false); + }; + + const handleScalePreview = (event: IpcMainEvent, payload: unknown): void => { + if (!isFromWindow(event) || window.isDestroyed()) return; + const scale = isRecord(payload) && typeof payload.scale === "number" ? payload.scale : undefined; + if (scale === undefined || !Number.isFinite(scale)) return; + resizePetWindowForScale(window, scale); + }; + + const handleScaleEnd = (event: IpcMainEvent, payload: unknown): void => { + if (!isFromWindow(event)) return; + petWindowScaling.set(window, false); + const scale = isRecord(payload) && typeof payload.scale === "number" ? payload.scale : undefined; + debug("pet.window", "scale end", { windowId, scale }); + if (scale !== undefined && Number.isFinite(scale)) { + onScaleChanged?.(scale); + } + setPassthrough(true); + }; + const resetForNavigation = (): void => { dragging = null; petWindowDragging.set(window, false); @@ -399,10 +440,15 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed ipcMain.off("openpets:pet-drag-move", handleDragMove); ipcMain.off("openpets:pet-drag-end", handleDragEnd); ipcMain.off("openpets:bubble-dismissed", handleBubbleDismissed); + ipcMain.off("openpets:pet-open-prompt", handlePromptRequested); + ipcMain.off("openpets:pet-scale-start", handleScaleStart); + ipcMain.off("openpets:pet-scale-preview", handleScalePreview); + ipcMain.off("openpets:pet-scale-end", handleScaleEnd); clearRearmTimers(); clearWindowsForwardingWatch(); petMouseInteropRecovery.delete(window); petWindowDragging.delete(window); + petWindowScaling.delete(window); if (!webContents.isDestroyed()) { webContents.off("did-start-navigation", resetForNavigation); webContents.off("did-start-loading", resetForNavigation); @@ -420,6 +466,10 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed ipcMain.on("openpets:pet-drag-move", handleDragMove); ipcMain.on("openpets:pet-drag-end", handleDragEnd); ipcMain.on("openpets:bubble-dismissed", handleBubbleDismissed); + ipcMain.on("openpets:pet-open-prompt", handlePromptRequested); + ipcMain.on("openpets:pet-scale-start", handleScaleStart); + ipcMain.on("openpets:pet-scale-preview", handleScalePreview); + ipcMain.on("openpets:pet-scale-end", handleScaleEnd); webContents.on("did-start-navigation", resetForNavigation); webContents.on("did-start-loading", resetForNavigation); webContents.on("did-finish-load", rearmAfterLoad); @@ -503,9 +553,11 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void { export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string): Promise { const sequence = allocateWindowLoadSequence(window); - debug("pet.window", "default content render begin", { windowId: window.id, sequence, paused, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), badge, defaultPetId: getAppStateSnapshot().preferences.defaultPetId }); + const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue; + resizePetWindowForDisplay(window, scale, display); + debug("pet.window", "default content render begin", { windowId: window.id, sequence, paused, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), fullMessage: display?.fullMessage === true, badge, defaultPetId: getAppStateSnapshot().preferences.defaultPetId }); const render = await createDefaultPetRender(paused, display, badge, dismissToken); - applyLinuxPetWindowShape(window, getAppStateSnapshot().preferences.petScale as PetScaleValue, Boolean(display?.message || display?.reactionMessage || display?.reaction || badge || paused)); + applyLinuxPetWindowShape(window, scale, Boolean(display?.message || display?.reactionMessage || display?.reaction || badge || paused)); if (tryUpdateLoadedPetContent(window, render, "default", sequence)) return; await loadPetHtmlFile(window, render.html, "default", sequence).then(() => { petWindowRenderCache.set(window, render.cacheKey); @@ -523,8 +575,9 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin if (!pet || pet.broken || pet.id === builtInPet.id) { throw new Error(`Cannot render explicit pet: ${petId}`); } - debug("pet.window", "explicit content render begin", { windowId: window.id, sequence, petId, displayName: pet.displayName, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), badge }); + debug("pet.window", "explicit content render begin", { windowId: window.id, sequence, petId, displayName: pet.displayName, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), fullMessage: display?.fullMessage === true, badge }); const scale = scaleOverride ?? state.preferences.petScale as PetScaleValue; + resizePetWindowForDisplay(window, scale, display); const render = await createInstalledPetRender(pet.id, pet.displayName, false, display, scale, badge, `explicit:${pet.id}`, dismissToken); applyLinuxPetWindowShape(window, scale, Boolean(display?.message || display?.reactionMessage || display?.reaction || badge)); if (tryUpdateLoadedPetContent(window, render, `explicit-${pet.id}`, sequence)) return; @@ -582,17 +635,30 @@ function tryUpdateLoadedPetContent(window: BrowserWindow, render: PetContentRend } export function getSafeDefaultPetPosition(position: Point | undefined): Point { - return clampToPrimaryWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize); + return clampToNearestWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize); } export function readWindowPosition(window: BrowserWindow): Point { - const [x, y] = window.getPosition(); - return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize); + const bounds = window.getBounds(); + const x = bounds.x + Math.round((bounds.width - defaultPetWindowSize.width) / 2); + const y = bounds.y + Math.max(0, bounds.height - defaultPetWindowSize.height); + return clampToNearestWorkArea({ x, y }, defaultPetWindowSize); +} + +function getBasePetWindowSize(scale: PetScaleValue): { readonly width: number; readonly height: number } { + const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale); + const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); + const hitPadding = 18; + const petBottom = 22; + const width = Math.max(defaultPetWindowSize.width, scaledWidth + hitPadding * 2 + 4); + const height = Math.max(defaultPetWindowSize.height, scaledHeight + hitPadding * 2 + petBottom + 8); + return { width, height }; } function applyLinuxPetWindowShape(window: BrowserWindow, scale: PetScaleValue, hasBubble: boolean): void { if (process.platform !== "linux" || window.isDestroyed()) return; + const bounds = window.getBounds(); const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale); const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); const petBottom = 22; @@ -601,8 +667,8 @@ function applyLinuxPetWindowShape(window: BrowserWindow, scale: PetScaleValue, h const petHitboxHeight = scaledHeight + hitPadding * 2; const shape: Electron.Rectangle[] = [ { - x: Math.round((defaultPetWindowSize.width - petHitboxWidth) / 2), - y: Math.round(defaultPetWindowSize.height - Math.max(0, petBottom - hitPadding) - petHitboxHeight), + x: Math.round((bounds.width - petHitboxWidth) / 2), + y: Math.round(bounds.height - Math.max(0, petBottom - hitPadding) - petHitboxHeight), width: petHitboxWidth, height: petHitboxHeight, }, @@ -612,9 +678,9 @@ function applyLinuxPetWindowShape(window: BrowserWindow, scale: PetScaleValue, h const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8); shape.push({ x: 0, - y: Math.max(0, defaultPetWindowSize.height - bubbleBottom - 156), - width: defaultPetWindowSize.width, - height: Math.min(156, defaultPetWindowSize.height), + y: 0, + width: bounds.width, + height: Math.max(156, Math.min(bounds.height, bounds.height - bubbleBottom + 18)), }); } @@ -769,6 +835,7 @@ function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: s ${spriteMarkup} +

`; } @@ -790,7 +857,7 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { .stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; } .pet-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; } .pet-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: ${petShellFilter}; transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; } - .bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(220px, calc(100vw - 18px)); max-height: 128px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); } + .bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(340px, calc(100vw - 24px)); max-height: 168px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); } .bubble[data-dismiss-token] { cursor: pointer; } .bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); } .bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; } @@ -800,15 +867,15 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { .bubble-status-icon svg { display: block; width: 14px; height: 14px; color: currentColor; } .bubble-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bubble-divider { height: 1px; width: 100%; margin: 8px 0; background: rgba(30, 58, 138, 0.12); } - .bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - .bubble-text { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-wrap: normal; overflow-wrap: break-word; } + .bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; overflow-y: auto; padding-right: 2px; } + .bubble-text { display: block; min-width: 0; overflow: visible; text-wrap: normal; overflow-wrap: break-word; } .bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; } .bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; } .bubble.is-message-only { border-radius: 14px 14px 3px 14px; } - .bubble.is-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 138px; } - .bubble.is-long-message .bubble-text { -webkit-line-clamp: 6; font-size: 10px; line-height: 13px; } - .bubble.is-very-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 156px; } - .bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 8; font-size: 9.5px; line-height: 12.5px; } + .bubble.is-long-message { max-width: min(380px, calc(100vw - 24px)); max-height: 188px; } + .bubble.is-long-message .bubble-text { font-size: 10px; line-height: 13px; } + .bubble.is-very-long-message { max-width: min(440px, calc(100vw - 24px)); max-height: 220px; } + .bubble.is-very-long-message .bubble-text { font-size: 9.5px; line-height: 12.5px; } .bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); } .bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); } .bubble.is-success .bubble-status-icon { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); } @@ -816,8 +883,15 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { .bubble.is-info .bubble-status-icon { background: #38bdf8; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(56, 189, 248, 0.34); } .bubble.is-busy .bubble-status-icon::before { content: ""; position: absolute; inset: 0; width: 18px; height: 18px; background: radial-gradient(circle at 50% 50%, #fff 0 4px, transparent 4.5px); animation: status-pulse 820ms ease-in-out infinite; } .bubble.is-waiting .bubble-status-icon::before { content: ""; position: absolute; left: 3px; top: 3px; box-sizing: border-box; width: 12px; height: 12px; border: 2px solid rgba(255, 255, 255, 0.96); border-top-color: rgba(255, 255, 255, 0.28); border-radius: 999px; } + .bubble.is-full-message { min-width: min(300px, calc(100vw - 24px)); max-width: min(calc(100vw - 24px), 720px); max-height: max(200px, calc(100vh - ${bubbleBottom + 20}px)); padding: 13px 15px; border-radius: 18px 18px 8px 18px; } + .bubble.is-full-message .bubble-header { font-size: 12px; line-height: 15px; } + .bubble.is-full-message .bubble-body { overflow-y: auto; padding-right: 2px; } + .bubble.is-full-message .bubble-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; -webkit-line-clamp: unset; -webkit-box-orient: initial; font-size: 12px; line-height: 16px; } @keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.96); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } } @keyframes status-pulse { 0%, 100% { opacity: 0.52; } 50% { opacity: 1; } } + .scale-handle { position: absolute; right: 6px; bottom: 6px; width: 14px; height: 14px; border-radius: 7px; background: rgba(255,255,255,0.55); border: 1px solid rgba(0,0,0,0.18); cursor: nwse-resize; pointer-events: auto; -webkit-app-region: no-drag; z-index: 10; opacity: 0; transition: opacity 180ms ease; } + .scale-handle:hover { opacity: 1; background: rgba(255,255,255,0.85); } + .pet-hitbox:hover ~ .scale-handle, .scale-handle:hover { opacity: 1; } @media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } } `; } @@ -845,7 +919,7 @@ function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean const status = !paused && badgeReaction ? getStatusBadge(badgeReaction) : null; if (!text && !status) return ""; const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage); - const className = getBubbleClassName(text, isExplicitMessage, status?.className); + const className = getBubbleClassName(text, isExplicitMessage, status?.className, display?.fullMessage === true); const header = status ? `
${escapeHtml(status.label)}
` : ""; const divider = status && text ? `` : ""; const body = text ? `
${escapeHtml(text)}
` : ""; @@ -873,9 +947,14 @@ function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: return null; } -function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined): string { +function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined, fullMessage = false): string { const statusClass = statusClassName ? ` ${statusClassName}` : ""; if (!text) return `bubble is-status-only${statusClass}`; + if (fullMessage) { + return statusClassName + ? `bubble is-message${statusClass} is-full-message` + : `bubble is-message-only is-full-message${isExplicitMessage ? getBubbleLengthClass(text) : ""}`; + } if (!statusClassName) return `bubble is-message-only${isExplicitMessage ? getBubbleLengthClass(text) : ""}`; const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : ""; return `bubble is-message${statusClass}${lengthClass}`; @@ -950,6 +1029,126 @@ function allocateWindowLoadSequence(window: BrowserWindow): number { return sequence; } +function resizePetWindowForDisplay(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): void { + if (window.isDestroyed()) return; + + const nextSize = getPetWindowSize(window, scale, display); + const currentBounds = window.getBounds(); + if (currentBounds.width === nextSize.width && currentBounds.height === nextSize.height) { + return; + } + + const anchor = readWindowPosition(window); + const nextBounds = { + x: anchor.x - Math.round((nextSize.width - defaultPetWindowSize.width) / 2), + y: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height), + width: nextSize.width, + height: nextSize.height, + }; + const displayForBounds = screen.getDisplayNearestPoint({ + x: anchor.x + Math.round(defaultPetWindowSize.width / 2), + y: anchor.y + defaultPetWindowSize.height, + }) ?? screen.getPrimaryDisplay(); + const workArea = displayForBounds.workArea; + const maxX = workArea.x + Math.max(0, workArea.width - nextSize.width); + const maxY = workArea.y + Math.max(0, workArea.height - nextSize.height); + window.setBounds({ + x: Math.min(Math.max(nextBounds.x, workArea.x), maxX), + y: Math.min(Math.max(nextBounds.y, workArea.y), maxY), + width: nextSize.width, + height: nextSize.height, + }, false); +} + +function resizePetWindowForScale(window: BrowserWindow, scale: PetScaleValue): void { + if (window.isDestroyed()) return; + const nextSize = getBasePetWindowSize(scale); + const currentBounds = window.getBounds(); + if (currentBounds.width === nextSize.width && currentBounds.height === nextSize.height) { + return; + } + const anchor = readWindowPosition(window); + const nextBounds = { + x: anchor.x - Math.round((nextSize.width - defaultPetWindowSize.width) / 2), + y: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height), + width: nextSize.width, + height: nextSize.height, + }; + const displayForBounds = screen.getDisplayNearestPoint({ + x: anchor.x + Math.round(defaultPetWindowSize.width / 2), + y: anchor.y + defaultPetWindowSize.height, + }) ?? screen.getPrimaryDisplay(); + const workArea = displayForBounds.workArea; + const maxX = workArea.x + Math.max(0, workArea.width - nextSize.width); + const maxY = workArea.y + Math.max(0, workArea.height - nextSize.height); + window.setBounds({ + x: Math.min(Math.max(nextBounds.x, workArea.x), maxX), + y: Math.min(Math.max(nextBounds.y, workArea.y), maxY), + width: nextSize.width, + height: nextSize.height, + }, false); +} + +function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): { readonly width: number; readonly height: number } { + const base = getBasePetWindowSize(scale); + const message = display?.message ?? display?.reactionMessage ?? ""; + if (!message) { + return base; + } + + const displayForBounds = screen.getDisplayMatching(window.getBounds()) ?? screen.getPrimaryDisplay(); + const workArea = displayForBounds.workArea; + const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); + const petBottom = 22; + const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8); + const petFootprintExtra = Math.max(0, Math.ceil((scale - 1) * 44)); + + if (display?.fullMessage) { + const width = clampNumber( + Math.round(340 + Math.min(220, Math.max(0, message.length - 80)) * 1.05), + base.width, + Math.max(base.width, Math.min(560, workArea.width - 28)), + ); + const charsPerLine = Math.max(28, Math.floor((width - 56) / 7.2)); + const estimatedLines = estimateWrappedLineCount(message, charsPerLine); + const bubbleHeight = 74 + estimatedLines * 16 + petFootprintExtra; + const height = clampNumber( + Math.round(base.height + bubbleHeight - 132), + base.height, + Math.max(base.height, workArea.height - 28), + ); + return { width, height }; + } + + // Normal message: base width, ensure window is tall enough for the scrollable bubble + const bubbleMaxHeight = 168; + const minHeightForBubble = bubbleBottom + bubbleMaxHeight + 8; + const height = clampNumber( + Math.max(base.height, minHeightForBubble), + base.height, + Math.max(base.height, workArea.height - 28), + ); + return { width: base.width, height }; +} + +function estimateWrappedLineCount(message: string, charsPerLine: number): number { + const paragraphs = message.split(/\r?\n/); + let lines = 0; + for (const paragraph of paragraphs) { + if (!paragraph) { + lines += 1; + continue; + } + lines += Math.max(1, Math.ceil(paragraph.length / Math.max(1, charsPerLine))); + } + return Math.max(1, lines); +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(Math.max(value, min), max); +} + async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string, sequence: number): Promise { const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet"; diff --git a/apps/desktop/src/prompt-memory-extraction.ts b/apps/desktop/src/prompt-memory-extraction.ts new file mode 100644 index 00000000..9f37cd2d --- /dev/null +++ b/apps/desktop/src/prompt-memory-extraction.ts @@ -0,0 +1,141 @@ +import type { OpenPetsMemoryKind } from "./local-ipc-protocol.js"; + +interface ExtractedMemoryCandidate { + readonly text: string; + readonly kind: OpenPetsMemoryKind; + readonly tags: readonly string[]; + readonly importance: number; +} + +const maxMemoryTextChars = 480; +const maxMemoryTags = 8; +const defaultMemoryImportance = 3; + +export function extractPromptMemoryCandidates(prompt: string): readonly ExtractedMemoryCandidate[] { + const normalizedPrompt = prompt.replaceAll("\r\n", "\n").trim(); + if (!normalizedPrompt) { + return []; + } + + const candidates: ExtractedMemoryCandidate[] = []; + const addCandidate = (candidate: ExtractedMemoryCandidate | undefined): void => { + if (!candidate) { + return; + } + const normalizedText = normalizeMemoryComparisonValue(candidate.text); + if (!normalizedText || candidates.some((existing) => normalizeMemoryComparisonValue(existing.text) === normalizedText)) { + return; + } + candidates.push(candidate); + }; + + for (const line of normalizedPrompt.split("\n")) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + let matchedSpecific = false; + + const nameMatch = trimmedLine.match(/\b(?:my name is|call me)\s+([^.,!?;\n]{1,80})/i); + if (nameMatch) { + const name = finalizeExtractedMemoryText(nameMatch[1]); + addCandidate(name ? { + text: `User's name is ${name}.`, + kind: "identity", + tags: ["identity", "name"], + importance: 5, + } : undefined); + matchedSpecific = true; + } + + const favoriteMatch = trimmedLine.match(/\bmy favorite\s+([^.!?\n]{1,40})\s+is\s+([^.!?\n]{1,120})/i); + if (favoriteMatch) { + const subject = finalizeExtractedMemoryText(favoriteMatch[1]); + const answer = finalizeExtractedMemoryText(favoriteMatch[2]); + addCandidate(subject && answer ? { + text: `User's favorite ${subject} is ${answer}.`, + kind: "preference", + tags: ["favorite", subject.toLowerCase().replace(/[^a-z0-9]+/g, "-")], + importance: 4, + } : undefined); + matchedSpecific = true; + } + + const preferenceMatch = trimmedLine.match(/\bI prefer\s+([^.!?\n]{1,180})/i); + if (preferenceMatch) { + const preference = finalizeExtractedMemoryText(preferenceMatch[1]); + addCandidate(preference ? { + text: `User prefers ${preference}.`, + kind: "preference", + tags: ["preference"], + importance: 4, + } : undefined); + matchedSpecific = true; + } + + const likesMatch = trimmedLine.match(/\bI (like|love|dislike|hate)\s+([^.!?\n]{1,180})/i); + if (likesMatch) { + const verb = likesMatch[1].toLowerCase(); + const subject = finalizeExtractedMemoryText(likesMatch[2]); + if (subject) { + const normalizedVerb = verb === "love" ? "loves" : verb === "like" ? "likes" : verb === "dislike" ? "dislikes" : "hates"; + addCandidate({ + text: `User ${normalizedVerb} ${subject}.`, + kind: "preference", + tags: ["preference"], + importance: verb === "love" || verb === "hate" ? 4 : 3, + }); + } + matchedSpecific = true; + } + + if (matchedSpecific) { + continue; + } + + const explicitRemember = trimmedLine.match(/(?:^|\s)[\/!#]?(?:remember|memorize|note)(?:\s+that|\s*[:,-])?\s+(.+)$/i); + if (explicitRemember) { + const remembered = finalizeExtractedMemoryText(explicitRemember[1]); + addCandidate(remembered ? { + text: remembered, + kind: "note", + tags: ["remember"], + importance: 5, + } : undefined); + } + + const dontForgetMatch = trimmedLine.match(/\bdon'?t forget(?:\s+that)?\s+(.+)$/i); + if (dontForgetMatch) { + const remembered = finalizeExtractedMemoryText(dontForgetMatch[1]); + addCandidate(remembered ? { + text: remembered, + kind: "note", + tags: ["remember"], + importance: 5, + } : undefined); + } + } + + return candidates.slice(0, 6); +} + +function finalizeExtractedMemoryText(value: string): string | undefined { + const normalized = normalizeMemoryText(value); + if (!normalized) { + return undefined; + } + return normalized.replace(/^[,.:;!?-]+/, "").replace(/[,:;!?-]+$/, "").trim() || undefined; +} + +function normalizeMemoryText(value: string): string | undefined { + const normalized = value.replaceAll("\r\n", "\n").replace(/\s+/g, " ").trim(); + if (!normalized || normalized.length > maxMemoryTextChars || /[\0]/.test(normalized)) { + return undefined; + } + return normalized; +} + +function normalizeMemoryComparisonValue(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim(); +} diff --git a/apps/desktop/src/prompt-window.ts b/apps/desktop/src/prompt-window.ts new file mode 100644 index 00000000..88cb721a --- /dev/null +++ b/apps/desktop/src/prompt-window.ts @@ -0,0 +1,812 @@ +import { app, BrowserWindow, ipcMain, screen, type IpcMainInvokeEvent } from "electron"; +import { join } from "node:path"; + +import { getAppStateSnapshot } from "./app-state.js"; +import { getDefaultPetWindowBounds } from "./default-pet-controller.js"; +import { error as logError } from "./logger.js"; +import { createConversation, deleteConversation, getAllChatTranscriptEntries, getCurrentConversationId, getOpenApiChatSettingsSnapshot, getOpenApiChatTranscriptEntries, listConversations, resetOpenApiConversation, sendOpenApiChatPrompt, switchConversation, type ChatConversation, type OpenApiChatTranscriptEntry } from "./openapi-chat.js"; +import { openControlCenterWindow } from "./windows.js"; + +type PromptWindowThemeMode = "system" | "light" | "dark"; +type PromptWindowResizeAnchor = "top-left" | "bottom-left"; + +interface PromptWindowState { + readonly hasCredential: boolean; + readonly themeMode: PromptWindowThemeMode; + readonly history: readonly OpenApiChatTranscriptEntry[]; + readonly conversations: readonly ChatConversation[]; + readonly currentConversationId: string | null; +} + +interface PromptWindowResizeRequest { + readonly width?: number; + readonly height?: number; + readonly anchor?: PromptWindowResizeAnchor; +} + +const promptWindowWidth = 392; +const promptWindowMinWidth = 332; +const promptWindowMaxWidth = 760; +const promptWindowCompactHeight = 140; +const promptWindowExpandedHeight = 456; +const promptWindowMinHeight = 160; +const promptWindowMaxHeight = 760; +const promptWindowWorkAreaMargin = 48; + +let promptWindow: BrowserWindow | null = null; +let promptWindowHandlersInstalled = false; + +export function installPromptWindowHandlers(): void { + if (promptWindowHandlersInstalled) { + return; + } + + promptWindowHandlersInstalled = true; + + ipcMain.handle("openpets:prompt-window-state", (event) => { + assertPromptWindowSender(event); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-submit", async (event, prompt: unknown) => { + assertPromptWindowSender(event); + if (typeof prompt !== "string") { + throw new Error("Prompt must be text."); + } + await sendOpenApiChatPrompt(prompt); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-reset-conversation", (event) => { + assertPromptWindowSender(event); + resetOpenApiConversation(); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-create-conversation", (event) => { + assertPromptWindowSender(event); + const conv = createConversation(); + switchConversation(conv.id); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-switch-conversation", (event, conversationId: unknown) => { + assertPromptWindowSender(event); + if (typeof conversationId !== "string") { + throw new Error("Conversation ID must be a string."); + } + switchConversation(conversationId); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-delete-conversation", (event, conversationId: unknown) => { + assertPromptWindowSender(event); + if (typeof conversationId !== "string") { + throw new Error("Conversation ID must be a string."); + } + deleteConversation(conversationId); + return getPromptWindowState(); + }); + + ipcMain.handle("openpets:prompt-window-open-settings", (event) => { + assertPromptWindowSender(event); + openControlCenterWindow("settings"); + }); + + ipcMain.handle("openpets:prompt-window-resize", (event, nextBounds: unknown) => { + assertPromptWindowSender(event); + resizePromptWindow(normalizeResizeRequest(nextBounds)); + }); + + ipcMain.handle("openpets:prompt-window-close", (event) => { + assertPromptWindowSender(event); + if (promptWindow && !promptWindow.isDestroyed()) { + promptWindow.close(); + } + }); +} + +export function openPromptWindow(): void { + const existing = promptWindow; + if (existing && !existing.isDestroyed()) { + movePromptWindowNearPet(existing); + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + return; + } + + const bounds = getPromptWindowBounds(); + const window = new BrowserWindow({ + title: "OpenPets Prompt", + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + minWidth: promptWindowMinWidth, + minHeight: promptWindowMinHeight, + maximizable: false, + minimizable: false, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + frame: false, + show: false, + backgroundColor: "#181818", + roundedCorners: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + preload: join(app.getAppPath(), "prompt-window-preload.cjs"), + }, + }); + + promptWindow = window; + window.setMenu(null); + window.setAlwaysOnTop(true, process.platform === "linux" ? "screen-saver" : "floating"); + if (process.platform === "darwin") { + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } else if (process.platform === "linux") { + window.setVisibleOnAllWorkspaces(true); + } + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + window.webContents.on("will-redirect", (event) => event.preventDefault()); + window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => { + logError("ui", "prompt window load failed", { errorCode, errorDescription }); + }); + window.webContents.on("render-process-gone", (_event, details) => { + logError("ui", "prompt window renderer gone", details); + }); + window.once("ready-to-show", () => { + if (window.isDestroyed()) return; + window.show(); + window.focus(); + }); + window.on("closed", () => { + promptWindow = null; + }); + + void window.loadURL(buildPromptWindowUrl()).catch((error: unknown) => { + logError("ui", "prompt window load exception", error instanceof Error ? error : { error }); + }); +} + +function getPromptWindowState(): PromptWindowState { + const snapshot = getOpenApiChatSettingsSnapshot(); + return { + hasCredential: snapshot.hasCredential, + themeMode: getPromptWindowThemeMode(), + history: getOpenApiChatTranscriptEntries(), + conversations: listConversations(), + currentConversationId: getCurrentConversationId(), + }; +} + +function getPromptWindowThemeMode(): PromptWindowThemeMode { + const theme = getAppStateSnapshot().preferences.openApiChatTheme; + return theme === "light" || theme === "dark" ? theme : "system"; +} + +function assertPromptWindowSender(event: IpcMainInvokeEvent): void { + if (!promptWindow || promptWindow.isDestroyed() || event.sender !== promptWindow.webContents) { + throw new Error("OpenPets prompt request came from an unexpected window."); + } +} + +function movePromptWindowNearPet(window: BrowserWindow): void { + if (window.isDestroyed()) return; + const current = window.getBounds(); + const bounds = getPromptWindowBounds(current.width, current.height); + window.setBounds(bounds, false); +} + +function normalizeResizeRequest(value: unknown): PromptWindowResizeRequest { + if (typeof value === "number" && Number.isFinite(value)) { + return { + height: value, + anchor: "bottom-left", + }; + } + if (!isRecord(value)) { + return {}; + } + return { + width: typeof value.width === "number" && Number.isFinite(value.width) ? value.width : undefined, + height: typeof value.height === "number" && Number.isFinite(value.height) ? value.height : undefined, + anchor: value.anchor === "bottom-left" ? "bottom-left" : "top-left", + }; +} + +function resizePromptWindow(nextRequest: PromptWindowResizeRequest): void { + const window = promptWindow; + if (!window || window.isDestroyed()) return; + + const current = window.getBounds(); + const display = screen.getDisplayMatching(current); + const workArea = display.workArea; + const maxHeight = Math.max(promptWindowMinHeight, Math.min(promptWindowMaxHeight, workArea.height - promptWindowWorkAreaMargin)); + const maxWidth = Math.max(promptWindowMinWidth, Math.min(promptWindowMaxWidth, workArea.width - promptWindowWorkAreaMargin)); + const nextWidth = clampNumber(nextRequest.width ?? current.width, promptWindowMinWidth, maxWidth); + const nextHeight = clampNumber(nextRequest.height ?? current.height, promptWindowMinHeight, maxHeight); + const nextX = clampNumber(current.x, workArea.x, workArea.x + Math.max(0, workArea.width - nextWidth)); + const nextY = nextRequest.anchor === "bottom-left" + ? clampNumber(current.y + current.height - nextHeight, workArea.y, workArea.y + Math.max(0, workArea.height - nextHeight)) + : clampNumber(current.y, workArea.y, workArea.y + Math.max(0, workArea.height - nextHeight)); + + if (current.width === nextWidth && current.height === nextHeight && current.x === nextX && current.y === nextY) { + return; + } + + window.setBounds({ + x: nextX, + y: nextY, + width: nextWidth, + height: nextHeight, + }, false); +} + +function getPromptWindowBounds(width = promptWindowWidth, height = promptWindowCompactHeight): Electron.Rectangle { + const petBounds = getDefaultPetWindowBounds(); + const anchorPoint = petBounds + ? { x: petBounds.x + Math.round(petBounds.width / 2), y: petBounds.y + Math.round(petBounds.height / 2) } + : screen.getCursorScreenPoint(); + const display = petBounds + ? screen.getDisplayMatching(petBounds) + : screen.getDisplayNearestPoint(anchorPoint); + const workArea = display.workArea; + const clampedWidth = clampNumber(width, promptWindowMinWidth, Math.max(promptWindowMinWidth, Math.min(promptWindowMaxWidth, workArea.width - promptWindowWorkAreaMargin))); + const clampedHeight = clampNumber(height, promptWindowMinHeight, Math.max(promptWindowMinHeight, Math.min(promptWindowMaxHeight, workArea.height - promptWindowWorkAreaMargin))); + const preferredX = petBounds + ? petBounds.x + petBounds.width - Math.round(clampedWidth * 0.76) + : workArea.x + workArea.width - clampedWidth - 36; + const preferredY = petBounds + ? petBounds.y - clampedHeight - 18 + : workArea.y + workArea.height - clampedHeight - 36; + return { + x: clampNumber(preferredX, workArea.x, workArea.x + Math.max(0, workArea.width - clampedWidth)), + y: clampNumber(preferredY, workArea.y, workArea.y + Math.max(0, workArea.height - clampedHeight)), + width: clampedWidth, + height: clampedHeight, + }; +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(Math.max(Math.round(value), min), max); +} + +function buildPromptWindowUrl(): string { + const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'"; + const html = `OpenPets Prompt
`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index 09bea76b..3ab0e569 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./styles.css"; +import { getMcpToolkitEntry, mcpToolkitEntries, mcpToolkitTierLabels } from "./mcp-toolkit-catalog"; import openPetsLogoUrl from "../../../assets/openpets.webp"; import defaultThumbUrl from "../../../assets/default-pet-thumbnail.png"; @@ -20,9 +21,11 @@ type StateSnapshot = { preferences: { defaultPetId: string }; pets: { installed: type CatalogState = { pets: PetEntry[]; source: string; error?: string; page?: number; pageCount?: number; total?: number; categories?: { id: "western" | "asian"; label: string; count: number }[]; originalsCount?: number; featuredCount?: number }; type CodexState = { pets: PetEntry[]; error?: string }; type PetScaleOption = { label: string; value: number }; +type ThemeMode = "system" | "light" | "dark"; type UserSelectableAnimationState = "idle" | "review" | "running" | "waiting" | "waving" | "jumping" | "failed"; type ReactionAnimationOverrides = Record; -type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; petScale: number; reactionAnimationOverrides?: ReactionAnimationOverrides }; petScaleOptions: PetScaleOption[] }; +type OpenApiChatSettings = { model: string; endpoint: string; defaultEndpoint: string; usingDefaultEndpoint: boolean; hasCredential: boolean; storageMode: "encrypted" | "plain" }; +type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; petScale: number; reactionAnimationOverrides?: ReactionAnimationOverrides; openApiChatModel?: string; openApiChatSystemPrompt?: string; openApiChatEndpoint?: string; openApiChatTheme?: ThemeMode; openApiChatBaseInstructionsEnabled?: boolean }; petScaleOptions: PetScaleOption[]; openApiChat: OpenApiChatSettings }; type LaunchAtLoginState = { supported: boolean; enabled: boolean }; type UpdateStatus = { state: "idle" | "checking" | "available" | "current" | "error"; currentVersion: string; latestVersion?: string; releaseUrl?: string; checkedAt?: number; error?: string }; type DashboardActivity = { messagesSent: number; reactionsSent: number; reactionCounts: Record; perPetActivityCounts: Record; lastActivityAt?: number }; @@ -48,7 +51,10 @@ type ControlCenterApi = { getPetsState(): Promise; getDashboardSnapshot(): Promise; getSettingsState(): Promise; + getOpenApiChatSettings(): Promise; updatePreferences(patch: Partial): Promise; + saveOpenApiCredential(apiKey: string): Promise; + clearOpenApiCredential(): Promise; getReactionAnimationSettings(): Promise; getLaunchAtLogin(): Promise; setLaunchAtLogin(enabled: boolean): Promise; @@ -80,6 +86,15 @@ type ControlCenterApi = { getIntegrationsState(selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise; runIntegrationAction(action: AgentSetupAction, selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise; updateIntegrationCommandPaths(patch: Partial): Promise; + copyText(text: string): Promise; + openExternalUrl(url: string): Promise; + installMcpToolkit(target: McpToolkitPersistentTarget): Promise; + getVanillaChatMcpTools(): Promise<{ enabled: string[]; available: string[]; active: string[] }>; + setVanillaChatMcpTools(toolIds: string[]): Promise; + getMemories(query?: string, limit?: number): Promise>; + storeMemory(text: string, kind?: string, tags?: string[], importance?: number): Promise<{ id: string; text: string; kind: string; tags: string[]; importance: number }>; + updateMemory(id: string, text: string, kind?: string, tags?: string[], importance?: number): Promise<{ id: string; text: string; kind: string; tags: string[]; importance: number }>; + deleteMemory(id: string): Promise; }; @@ -96,9 +111,162 @@ type AgentSetupCommandPaths = { claude: string; node: string; opencode: string } type AgentSetupActionResult = { ok: boolean; action: AgentSetupAction; message: string; changed: boolean }; type AgentSetupSnapshot = { selectedPetId?: string; commandMode: "published" | "local" | "bundled"; localDevAvailable: boolean; petOptions: AgentSetupPetOption[]; preview: { displayCommand: string; mcpJson: Record }; status: ClaudeCodeStatus; hookStatus: ClaudeHookDoctorResult; memoryStatus: ClaudeOpenPetsMemoryStatus; opencodeStatus: OpenCodeSetupStatus; opencodePreview: OpenCodeSetupPreview; cursorStatus: CursorSetupStatus; cursorPreview: CursorSetupPreview; commandPaths: AgentSetupCommandPaths; busy: boolean; lastAction?: AgentSetupActionResult }; type StatusTone = keyof typeof statusPillToneClass; +type OpenApiEndpointPreset = { + id: string; + label: string; + endpoint: string; + applyMode: "save" | "draft"; + description: string; +}; + +type OpenApiCredentialUi = { + label: string; + placeholder: string; + replacementPlaceholder: string; + savedMessage: string; + clearedMessage: string; +}; +type McpToolkitInstallMode = "manual" | "persistent"; +type McpToolkitPersistentTarget = "claude-user" | "codex-global"; +type McpToolkitBundle = { + readonly label: string; + readonly language: "bash" | "json" | "text"; + readonly value: string; + readonly description: string; +}; +type McpToolkitInstallResult = { + readonly target: McpToolkitPersistentTarget; + readonly label: string; + readonly installed: { id: string; name: string; status: "installed" | "skipped"; detail: string }[]; + readonly skipped: { id: string; name: string; status: "installed" | "skipped"; detail: string }[]; + readonly notes: string[]; +}; const api = (window as unknown as { openPetsControlCenter: ControlCenterApi }).openPetsControlCenter; +const openApiEndpointPresets: readonly OpenApiEndpointPreset[] = [ + { + id: "openai", + label: "OpenAI", + endpoint: "", + applyMode: "save", + description: "Uses the built-in OpenAI Responses endpoint immediately.", + }, + { + id: "openrouter", + label: "OpenRouter", + endpoint: "https://openrouter.ai/api/v1", + applyMode: "save", + description: "Uses the OpenRouter OpenAPI base so OpenPets can choose the best supported chat route automatically.", + }, + { + id: "azure", + label: "Azure Template", + endpoint: "https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1", + applyMode: "draft", + description: "Prefills the Azure OpenAI OpenAPI base. Replace YOUR-RESOURCE-NAME, then save.", + }, + { + id: "litellm-local", + label: "LiteLLM Local", + endpoint: "http://localhost:4000/v1", + applyMode: "save", + description: "Common local LiteLLM OpenAPI-compatible gateway port.", + }, + { + id: "vllm-local", + label: "vLLM Local", + endpoint: "http://localhost:8000/v1", + applyMode: "save", + description: "Common local vLLM OpenAPI-compatible server port.", + }, + { + id: "localhost-template", + label: "Custom Local Template", + endpoint: "http://localhost:11434/v1", + applyMode: "draft", + description: "Template for a local gateway. Edit the host, port, or path if needed, then save.", + }, + { + id: "https-template", + label: "Generic HTTPS Template", + endpoint: "https://YOUR-HOSTNAME.example.com/v1", + applyMode: "draft", + description: "Template for hosted OpenAPI-compatible gateways and proxies.", + }, + { + id: "moonshot", + label: "Moonshot (Kimi)", + endpoint: "https://api.moonshot.cn/v1", + applyMode: "save", + description: "Moonshot AI OpenAPI-compatible endpoint for Kimi models.", + }, +] as const; + +function getOpenApiCredentialUi(presetId: string | undefined): OpenApiCredentialUi { + switch (presetId) { + case "openai": + return { + label: "OpenAI API key", + placeholder: "Enter your OpenAI API key", + replacementPlaceholder: "Replace saved OpenAI API key", + savedMessage: "OpenAI API key saved.", + clearedMessage: "OpenAI API key cleared.", + }; + case "openrouter": + return { + label: "OpenRouter API key", + placeholder: "Enter your OpenRouter API key", + replacementPlaceholder: "Replace saved OpenRouter API key", + savedMessage: "OpenRouter API key saved.", + clearedMessage: "OpenRouter API key cleared.", + }; + case "azure": + return { + label: "Azure API key", + placeholder: "Enter your Azure API key", + replacementPlaceholder: "Replace saved Azure API key", + savedMessage: "Azure API key saved.", + clearedMessage: "Azure API key cleared.", + }; + case "moonshot": + return { + label: "Moonshot API key", + placeholder: "Enter your Moonshot API key", + replacementPlaceholder: "Replace saved Moonshot API key", + savedMessage: "Moonshot API key saved.", + clearedMessage: "Moonshot API key cleared.", + }; + default: + return { + label: "API key or token", + placeholder: "Enter your provider API key or token", + replacementPlaceholder: "Replace saved API key or token", + savedMessage: "Chat credential saved.", + clearedMessage: "Chat credential cleared.", + }; + } +} + +function normalizeComparableEndpoint(value: string): string { + return value.replace(/\/(?:responses|chat\/completions)$/, "/v1"); +} + +const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + +function normalizeThemeMode(value: unknown): ThemeMode { + return value === "light" || value === "dark" ? value : "system"; +} + +function resolveThemeMode(mode: ThemeMode): "light" | "dark" { + if (mode === "light" || mode === "dark") return mode; + return themeMediaQuery.matches ? "dark" : "light"; +} + +function applyDocumentTheme(mode: ThemeMode): void { + document.documentElement.dataset.theme = resolveThemeMode(mode); +} + // Inline SVG Icons for actions, pagination, and filters const InstallIcon = () => ( @@ -210,6 +378,21 @@ const CloseIcon = () => ( ); +const CopyIcon = () => ( + + + + +); + +const ExternalLinkIcon = () => ( + + + + + +); + const PrevIcon = () => ( @@ -710,11 +893,65 @@ function initialControlCenterRoute(): Route { } const commandModeLabels: Record = { - published: "Published package", - bundled: "Bundled desktop CLI", - local: "Local development", + published: "Stable release (npm)", + bundled: "Shipped with this app", + local: "Local source build", }; +const persistentToolkitUnavailable = [ + "github", + "databases", + "docker", + "shell", + "process-logs", + "system-info", + "ssh", + "package-manager", + "tmux", + "kubernetes-cloud", + "ci-cd", + "ghidra", + "binary-analysis", + "network-analysis", +] as const; + +function buildPersistentToolkitBundle(target: McpToolkitPersistentTarget): McpToolkitBundle { + if (target === "claude-user") { + return { + label: "Claude Code user-scope install bundle", + language: "bash", + description: "This mirrors OpenPets' supported Install Now baseline for Claude Code. It keeps the same managed server names so reruns stay predictable.", + value: `claude mcp add --scope user openpets-filesystem -- npx -y @modelcontextprotocol/server-filesystem "$HOME" +claude mcp add --scope user openpets-playwright -- npx @playwright/mcp@latest +claude mcp add --scope user openpets-memory -- npx -y @modelcontextprotocol/server-memory +claude mcp add --scope user openpets-context7 -- npx -y @upstash/context7-mcp +claude mcp add --scope user openpets-fetch -- uvx mcp-server-fetch +claude mcp add --scope user openpets-sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking +claude mcp add --scope user openpets-browser-use -- uvx --from browser-use[cli] browser-use --mcp`, + }; + } + + return { + label: "Codex CLI persistent install bundle", + language: "bash", + description: "This mirrors OpenPets' supported Install Now baseline for Codex CLI and keeps the same managed server names.", + value: `codex mcp add openpets-filesystem -- npx -y @modelcontextprotocol/server-filesystem "$HOME" +codex mcp add openpets-playwright -- npx @playwright/mcp@latest +codex mcp add openpets-memory -- npx -y @modelcontextprotocol/server-memory +codex mcp add openpets-context7 -- npx -y @upstash/context7-mcp +codex mcp add openpets-fetch -- uvx mcp-server-fetch +codex mcp add openpets-sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking +codex mcp add openpets-browser-use -- uvx --from browser-use[cli] browser-use --mcp`, + }; +} + +function getPersistentToolkitFollowUps(): string[] { + return persistentToolkitUnavailable + .map((id) => getMcpToolkitEntry(id)) + .filter((entry): entry is NonNullable => Boolean(entry)) + .map((entry) => entry.name); +} + function Button({ children, variant = "primary", @@ -877,7 +1114,7 @@ function ReactionPreviewSprite({ settings, state }: { settings: ReactionAnimatio ); } -function SettingsView() { +function SettingsView({ onThemeModeChange }: { onThemeModeChange: (mode: ThemeMode) => void }) { const [settings, setSettings] = useState(null); const [reactionSettings, setReactionSettings] = useState(null); const [launchAtLogin, setLaunchAtLogin] = useState(null); @@ -886,8 +1123,36 @@ function SettingsView() { const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const [openApiCredential, setOpenApiCredential] = useState(""); + const [openApiModelDraft, setOpenApiModelDraft] = useState(""); + const [openApiSystemPromptDraft, setOpenApiSystemPromptDraft] = useState(""); + const [openApiBaseInstructionsEnabled, setOpenApiBaseInstructionsEnabled] = useState(true); + const [openApiEndpointDraft, setOpenApiEndpointDraft] = useState(""); + const [memories, setMemories] = useState>([]); + const [memoryQuery, setMemoryQuery] = useState(""); + const [memoryBusy, setMemoryBusy] = useState(false); + const [openApiPresetId, setOpenApiPresetId] = useState(openApiEndpointPresets[0]?.id ?? "openai"); const reactionSaveQueue = useRef(Promise.resolve()); + function syncOpenApiPresetSelection(nextSettings: SettingsState) { + const configuredEndpoint = nextSettings.preferences.openApiChatEndpoint ?? nextSettings.openApiChat.endpoint; + const matchingPreset = openApiEndpointPresets.find((preset) => { + if (!preset.endpoint) return nextSettings.openApiChat.usingDefaultEndpoint; + return normalizeComparableEndpoint(preset.endpoint) === normalizeComparableEndpoint(configuredEndpoint); + }); + if (matchingPreset) { + setOpenApiPresetId(matchingPreset.id); + return; + } + if (configuredEndpoint.startsWith("http://localhost") || configuredEndpoint.startsWith("http://127.0.0.1")) { + setOpenApiPresetId("localhost-template"); + return; + } + if (configuredEndpoint.startsWith("https://")) { + setOpenApiPresetId("https-template"); + } + } + async function loadSettings() { setError(""); const [nextSettings, nextReactions, nextLaunch, nextUpdate] = await Promise.all([ @@ -897,6 +1162,12 @@ function SettingsView() { api.getUpdateStatus(), ]); setSettings(nextSettings); + setOpenApiModelDraft(nextSettings.preferences.openApiChatModel ?? nextSettings.openApiChat.model); + setOpenApiSystemPromptDraft(nextSettings.preferences.openApiChatSystemPrompt ?? ""); + setOpenApiBaseInstructionsEnabled(nextSettings.preferences.openApiChatBaseInstructionsEnabled !== false); + setOpenApiEndpointDraft(nextSettings.preferences.openApiChatEndpoint ?? ""); + onThemeModeChange(normalizeThemeMode(nextSettings.preferences.openApiChatTheme)); + syncOpenApiPresetSelection(nextSettings); setReactionSettings(nextReactions); setLaunchAtLogin(nextLaunch); setUpdateStatus(nextUpdate); @@ -905,6 +1176,18 @@ function SettingsView() { } } + async function loadMemories(query = "") { + try { + setMemoryBusy(true); + const list = await api.getMemories(query, 50); + setMemories(list); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } finally { + setMemoryBusy(false); + } + } + useEffect(() => { void loadSettings().catch((err) => setError(String(err?.message ?? err))); }, []); useEffect(() => { @@ -923,13 +1206,49 @@ function SettingsView() { void run("Saving", async () => { const next = await api.updatePreferences(patch); setSettings(next); + onThemeModeChange(normalizeThemeMode(next.preferences.openApiChatTheme)); + syncOpenApiPresetSelection(next); if ("reactionAnimationOverrides" in patch) { setReactionSettings((current) => current ? { ...current, overrides: next.preferences.reactionAnimationOverrides ?? {} } : current); } + if ("openApiChatSystemPrompt" in patch) { + setOpenApiSystemPromptDraft(next.preferences.openApiChatSystemPrompt ?? ""); + } setMessage(success); }); } + function saveOpenApiEndpoint() { + void run("Saving", async () => { + const next = await api.updatePreferences({ openApiChatEndpoint: openApiEndpointDraft.trim() || undefined }); + setSettings(next); + setOpenApiEndpointDraft(next.preferences.openApiChatEndpoint ?? ""); + onThemeModeChange(normalizeThemeMode(next.preferences.openApiChatTheme)); + syncOpenApiPresetSelection(next); + setMessage(next.preferences.openApiChatEndpoint ? "Custom chat endpoint saved." : "Returned to the default OpenAI endpoint."); + }); + } + + function applyOpenApiPreset() { + const preset = openApiEndpointPresets.find((entry) => entry.id === openApiPresetId) ?? openApiEndpointPresets[0]; + if (!preset) return; + if (preset.applyMode === "draft") { + setError(""); + setOpenApiEndpointDraft(preset.endpoint); + setMessage(`${preset.label} loaded into the endpoint field. ${preset.description}`); + return; + } + setOpenApiEndpointDraft(preset.endpoint); + void run("Saving", async () => { + const next = await api.updatePreferences({ openApiChatEndpoint: preset.endpoint || undefined }); + setSettings(next); + setOpenApiEndpointDraft(next.preferences.openApiChatEndpoint ?? ""); + onThemeModeChange(normalizeThemeMode(next.preferences.openApiChatTheme)); + syncOpenApiPresetSelection(next); + setMessage(preset.endpoint ? `${preset.label} endpoint applied.` : "Returned to the default OpenAI endpoint."); + }); + } + function updateReactionOverride(reaction: ReactionAnimationSettings["reactions"][number], value: UserSelectableAnimationState) { const queuedSave = reactionSaveQueue.current.catch(() => undefined).then(() => run("Saving", async () => { const latestReactions = await api.getReactionAnimationSettings(); @@ -946,6 +1265,8 @@ function SettingsView() { } const overrides = settings?.preferences.reactionAnimationOverrides ?? {}; + const selectedOpenApiPreset = openApiEndpointPresets.find((entry) => entry.id === openApiPresetId) ?? openApiEndpointPresets[0]; + const openApiCredentialUi = getOpenApiCredentialUi(openApiPresetId); return
{error &&
{error}
} @@ -987,14 +1308,309 @@ function SettingsView() {
Pet scale - Adjust how large the default desktop pet appears. + Drag the slider to make the pet as large as the screen.
- patchPreferences({ petScale: Number(event.target.value) }, "Pet scale saved.")} + /> + {(settings?.preferences.petScale ?? 0.56).toFixed(2)}x +
+
+
+
+ Theme + Controls both the control center and the floating chat window. +
+
+
+
+
+ Base instructions + Toggle the built-in safety and behavior floor for the chat. +
+
+ +
+
+ +
+
+ Pet character + Sets the pet's tone, personality, and boundaries for future replies. +
+
+