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.
+
+
@@ -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.
@@ -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 ? `` : "";
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?.petScaleOptions ?? []).map((option) => {option.label} )}
+
+ 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.
+
+
patchPreferences({ openApiChatTheme: normalizeThemeMode(event.target.value) }, "Theme saved.")}
+ >
+ Auto
+ Dark
+ Light
+
+
+
+ Base instructions
+ Toggle the built-in safety and behavior floor for the chat.
+
+
+
+ {
+ const next = event.target.checked;
+ setOpenApiBaseInstructionsEnabled(next);
+ void patchPreferences({ openApiChatBaseInstructionsEnabled: next }, next ? "Base instructions enabled." : "Base instructions disabled.");
+ }}
+ />
+ Include OpenPets base instructions
+
+
+
+
+
+
+ Pet character
+ Sets the pet's tone, personality, and boundaries for future replies.
+
+
+
+
+
+
+ Model
+ Leave blank to fall back to the default chat model.
+
+
+ setOpenApiModelDraft(event.target.value)}
+ />
+ patchPreferences({ openApiChatModel: openApiModelDraft.trim() || undefined }, "Model saved.")}
+ >
+ Save
+
+
+
+
+
+
+ Provider presets
+ {selectedOpenApiPreset?.description ?? "Quick endpoint presets and templates."}
+
+
+ setOpenApiPresetId(event.target.value)}
+ >
+ {openApiEndpointPresets.map((preset) => (
+ {preset.label}
+ ))}
+
+
+ Apply
+
+
+
+
+
+
+ Endpoint
+ Leave blank for OpenAI, or use a base ending in /v1 or a full /responses or /chat/completions URL. Plain http is limited to localhost.
+
+
+ setOpenApiEndpointDraft(event.target.value)}
+ />
+
+ Save
+
+ {
+ setOpenApiEndpointDraft("");
+ void run("Saving", async () => {
+ const next = await api.updatePreferences({ openApiChatEndpoint: undefined });
+ setSettings(next);
+ syncOpenApiPresetSelection(next);
+ setMessage("Returned to the default OpenAI endpoint.");
+ });
+ }}
+ >
+ Default
+
+
+
+
+
+
+ {openApiCredentialUi.label}
+
+ One saved credential is used for the selected preset or endpoint.
+ {" "}Status: {settings?.openApiChat.hasCredential ? "configured" : "missing"}.
+ {" "}Storage: {settings?.openApiChat.storageMode ?? "plain"}.
+
+
+
+ setOpenApiCredential(event.target.value)}
+ />
+ void run("Saving", async () => {
+ const openApiChat = await api.saveOpenApiCredential(openApiCredential);
+ setSettings((current) => current ? { ...current, openApiChat } : current);
+ setOpenApiCredential("");
+ setMessage(openApiCredentialUi.savedMessage);
+ })}
+ >
+ Save
+
+ void run("Clearing", async () => {
+ const openApiChat = await api.clearOpenApiCredential();
+ setSettings((current) => current ? { ...current, openApiChat } : current);
+ setOpenApiCredential("");
+ setMessage(openApiCredentialUi.clearedMessage);
+ })}
+ >
+ Clear
+
+
+
+
+
+
+
+
+ Memories
+ View, search, edit, and delete stored memories.
+
+
+
+ setMemoryQuery(event.target.value)}
+ onKeyDown={(event) => { if (event.key === "Enter") void loadMemories(memoryQuery); }}
+ />
+ void loadMemories(memoryQuery)}>Search
+ { setMemoryQuery(""); void loadMemories(""); }}>All
+
+
+ {memories.length === 0 &&
No memories found.
}
+ {memories.map((memory) => (
+
+
+
{memory.kind}
+
+ {
+ const nextText = window.prompt("Edit memory:", memory.text);
+ if (nextText === null || nextText.trim() === memory.text) return;
+ void (async () => {
+ setMemoryBusy(true);
+ try {
+ await api.updateMemory(memory.id, nextText.trim(), memory.kind, memory.tags, memory.importance);
+ setMessage("Memory updated.");
+ await loadMemories(memoryQuery);
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ } finally {
+ setMemoryBusy(false);
+ }
+ })();
+ }}>Edit
+ {
+ if (!window.confirm("Delete this memory?")) return;
+ void (async () => {
+ setMemoryBusy(true);
+ try {
+ await api.deleteMemory(memory.id);
+ setMessage("Memory deleted.");
+ await loadMemories(memoryQuery);
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ } finally {
+ setMemoryBusy(false);
+ }
+ })();
+ }}>Delete
+
+
+
{memory.text}
+
+ {memory.tags.map((tag) => {tag} )}
+ importance {memory.importance}
+
+
+ ))}
+
+
+
+
+
void run("Resetting", async () => { setSettings(await api.resetDefaultPetPosition()); setMessage("Default pet position reset."); })}>Reset Pet Position
@@ -1351,6 +1967,7 @@ function IntegrationIcon({ id }: { id: string }) {
};
const src = logos[id];
if (src) return ;
+ if (id === "mcp-toolkit") return ;
return ;
}
@@ -1376,9 +1993,22 @@ function cursorStatusTone(state: CursorSetupStatus["state"]): StatusTone {
return "slate";
}
+function mcpToolkitTrustTone(trust: "official" | "hosted" | "gateway" | "community"): StatusTone {
+ if (trust === "official") return "green";
+ if (trust === "hosted") return "blue";
+ if (trust === "gateway") return "orange";
+ return "slate";
+}
+
function IntegrationsView() {
const [snapshot, setSnapshot] = useState(null);
const [selectedId, setSelectedId] = useState(null);
+ const [selectedToolkitId, setSelectedToolkitId] = useState(mcpToolkitEntries[0]?.id ?? "");
+ const [toolkitInstallMode, setToolkitInstallMode] = useState("manual");
+ const [toolkitPersistentTarget, setToolkitPersistentTarget] = useState("claude-user");
+ const [toolkitLastInstall, setToolkitLastInstall] = useState(null);
+ const [vanillaChatTools, setVanillaChatTools] = useState([]);
+ const [vanillaChatSaving, setVanillaChatSaving] = useState(false);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
@@ -1403,6 +2033,17 @@ function IntegrationsView() {
return () => window.clearTimeout(timeout);
}, [message]);
+ useEffect(() => {
+ void (async () => {
+ try {
+ const state = await api.getVanillaChatMcpTools();
+ setVanillaChatTools(state.enabled);
+ } catch {
+ // ignore
+ }
+ })();
+ }, []);
+
const run = async (label: string, action: AgentSetupAction) => {
try {
setBusy(label);
@@ -1438,6 +2079,65 @@ function IntegrationsView() {
void load(snapshot?.selectedPetId, mode);
};
+ const copyText = async (label: string, value: string) => {
+ try {
+ await api.copyText(value);
+ setMessage(`${label} copied.`);
+ setError("");
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ }
+ };
+
+ const openDocs = async (url: string) => {
+ try {
+ await api.openExternalUrl(url);
+ setError("");
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ }
+ };
+
+ const saveVanillaChatTools = async (toolIds: string[]) => {
+ try {
+ setVanillaChatSaving(true);
+ setError("");
+ const saved = await api.setVanillaChatMcpTools(toolIds);
+ setVanillaChatTools(saved);
+ setMessage(saved.length > 0 ? `Activated ${saved.length} tool${saved.length === 1 ? "" : "s"} for vanilla chat.` : "Vanilla chat tools deactivated.");
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ } finally {
+ setVanillaChatSaving(false);
+ }
+ };
+
+ const toggleVanillaChatTool = (id: string) => {
+ setVanillaChatTools((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
+ };
+
+ const installToolkitHost = async () => {
+ try {
+ setBusy("Installing toolkit");
+ setError("");
+ setMessage("");
+ const result = await api.installMcpToolkit(toolkitPersistentTarget);
+ setToolkitLastInstall(result);
+ if (result.installed.length > 0) {
+ const installedSummary = result.installed.map((entry) => entry.name).join(", ");
+ setMessage(result.skipped.length > 0
+ ? `Installed ${installedSummary}. Some lanes still need manual follow-up.`
+ : `Installed ${installedSummary}.`);
+ } else {
+ setMessage("No toolkit MCPs were installed automatically. Check the details below.");
+ }
+ } catch (err) {
+ setError(String((err as Error)?.message ?? err));
+ } finally {
+ setBusy("");
+ }
+ };
+
if (!snapshot) {
return (
@@ -1449,12 +2149,16 @@ function IntegrationsView() {
const isBusy = Boolean(busy) || snapshot.busy;
const integrationDialogTitleId = selectedId ? `integration-detail-title-${selectedId}` : undefined;
+ const selectedToolkit = getMcpToolkitEntry(selectedToolkitId) ?? mcpToolkitEntries[0];
+ const persistentBundle = buildPersistentToolkitBundle(toolkitPersistentTarget);
+ const persistentFollowUps = getPersistentToolkitFollowUps();
const integrations = [
{ id: "claude", name: "Claude Code", icon: "claude", status: snapshot.status.label, tone: claudeStatusTone(snapshot.status.state), description: "Connect Claude Code to your OpenPets companion." },
{ id: "opencode", name: "OpenCode", icon: "opencode", status: snapshot.opencodeStatus.label, tone: opencodeStatusTone(snapshot.opencodeStatus.state), description: "Connect OpenCode globally to your OpenPets companion." },
{ id: "cursor", name: "Cursor", icon: "cursor", status: snapshot.cursorStatus.label, tone: cursorStatusTone(snapshot.cursorStatus.state), description: "Connect Cursor to your OpenPets companion via global MCP config." },
{ id: "pi", name: "Pi", icon: "pi", status: "Manual", tone: "blue" satisfies StatusTone, description: "Connect Pi coding-agent activity through the OpenPets Pi extension package." },
+ { id: "mcp-toolkit", name: "MCP Toolkit", icon: "mcp-toolkit", status: "Curated", tone: "purple" satisfies StatusTone, description: "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes." },
] as const;
const soon = [
@@ -1463,7 +2167,11 @@ function IntegrationsView() {
{ name: "Zed", icon: "zed" },
];
- const selectedIntegrationName = selectedId === "pi" ? "Pi" : integrations.find((item) => item.id === selectedId)?.name;
+ const selectedIntegrationName = selectedId === "pi"
+ ? "Pi"
+ : selectedId === "mcp-toolkit"
+ ? "MCP Toolkit"
+ : integrations.find((item) => item.id === selectedId)?.name;
return (
@@ -1540,7 +2248,7 @@ function IntegrationsView() {
{commandModeLabels.bundled}
{commandModeLabels.local}{snapshot.localDevAvailable ? "" : " unavailable"}
-
Use the published package for normal setup, bundled for the desktop app build, or local while developing OpenPets.
+
Choose where the OpenPets MCP command comes from. Stable release uses the published npm package. Shipped with app uses the CLI bundled inside this desktop build. Local source is for developers running OpenPets from source.
)}
@@ -1766,6 +2474,297 @@ function IntegrationsView() {
)}
+
+ {selectedId === "mcp-toolkit" && (
+ <>
+
+ Install Choice Toolkit behavior
+
+ Choose how you want to set up MCP tools for external host agents like Claude Code or Codex CLI.
+
+
+ setToolkitInstallMode("manual")}
+ >
+ Manual Setup
+
+ setToolkitInstallMode("persistent")}
+ >
+ Persistent Full Access
+
+
+
+ Manual Setup shows copy-paste commands for each tool so you can install them yourself into your host agent. Persistent Full Access lets OpenPets register the supported baseline automatically into Claude Code or Codex CLI.
+
+
+
+
+ Vanilla Chat Activate tools for built-in chat
+
+ Select which MCP tools the pet can use directly in the floating chat window. When active, the pet can run terminal commands, browse files, fetch web pages, and more.
+
+
+ {(["starter", "system", "advanced"] as const).map((tier) => {
+ const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier);
+ return (
+
+
+ {mcpToolkitTierLabels[tier]}
+ {entries.length} options
+
+
+ {entries.map((entry) => {
+ const isSelected = vanillaChatTools.includes(entry.id);
+ return (
+ toggleVanillaChatTool(entry.id)}
+ title={isSelected ? "Click to deactivate" : "Click to activate for vanilla chat"}
+ >
+
+ {entry.name}
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+ } disabled={isBusy || vanillaChatSaving} onClick={() => void saveVanillaChatTools(vanillaChatTools)}>
+ Save & Activate
+
+ void saveVanillaChatTools([])}>
+ Deactivate All
+
+
+
+ Only tools with a green dot are active in the vanilla chat. The pet will only use tools that are both selected and successfully started by the system.
+
+
+
+
+ Catalog Browse & Details
+
+ Click any tool below to view its details, installation snippets, and safety notes.
+
+
+ {(["starter", "system", "advanced"] as const).map((tier) => {
+ const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier);
+ return (
+
+
+ {mcpToolkitTierLabels[tier]}
+ {entries.length} options
+
+
+ {entries.map((entry) => (
+ setSelectedToolkitId(entry.id)}
+ >
+ {entry.name}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+ {toolkitInstallMode === "manual" ? (
+ <>
+
+
+
+ {mcpToolkitTierLabels[selectedToolkit.tier]}
+ {selectedToolkit.name}
+
+
+ {selectedToolkit.badge}
+ } onClick={() => void openDocs(selectedToolkit.docsUrl)}>
+ {selectedToolkit.docsLabel ?? "Open docs"}
+
+
+
+
+ {selectedToolkit.summary}
+
+
+
+
Why it matters
+
{selectedToolkit.whyItMatters}
+
+
+
VectorShell fit
+
{selectedToolkit.vectorShellFit}
+
+
+
+
+
Permission boundary
+
{selectedToolkit.safety}
+
+
+
+ {selectedToolkit.tags.map((tag) => (
+ {tag}
+ ))}
+
+
+
+ {selectedToolkit.snippets?.length ? (
+
+ Install Surface Ready-to-paste snippets
+
+ {selectedToolkit.snippets.map((snippet) => (
+
+
+
+ {snippet.label}
+ {snippet.description && {snippet.description} }
+
+
} onClick={() => void copyText(snippet.label, snippet.value)}>
+ Copy
+
+
+
{snippet.value}
+
+ ))}
+
+
+ ) : null}
+
+ {selectedToolkit.notes?.length ? (
+
+ Notes Selection guidance
+
+ {selectedToolkit.notes.map((note) => {note} )}
+
+
+ ) : null}
+ >
+ ) : (
+ <>
+
+ Persistent Install Choose your host
+
+ This mode is for the “install it for good” path. OpenPets can install the supported persistent baseline for you now, and it also shows the exact matching bundle if you prefer to run it yourself.
+
+
+ setToolkitPersistentTarget("claude-user")}
+ >
+ Claude User Scope
+
+ setToolkitPersistentTarget("codex-global")}
+ >
+ Codex Global
+
+
+
+
+
+
+
+ Persistent Full Access
+ {persistentBundle.label}
+
+
+ } disabled={isBusy} onClick={() => void installToolkitHost()}>
+ Install Now
+
+ } onClick={() => void copyText(persistentBundle.label, persistentBundle.value)}>
+ Copy Bundle
+
+
+
+ {persistentBundle.description}
+ {persistentBundle.value}
+
+
+
+ Included Now Stable persistent baseline
+
+ {["filesystem", "playwright", "browser-use", "memory", "fetch-web", "sequential-thinking"].map((id) => {
+ const entry = getMcpToolkitEntry(id);
+ return entry ? {entry.name} : null;
+ })}
+ Context7 / Docs
+
+
+ These are the lanes OpenPets can attempt automatically today. Browser Use and Fetch / Web still depend on `uvx`, and Browser Use still needs its own runtime credential in the host environment.
+
+
+
+ {toolkitLastInstall && toolkitLastInstall.target === toolkitPersistentTarget && (
+
+ Last Install {toolkitLastInstall.label}
+ {toolkitLastInstall.installed.length > 0 && (
+
+ {toolkitLastInstall.installed.map((entry) => (
+
+
+ {entry.name}
+ Installed
+
+
{entry.detail}
+
+ ))}
+
+ )}
+ {toolkitLastInstall.skipped.length > 0 && (
+
+ {toolkitLastInstall.skipped.map((entry) => (
+
+
+ {entry.name}
+ Skipped
+
+
{entry.detail}
+
+ ))}
+
+ )}
+ {toolkitLastInstall.notes.length > 0 && (
+
+ {toolkitLastInstall.notes.map((note) => {note} )}
+
+ )}
+
+ )}
+
+
+ Manual Follow-Up Still better done explicitly
+
+ These lanes stay outside the one-shot persistent bundle for now because they are more dependent on auth, provider choice, host environment, or high-risk permission boundaries.
+
+
+ {persistentFollowUps.map((name) => (
+ {name}
+ ))}
+
+
+ >
+ )}
+ >
+ )}
@@ -1996,6 +2995,7 @@ function PluginsView() {
function App() {
const [currentRoute, setCurrentRoute] = useState(() => initialControlCenterRoute());
+ const [themeMode, setThemeMode] = useState("system");
const [state, setState] = useState(null);
const [catalog, setCatalog] = useState(null);
const [catalogPages, setCatalogPages] = useState>({});
@@ -2014,6 +3014,23 @@ function App() {
if (isRoute(route)) setCurrentRoute(route);
}), []);
+ useEffect(() => {
+ let cancelled = false;
+ void api.getSettingsState().then((settings) => {
+ if (!cancelled) {
+ setThemeMode(normalizeThemeMode(settings.preferences.openApiChatTheme));
+ }
+ }).catch(() => undefined);
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ const applyTheme = () => applyDocumentTheme(themeMode);
+ applyTheme();
+ themeMediaQuery.addEventListener("change", applyTheme);
+ return () => themeMediaQuery.removeEventListener("change", applyTheme);
+ }, [themeMode]);
+
async function loadPetsData() {
setError("");
const [nextState, nextCatalog, nextCodex] = await Promise.all([api.getPetsState(), api.getCatalog(), api.getCodexPets()]);
@@ -2259,7 +3276,7 @@ function App() {
{currentRoute === "dashboard" ? (
) : currentRoute === "settings" ? (
-
+
) : currentRoute === "plugins" ? (
) : currentRoute === "integrations" ? (
diff --git a/apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts b/apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
new file mode 100644
index 00000000..9fb3e574
--- /dev/null
+++ b/apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
@@ -0,0 +1,533 @@
+export type McpToolkitTier = "starter" | "system" | "advanced";
+export type McpToolkitTrust = "official" | "hosted" | "gateway" | "community";
+
+export type McpToolkitSnippet = {
+ readonly id: string;
+ readonly label: string;
+ readonly language: "bash" | "json" | "text";
+ readonly value: string;
+ readonly description?: string;
+};
+
+export type McpToolkitEntry = {
+ readonly id: string;
+ readonly name: string;
+ readonly tier: McpToolkitTier;
+ readonly trust: McpToolkitTrust;
+ readonly badge: string;
+ readonly summary: string;
+ readonly whyItMatters: string;
+ readonly vectorShellFit: string;
+ readonly safety: string;
+ readonly docsUrl: string;
+ readonly docsLabel?: string;
+ readonly tags: readonly string[];
+ readonly snippets?: readonly McpToolkitSnippet[];
+ readonly notes?: readonly string[];
+};
+
+export const mcpToolkitTierLabels: Record = {
+ starter: "Starter Stack",
+ system: "Terminal & Systems",
+ advanced: "Later / Advanced",
+};
+
+export const mcpToolkitEntries: readonly McpToolkitEntry[] = [
+ {
+ id: "filesystem",
+ name: "Filesystem",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Controlled file reads, writes, moves, and search with explicit directory boundaries.",
+ whyItMatters: "It is the foundation for coding, reverse engineering, and repo maintenance work.",
+ vectorShellFit: "Great default for Unreal, docs, scripts, and bounded repo custody work.",
+ safety: "Only grant the project roots the agent really needs. Avoid pointing it at your whole home directory.",
+ docsUrl: "https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/README.md",
+ tags: ["files", "search", "write", "bounded"],
+ snippets: [
+ {
+ id: "filesystem-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /path/to/project",
+ description: "Swap `/path/to/project` for the repo root or another tightly scoped folder.",
+ },
+ {
+ id: "filesystem-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
+ }
+ }
+}`,
+ description: "Use one or more explicit allowed paths. Do not leave it unscoped.",
+ },
+ ],
+ },
+ {
+ id: "git",
+ name: "Git",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Semantic access to branches, commits, diffs, blame, and repository history.",
+ whyItMatters: "Lets agents inspect repo state without brittle parsing of raw terminal output.",
+ vectorShellFit: "Especially useful for repo-evaluation, packet work, and implementation audits.",
+ safety: "Prefer a single target repository per server instance instead of broad multi-repo exposure.",
+ docsUrl: "https://github.com/modelcontextprotocol/servers",
+ docsLabel: "Official servers repo",
+ tags: ["git", "diffs", "history", "review"],
+ snippets: [
+ {
+ id: "git-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add git -- uvx mcp-server-git --repository /path/to/repo",
+ },
+ {
+ id: "git-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "git": {
+ "command": "uvx",
+ "args": ["mcp-server-git", "--repository", "/path/to/repo"]
+ }
+ }
+}`,
+ },
+ ],
+ },
+ {
+ id: "github",
+ name: "GitHub",
+ tier: "starter",
+ trust: "hosted",
+ badge: "Hosted",
+ summary: "Issues, pull requests, repo metadata, code search, and GitHub workflow context.",
+ whyItMatters: "Covers the hosted side of engineering work that local Git alone cannot see.",
+ vectorShellFit: "Helpful for repo custody, issue triage, CI visibility, and PR preparation.",
+ safety: "Prefer GitHub's hosted OAuth flow or a least-privilege PAT. Keep write scopes narrow.",
+ docsUrl: "https://docs.github.com/copilot/how-tos/provide-context/use-mcp-in-your-ide/set-up-the-github-mcp-server",
+ tags: ["github", "issues", "prs", "ci"],
+ snippets: [
+ {
+ id: "github-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "servers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/"
+ }
+ }
+}`,
+ description: "This is GitHub's hosted MCP endpoint. Some clients can complete OAuth automatically.",
+ },
+ {
+ id: "github-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add --transport http github https://api.githubcopilot.com/mcp/",
+ description: "Use a PAT or the client's OAuth flow if the host prompts for authentication.",
+ },
+ ],
+ },
+ {
+ id: "playwright",
+ name: "Playwright",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Structured browser automation and testing through accessibility snapshots instead of screenshots alone.",
+ whyItMatters: "Great for verification, regression checks, form filling, and deterministic browser workflows.",
+ vectorShellFit: "Use it for front-door sweeps, web validation, and browser-driven debugging.",
+ safety: "Only enable extra capabilities you actually need. The unsafe code-execution tool is effectively RCE.",
+ docsUrl: "https://playwright.dev/docs/getting-started-mcp",
+ tags: ["browser", "testing", "automation", "ui"],
+ snippets: [
+ {
+ id: "playwright-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add playwright npx @playwright/mcp@latest",
+ },
+ {
+ id: "playwright-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": ["@playwright/mcp@latest"]
+ }
+ }
+}`,
+ description: "Add flags such as `--headless` or `--caps=network,storage` only when needed.",
+ },
+ ],
+ },
+ {
+ id: "browser-use",
+ name: "Browser Use",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Agentic browser workflows with both a local stdio MCP server and a hosted MCP endpoint.",
+ whyItMatters: "Useful when you want higher-level browser task execution rather than raw low-level page control.",
+ vectorShellFit: "A good companion to Playwright when you want broader browser-use tasks or cloud execution.",
+ safety: "The local server needs its own LLM credential. Keep browser-use permissions narrow and avoid unsafe security disables.",
+ docsUrl: "https://docs.browser-use.com/open-source/customize/integrations/docs-mcp",
+ tags: ["browser", "agentic", "cloud", "tasks"],
+ snippets: [
+ {
+ id: "browser-use-local",
+ label: "Local stdio",
+ language: "bash",
+ value: "claude mcp add browser-use -- uvx --from 'browser-use[cli]' browser-use --mcp",
+ description: "Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in the host client environment.",
+ },
+ {
+ id: "browser-use-cloud",
+ label: "Hosted HTTP",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "browser-use": {
+ "url": "https://api.browser-use.com/v3/mcp",
+ "headers": {
+ "x-browser-use-api-key": "YOUR_API_KEY"
+ }
+ }
+ }
+}`,
+ description: "Use the hosted endpoint when you want managed remote browser sessions.",
+ },
+ ],
+ },
+ {
+ id: "databases",
+ name: "SQLite / PostgreSQL",
+ tier: "starter",
+ trust: "gateway",
+ badge: "Catalog",
+ summary: "Database inspection and safe querying for local SQLite files or shared Postgres instances.",
+ whyItMatters: "Critical for data-aware agents, schema review, and debugging stateful systems.",
+ vectorShellFit: "Use SQLite for local artifacts and Postgres for services, telemetry, or ops workflows.",
+ safety: "Prefer read-only credentials whenever possible. Keep production write access behind a stronger gate than dev.",
+ docsUrl: "https://docs.docker.com/ai/mcp-catalog-and-toolkit/cli/",
+ docsLabel: "Docker MCP CLI",
+ tags: ["sqlite", "postgres", "database", "query"],
+ notes: [
+ "The official MCP servers repo still documents Postgres examples, but its database servers are no longer the main reference focus.",
+ "For a stable day-to-day workflow, I recommend discovering the current Postgres or SQLite server through Docker MCP Toolkit or your host's MCP catalog instead of hard-wiring a stale package name here.",
+ ],
+ snippets: [
+ {
+ id: "databases-docker-catalog",
+ label: "Catalog lookup",
+ language: "bash",
+ value: "docker mcp catalog server ls mcp/docker-mcp-catalog --filter name=postgres\n\ndocker mcp catalog server ls mcp/docker-mcp-catalog --filter name=sqlite",
+ description: "Use the catalog to find the current server IDs before enabling them in a profile.",
+ },
+ ],
+ },
+ {
+ id: "memory",
+ name: "Memory",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Persistent project facts, conventions, and architecture decisions for the host agent.",
+ whyItMatters: "Lets the agent keep durable context without burying everything inside prompts or chat history.",
+ vectorShellFit: "Useful for doctrine, implementation decisions, recurring repo conventions, and personal preferences.",
+ safety: "Treat memory as durable storage. Do not put secrets or highly sensitive transient data into it.",
+ docsUrl: "https://github.com/modelcontextprotocol/servers",
+ docsLabel: "Official servers repo",
+ tags: ["memory", "facts", "decisions", "persistence"],
+ notes: [
+ "OpenPets now also has its own pet-side memory system and `openpets_memory_*` tools. This entry is for the host agent's broader project memory.",
+ ],
+ snippets: [
+ {
+ id: "memory-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add memory -- npx -y @modelcontextprotocol/server-memory",
+ },
+ {
+ id: "memory-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "memory": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-memory"]
+ }
+ }
+}`,
+ },
+ ],
+ },
+ {
+ id: "fetch-web",
+ name: "Fetch / Web",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Web retrieval and page-to-markdown extraction for docs lookup without full browser automation.",
+ whyItMatters: "Perfect for docs, API references, and lightweight retrieval when a browser would be overkill.",
+ vectorShellFit: "Use it for policy docs, upstream API references, and quick source gathering.",
+ safety: "This kind of server can reach internal or local addresses if misconfigured. Keep your network exposure in mind.",
+ docsUrl: "https://github.com/modelcontextprotocol/servers/blob/main/src/fetch/README.md",
+ tags: ["fetch", "docs", "web", "lookup"],
+ snippets: [
+ {
+ id: "fetch-json",
+ label: "JSON client",
+ language: "json",
+ value: `{
+ "mcpServers": {
+ "fetch": {
+ "command": "uvx",
+ "args": ["mcp-server-fetch"]
+ }
+ }
+}`,
+ },
+ ],
+ },
+ {
+ id: "sequential-thinking",
+ name: "Sequential Thinking",
+ tier: "starter",
+ trust: "official",
+ badge: "Official",
+ summary: "Structured multi-step reasoning for planning, branching, revising, and surfacing uncertainty.",
+ whyItMatters: "Useful when the work is ambiguous, high-stakes, or requires explicit reasoning checkpoints.",
+ vectorShellFit: "Strong fit for doctrine reconciliation, implementation packet planning, and non-trivial debugging.",
+ safety: "Disable detailed thought logging when the workflow could contain sensitive reasoning or copied secrets.",
+ docsUrl: "https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking/README.md",
+ tags: ["planning", "reasoning", "branching", "debugging"],
+ snippets: [
+ {
+ id: "sequential-thinking-claude",
+ label: "Claude Code",
+ language: "bash",
+ value: "claude mcp add sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking",
+ },
+ {
+ id: "sequential-thinking-codex",
+ label: "Codex CLI",
+ language: "bash",
+ value: "codex mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking",
+ },
+ ],
+ },
+ {
+ id: "docker",
+ name: "Docker MCP Toolkit",
+ tier: "starter",
+ trust: "gateway",
+ badge: "Gateway",
+ summary: "A safer execution layer for discovering, running, and connecting multiple MCP servers through Docker.",
+ whyItMatters: "It centralizes lifecycle, isolation, and client hookups instead of running every server directly on the host.",
+ vectorShellFit: "A strong fit once your stack has multiple MCPs or you want cleaner profile-based switching.",
+ safety: "Prefer Docker profiles and the MCP Gateway when the server ecosystem feels uneven or over-privileged.",
+ docsUrl: "https://docs.docker.com/ai/mcp-catalog-and-toolkit/get-started/",
+ tags: ["docker", "gateway", "profiles", "isolation"],
+ snippets: [
+ {
+ id: "docker-connect",
+ label: "CLI flow",
+ language: "bash",
+ value: "docker mcp profile create --name vectorshell\n\ndocker mcp client connect cursor --profile vectorshell\n\ndocker mcp gateway run --profile vectorshell",
+ description: "After that, add servers to the profile through Docker Desktop or the `docker mcp` catalog commands.",
+ },
+ ],
+ },
+ {
+ id: "shell",
+ name: "Shell / Terminal",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "Actual command execution for Bash, PowerShell, Zsh, Fish, builds, tests, and tool launch.",
+ whyItMatters: "This is the workhorse for coding agents, but it is also the riskiest tool class.",
+ vectorShellFit: "Essential for reverse engineering, build orchestration, sysadmin work, and repo-scale automation.",
+ safety: "Tight sandboxing matters here more than anywhere else. Prefer containerized or approval-gated execution.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["terminal", "bash", "powershell", "execution"],
+ notes: [
+ "I did not pin a default shell MCP server here because this part of the ecosystem is broad and uneven.",
+ "For serious use, start with Docker-isolated execution or a reviewed server with strict allowlists and auditing.",
+ ],
+ },
+ {
+ id: "process-logs",
+ name: "Process & Logs",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "Process inspection, crash checks, resource usage, and dedicated log analysis.",
+ whyItMatters: "Separating process and log tooling from raw shell reduces noise and makes debugging much faster.",
+ vectorShellFit: "Useful for Unreal builds, local model servers, Rider, Docker services, and long-running agent lanes.",
+ safety: "Keep scope narrow to the workloads you actually want the agent to inspect or stop.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["processes", "logs", "crashes", "monitoring"],
+ },
+ {
+ id: "system-info",
+ name: "System Info",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "CPU, RAM, disk, GPU, and network visibility for hardware-aware decisions.",
+ whyItMatters: "Helps agents choose sane model sizes, build parallelism, and cache strategies.",
+ vectorShellFit: "Handy for local-model work, build tuning, and remote-host resource checks.",
+ safety: "Treat network and hardware inventory as sensitive operational context when sharing transcripts.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["system", "cpu", "gpu", "resources"],
+ },
+ {
+ id: "ssh",
+ name: "SSH",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "Remote-host and jump-box access for fleet, VPS, and lab administration.",
+ whyItMatters: "One of the highest-value MCPs once your workflow spans more than one machine.",
+ vectorShellFit: "Near-mandatory for your VPS and reverse-tunnel style workflows.",
+ safety: "Use locked-down keys, host allowlists, and command restrictions. Avoid broad root-capable sessions.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["ssh", "remote", "vps", "fleet"],
+ },
+ {
+ id: "package-manager",
+ name: "Package Manager",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "Semantic installs and upgrades for apt, winget, npm, pip, cargo, nuget, and friends.",
+ whyItMatters: "Cleaner than free-form shell installs when you want agents to reason about dependencies.",
+ vectorShellFit: "Good for repeatable workstation setup, toolchain repairs, and environment bootstrap lanes.",
+ safety: "Keep package-manager permissions separate from broad shell access when possible.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["packages", "dependencies", "bootstrap", "ops"],
+ },
+ {
+ id: "tmux",
+ name: "Tmux",
+ tier: "system",
+ trust: "community",
+ badge: "BYO",
+ summary: "Persistent sessions, pane orchestration, and monitoring for long-running jobs.",
+ whyItMatters: "Excellent for compilation, indexing, and long AI tasks that should survive client restarts.",
+ vectorShellFit: "A strong add-on for VPS-heavy and terminal-first workflows.",
+ safety: "Expose only the session namespace you want the agent to control.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["tmux", "sessions", "long-jobs", "terminal"],
+ },
+ {
+ id: "kubernetes-cloud",
+ name: "Kubernetes / Cloud",
+ tier: "advanced",
+ trust: "community",
+ badge: "Later",
+ summary: "Cluster and cloud control once deployment complexity actually warrants it.",
+ whyItMatters: "Useful later, but it is overkill for smaller or mostly local lanes.",
+ vectorShellFit: "Add only when the environment genuinely needs multi-service or multi-region orchestration.",
+ safety: "Use the smallest cloud role and cluster scope possible. This class can become dangerously overpowered fast.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["kubernetes", "cloud", "deployment", "ops"],
+ },
+ {
+ id: "ci-cd",
+ name: "CI / CD",
+ tier: "advanced",
+ trust: "community",
+ badge: "Later",
+ summary: "Inspect pipeline failures, artifacts, and reruns across GitHub Actions or other CI systems.",
+ whyItMatters: "Important once local changes frequently bounce off remote automation.",
+ vectorShellFit: "A natural next step after GitHub plus Docker are in regular use.",
+ safety: "Keep secrets, deployment approvals, and destructive promotion steps behind human gates.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["ci", "cd", "pipelines", "artifacts"],
+ },
+ {
+ id: "ghidra",
+ name: "Ghidra",
+ tier: "advanced",
+ trust: "community",
+ badge: "Later",
+ summary: "Structured navigation of symbols, functions, and decompilation during binary analysis.",
+ whyItMatters: "One of the highest-value future MCP categories for deep reverse engineering.",
+ vectorShellFit: "Highly relevant for the longer-term VectorShell reverse-engineering lane.",
+ safety: "Treat binaries and captured artifacts as sensitive research inputs, especially when they are proprietary.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["ghidra", "reverse-engineering", "decompile", "binary"],
+ },
+ {
+ id: "binary-analysis",
+ name: "Binary Analysis",
+ tier: "advanced",
+ trust: "community",
+ badge: "Later",
+ summary: "Radare2, Rizin, Binary Ninja, and adjacent binary-inspection tooling.",
+ whyItMatters: "Pushes the environment beyond coding assistant territory into systems analysis.",
+ vectorShellFit: "Strong future fit once you want multiple reverse-engineering backends, not just Ghidra.",
+ safety: "Keep samples isolated and watch licensing or platform restrictions on commercial tools.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["binary", "rizin", "radare2", "analysis"],
+ },
+ {
+ id: "network-analysis",
+ name: "Network Analysis",
+ tier: "advanced",
+ trust: "community",
+ badge: "Later",
+ summary: "Protocol inspection and packet analysis with tools such as Wireshark, Zeek, or Suricata.",
+ whyItMatters: "Powerful for protocol understanding, troubleshooting, and deeper systems work.",
+ vectorShellFit: "Worth adding once protocol work becomes a repeat lane instead of a rare task.",
+ safety: "Network captures can contain credentials or private data. Treat them as highly sensitive.",
+ docsUrl: "https://github.com/modelcontextprotocol/registry",
+ docsLabel: "MCP Registry",
+ tags: ["network", "packets", "protocols", "traffic"],
+ },
+] as const;
+
+export const vectorShellStarterIds = [
+ "filesystem",
+ "git",
+ "github",
+ "playwright",
+ "browser-use",
+ "databases",
+ "memory",
+ "fetch-web",
+ "sequential-thinking",
+ "docker",
+] as const;
+
+export function getMcpToolkitEntry(id: string): McpToolkitEntry | undefined {
+ return mcpToolkitEntries.find((entry) => entry.id === id);
+}
diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css
index c5942bfe..92eae57a 100644
--- a/apps/desktop/src/renderer/src/styles.css
+++ b/apps/desktop/src/renderer/src/styles.css
@@ -8,6 +8,137 @@
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(96, 165, 250, 0.25); border-radius: 100px; }
::-webkit-scrollbar-thumb:hover { background: rgba(96, 165, 250, 0.45); }
+ html[data-theme="dark"] { color-scheme: dark; }
+ html[data-theme="dark"] body { color:#e6ebf2; background: radial-gradient(circle at 12% 8%, rgba(255, 255, 255, 0.06), transparent 24%), linear-gradient(180deg, #242424 0%, #1c1c1c 54%, #151515 100%); }
+ html[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(180, 190, 205, 0.28); }
+ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(200, 210, 225, 0.42); }
+}
+
+@layer components {
+ html[data-theme="dark"] .glass,
+ html[data-theme="dark"] .settings-group,
+ html[data-theme="dark"] .plugin-section,
+ html[data-theme="dark"] .plugin-card,
+ html[data-theme="dark"] .pet-card,
+ html[data-theme="dark"] .integration-card,
+ html[data-theme="dark"] .dashboard-stat-card,
+ html[data-theme="dark"] .dashboard-activity-charts,
+ html[data-theme="dark"] .dashboard-chart-panel,
+ html[data-theme="dark"] .plugin-empty,
+ html[data-theme="dark"] .pet-detail-reactions,
+ html[data-theme="dark"] .settings-system-footer,
+ html[data-theme="dark"] .reaction-row,
+ html[data-theme="dark"] .plugin-config-row,
+ html[data-theme="dark"] .plugin-config-group,
+ html[data-theme="dark"] .plugin-list-item,
+ html[data-theme="dark"] .plugin-status-strip,
+ html[data-theme="dark"] .plugin-card-footer,
+ html[data-theme="dark"] .plugin-inspector-icon,
+ html[data-theme="dark"] .plugin-card-icon,
+ html[data-theme="dark"] .integration-icon,
+ html[data-theme="dark"] .reaction-preview-box,
+ html[data-theme="dark"] .stage,
+ html[data-theme="dark"] .thumb {
+ border-color: rgba(255, 255, 255, 0.1);
+ background: rgba(30, 30, 30, 0.82);
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), 0 20px 48px rgba(0,0,0,0.22);
+ }
+
+ html[data-theme="dark"] .hero-desc,
+ html[data-theme="dark"] .desc,
+ html[data-theme="dark"] .text-slatecopy,
+ html[data-theme="dark"] .settings-row-info small,
+ html[data-theme="dark"] .reaction-info small,
+ html[data-theme="dark"] .plugin-card-content small,
+ html[data-theme="dark"] .plugin-config-row small,
+ html[data-theme="dark"] .plugin-empty small,
+ html[data-theme="dark"] .dashboard-stat-footer,
+ html[data-theme="dark"] .dashboard-reaction-label,
+ html[data-theme="dark"] .dashboard-chart-heading small {
+ color: #aeb6c3 !important;
+ }
+
+ html[data-theme="dark"] .eyebrow,
+ html[data-theme="dark"] .dashboard-section-title,
+ html[data-theme="dark"] .plugin-section-title small,
+ html[data-theme="dark"] .settings-system-version {
+ color: #96b6ea !important;
+ }
+
+ html[data-theme="dark"] .text-navy,
+ html[data-theme="dark"] .card-title,
+ html[data-theme="dark"] .settings-row-info strong,
+ html[data-theme="dark"] .reaction-info strong,
+ html[data-theme="dark"] .plugin-card-content strong,
+ html[data-theme="dark"] .plugin-config-row strong,
+ html[data-theme="dark"] .plugin-section-title strong,
+ html[data-theme="dark"] .settings-section-title,
+ html[data-theme="dark"] .dashboard-stat-value,
+ html[data-theme="dark"] .dashboard-chart-heading span,
+ html[data-theme="dark"] .dashboard-hero-title,
+ html[data-theme="dark"] .plugin-empty strong {
+ color: #f3f5f8 !important;
+ }
+
+ html[data-theme="dark"] .search,
+ html[data-theme="dark"] .settings-select,
+ html[data-theme="dark"] .plugin-input,
+ html[data-theme="dark"] .plugin-chip {
+ border-color: rgba(255, 255, 255, 0.12);
+ background: rgba(18, 18, 18, 0.94);
+ color: #f3f5f8;
+ }
+
+ html[data-theme="dark"] .settings-slider {
+ background: rgba(255, 255, 255, 0.18);
+ }
+
+ html[data-theme="dark"] .filter,
+ html[data-theme="dark"] .nav-tab,
+ html[data-theme="dark"] .pager,
+ html[data-theme="dark"] .settings-nav-item,
+ html[data-theme="dark"] .btn-secondary {
+ border-color: rgba(255, 255, 255, 0.12);
+ background: rgba(34, 34, 34, 0.9);
+ color: #e6ebf2;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), 0 2px 10px rgba(0,0,0,0.18);
+ }
+
+ html[data-theme="dark"] .filter:hover:not(.active),
+ html[data-theme="dark"] .nav-tab:hover:not(.active),
+ html[data-theme="dark"] .settings-nav-item:hover,
+ html[data-theme="dark"] .btn-secondary:hover:not(:disabled) {
+ border-color: rgba(148, 163, 184, 0.28);
+ background: rgba(42, 42, 42, 0.96);
+ color: #f8fbff;
+ }
+
+ html[data-theme="dark"] .settings-nav-item.active {
+ background: linear-gradient(180deg, rgba(66, 133, 244, 0.24), rgba(37, 99, 235, 0.18));
+ border-color: rgba(110, 159, 237, 0.28);
+ color: #f8fbff;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 10px 24px rgba(0,0,0,0.24);
+ }
+
+ html[data-theme="dark"] .nav-bar {
+ border-bottom-color: rgba(255, 255, 255, 0.08);
+ }
+
+ html[data-theme="dark"] .plugin-config-backdrop {
+ background: rgba(8, 8, 8, 0.58);
+ }
+
+ html[data-theme="dark"] .error {
+ border-color: rgba(248, 113, 113, 0.28);
+ background: rgba(69, 16, 27, 0.72);
+ color: #fecaca;
+ }
+
+ html[data-theme="dark"] .settings-success {
+ border-color: rgba(52, 211, 153, 0.26);
+ background: rgba(6, 78, 59, 0.72);
+ color: #bbf7d0;
+ }
}
@layer components {
.app-shell { @apply mx-auto flex h-screen max-w-[1160px] flex-col p-6; }
@@ -112,6 +243,7 @@
.settings-toggle { @apply h-6 w-11 shrink-0 cursor-pointer accent-brand disabled:cursor-not-allowed; }
.settings-select { @apply min-h-10 min-w-[140px] rounded-xl border border-blue-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-navy outline-none focus:border-brand focus:ring-4 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60; }
+ .settings-slider { @apply h-2 w-full cursor-pointer accent-brand rounded-lg appearance-none bg-blue-200/50 disabled:cursor-not-allowed disabled:opacity-60; }
.settings-system-footer { @apply mt-2 flex items-center justify-between gap-4 p-4 rounded-2xl bg-blue-50/40 border border-blue-100/50; }
.settings-system-info { @apply flex items-center gap-3 text-xs font-bold text-slatecopy; }
diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts
index b79f3715..3fff251b 100644
--- a/apps/desktop/src/windows.ts
+++ b/apps/desktop/src/windows.ts
@@ -1,15 +1,18 @@
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
-import { app, BrowserWindow, dialog, ipcMain, protocol, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron";
+import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron";
import { getAgentSetupSnapshot, runAgentSetupAction, updateAgentSetupCommandPaths } from "./agent-setup.js";
import { refreshAgentPetContent } from "./agent-pet-controller.js";
-import { getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js";
+import { getAppStateSnapshot, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js";
import { createAppIcon } from "./assets.js";
import { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
-import { getCodexPetsUiState, importCodexPet, readCodexPetSpritesheet } from "./codex-pets.js";
import { recoverDefaultPetMouseInterop, refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js";
+import { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js";
+import { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js";
+import { clearOpenApiCredential, getOpenApiChatSettingsSnapshot, normalizeChatModel, resetOpenApiConversationContext, saveOpenApiCredential } from "./openapi-chat.js";
+import { forgetOpenPetsMemory, listOpenPetsMemories, searchOpenPetsMemories, storeOpenPetsMemory, updateOpenPetsMemory } from "./openpets-memory.js";
import { installPet, installPetFromFolder, installPetFromZipFile, removePet, setDefaultInstalledPet } from "./pet-installation.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { debug, error as logError, warn } from "./logger.js";
@@ -28,6 +31,10 @@ let pendingDockTimer: NodeJS.Timeout | null = null;
let lastDockHideAt = 0;
const dockHideShowCooldownMs = 1100;
+async function loadCodexPetsModule() {
+ return import("./codex-pets.js");
+}
+
function hasOpenInternalUiWindows(): boolean {
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) return true;
return false;
@@ -63,8 +70,9 @@ function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; pets:
}
function getSettingsStateSnapshot(): {
- preferences: Pick["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides">;
+ preferences: Pick["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides" | "openApiChatModel" | "openApiChatSystemPrompt" | "openApiChatEndpoint" | "openApiChatTheme" | "vanillaChatMcpTools" | "openApiChatBaseInstructionsEnabled">;
petScaleOptions: typeof petScaleOptions;
+ openApiChat: ReturnType;
} {
const state = getAppStateSnapshot();
return {
@@ -72,8 +80,15 @@ function getSettingsStateSnapshot(): {
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
petScale: state.preferences.petScale,
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
+ openApiChatModel: state.preferences.openApiChatModel,
+ openApiChatSystemPrompt: state.preferences.openApiChatSystemPrompt,
+ openApiChatEndpoint: state.preferences.openApiChatEndpoint,
+ openApiChatTheme: state.preferences.openApiChatTheme,
+ vanillaChatMcpTools: state.preferences.vanillaChatMcpTools,
+ openApiChatBaseInstructionsEnabled: state.preferences.openApiChatBaseInstructionsEnabled,
},
petScaleOptions,
+ openApiChat: getOpenApiChatSettingsSnapshot(),
};
}
@@ -143,6 +158,93 @@ export function installInternalUiHandlers(): void {
return getDashboardSnapshot();
});
+ ipcMain.handle("openpets:get-openapi-chat-settings", (event) => {
+ assertAllowedSender(event, ["control-center"]);
+ return getOpenApiChatSettingsSnapshot();
+ });
+
+ ipcMain.handle("openpets:save-openapi-credential", (event, apiKey: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (typeof apiKey !== "string") throw new Error("Invalid chat credential.");
+ return saveOpenApiCredential(apiKey);
+ });
+
+ ipcMain.handle("openpets:clear-openapi-credential", (event) => {
+ assertAllowedSender(event, ["control-center"]);
+ return clearOpenApiCredential();
+ });
+
+ ipcMain.handle("openpets:install-mcp-toolkit", async (event, target: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (target !== "claude-user" && target !== "codex-global") {
+ throw new Error("Invalid MCP toolkit target.");
+ }
+ return installPersistentToolkit(target as McpToolkitPersistentTarget);
+ });
+
+ ipcMain.handle("openpets:get-vanilla-chat-mcp-tools", async (event) => {
+ assertAllowedSender(event, ["control-center"]);
+ const state = getAppStateSnapshot();
+ return {
+ enabled: state.preferences.vanillaChatMcpTools ?? [],
+ available: listMcpChatVanillaServerIds(),
+ active: getMcpChatClientManager().getActiveServerIds(),
+ };
+ });
+
+ ipcMain.handle("openpets:set-vanilla-chat-mcp-tools", async (event, toolIds: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (!Array.isArray(toolIds) || !toolIds.every((id) => typeof id === "string")) {
+ throw new Error("Invalid vanilla chat MCP tool list.");
+ }
+ const validIds = new Set(listMcpChatVanillaServerIds());
+ const sanitized = toolIds.filter((id: string) => validIds.has(id));
+ updatePreferences({ vanillaChatMcpTools: sanitized });
+ await getMcpChatClientManager().startEnabledServers(sanitized);
+ return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
+ });
+
+ ipcMain.handle("openpets:get-memories", async (event, query: unknown, limit: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ const q = typeof query === "string" ? query.trim() : "";
+ const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 50;
+ if (q) return searchOpenPetsMemories(q, l).map((h) => h.entry);
+ return listOpenPetsMemories(l);
+ });
+
+ ipcMain.handle("openpets:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
+ return storeOpenPetsMemory({
+ text: text.trim(),
+ kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
+ tags: Array.isArray(tags) ? tags.filter((t): t is string => typeof t === "string") : undefined,
+ importance: typeof importance === "number" ? importance : undefined,
+ });
+ });
+
+ ipcMain.handle("openpets:update-memory", async (event, id: unknown, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
+ if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
+ const result = updateOpenPetsMemory(id.trim(), {
+ text: text.trim(),
+ kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
+ tags: Array.isArray(tags) ? tags.filter((t): t is string => typeof t === "string") : undefined,
+ importance: typeof importance === "number" ? importance : undefined,
+ });
+ if (!result) throw new Error("Memory not found.");
+ return result;
+ });
+
+ ipcMain.handle("openpets:delete-memory", async (event, id: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
+ const ok = forgetOpenPetsMemory(id.trim());
+ if (!ok) throw new Error("Memory not found.");
+ return true;
+ });
+
ipcMain.handle("openpets:get-reaction-animation-settings", async (event) => {
assertAllowedSender(event, ["control-center"]);
return getReactionAnimationSettingsSnapshot();
@@ -223,14 +325,19 @@ export function installInternalUiHandlers(): void {
ipcMain.handle("openpets:get-codex-pets", async (event) => {
assertAllowedSender(event, ["control-center"]);
- return getCodexPetsUiState();
+ return (await loadCodexPetsModule()).getCodexPetsUiState();
});
ipcMain.handle("openpets:update-preferences", (event, patch: unknown) => {
assertAllowedSender(event, ["control-center"]);
+ const currentState = getAppStateSnapshot();
const previousScale = getAppStateSnapshot().preferences.petScale;
const previousOverrides = JSON.stringify(getAppStateSnapshot().preferences.reactionAnimationOverrides ?? {});
const state = updatePreferences(validatePreferencePatch(patch));
+ if (currentState.preferences.openApiChatModel !== state.preferences.openApiChatModel
+ || currentState.preferences.openApiChatEndpoint !== state.preferences.openApiChatEndpoint) {
+ resetOpenApiConversationContext();
+ }
const nextOverrides = JSON.stringify(state.preferences.reactionAnimationOverrides ?? {});
if (state.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
refreshDefaultPetContent();
@@ -270,6 +377,18 @@ export function installInternalUiHandlers(): void {
await openUpdateReleasePage();
});
+ ipcMain.handle("openpets:copy-text", (event, text: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ if (typeof text !== "string" || !text.trim()) throw new Error("Invalid copy payload.");
+ clipboard.writeText(text);
+ });
+
+ ipcMain.handle("openpets:open-external-url", async (event, rawUrl: unknown) => {
+ assertAllowedSender(event, ["control-center"]);
+ const url = validateExternalUrl(rawUrl);
+ await shell.openExternal(url);
+ });
+
ipcMain.handle("openpets:set-default-pet", async (event, petId: unknown) => {
assertAllowedSender(event, ["control-center"]);
if (typeof petId !== "string") {
@@ -334,7 +453,7 @@ export function installInternalUiHandlers(): void {
throw new Error("Invalid pet id.");
}
- const state = await importCodexPet(petId);
+ const state = await (await loadCodexPetsModule()).importCodexPet(petId);
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state;
});
@@ -399,7 +518,7 @@ export function installInternalUiProtocol(): void {
const url = new URL(request.url);
if (url.hostname !== "spritesheet" || url.search || url.hash) return new Response(null, { status: 404 });
const petId = decodeURIComponent(url.pathname.replace(/^\//, ""));
- const spritesheet = await readCodexPetSpritesheet(petId);
+ const spritesheet = await (await loadCodexPetsModule()).readCodexPetSpritesheet(petId);
return new Response(spritesheet, {
headers: {
"Content-Type": "image/webp",
@@ -626,12 +745,12 @@ async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
}
-function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType } {
+function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType; openApiChatModel?: string | undefined; openApiChatSystemPrompt?: string | undefined; openApiChatEndpoint?: string | undefined; openApiChatTheme?: "system" | "light" | "dark" } {
if (!isRecord(value)) {
throw new Error("Invalid preferences patch.");
}
- const patch: { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType } = {};
+ const patch: { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType; openApiChatModel?: string | undefined; openApiChatSystemPrompt?: string | undefined; openApiChatEndpoint?: string | undefined; openApiChatTheme?: "system" | "light" | "dark" } = {};
if ("openDefaultPetOnLaunch" in value) {
if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value.");
@@ -648,6 +767,45 @@ function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boo
patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides);
}
+ if ("openApiChatModel" in value) {
+ if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") {
+ patch.openApiChatModel = undefined;
+ } else {
+ const model = normalizeChatModel(value.openApiChatModel);
+ if (!model) throw new Error("Invalid model name.");
+ patch.openApiChatModel = model;
+ }
+ }
+
+ if ("openApiChatSystemPrompt" in value) {
+ if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") {
+ patch.openApiChatSystemPrompt = undefined;
+ } else if (typeof value.openApiChatSystemPrompt === "string") {
+ patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt;
+ } else {
+ throw new Error("Invalid pet character prompt.");
+ }
+ }
+
+ if ("openApiChatEndpoint" in value) {
+ if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") {
+ patch.openApiChatEndpoint = undefined;
+ } else {
+ const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint);
+ if (!endpoint) {
+ throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL.");
+ }
+ patch.openApiChatEndpoint = endpoint;
+ }
+ }
+
+ if ("openApiChatTheme" in value) {
+ if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") {
+ throw new Error("Invalid theme mode.");
+ }
+ patch.openApiChatTheme = value.openApiChatTheme;
+ }
+
return patch;
}
@@ -663,3 +821,26 @@ function isLaunchAtLoginSupported(): boolean {
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+
+function validateExternalUrl(value: unknown): string {
+ if (typeof value !== "string") {
+ throw new Error("Invalid URL.");
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(value);
+ } catch {
+ throw new Error("Invalid URL.");
+ }
+
+ const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
+ if ((parsed.protocol !== "https:" && !isLocalHttp)
+ || parsed.username
+ || parsed.password
+ || parsed.hash) {
+ throw new Error("Only https URLs, or localhost http URLs, are allowed.");
+ }
+
+ return parsed.toString();
+}
diff --git a/apps/desktop/tests/onboarding-state.test.ts b/apps/desktop/tests/onboarding-state.test.ts
index 928a7c3e..23783883 100644
--- a/apps/desktop/tests/onboarding-state.test.ts
+++ b/apps/desktop/tests/onboarding-state.test.ts
@@ -40,11 +40,16 @@ assert.equal(preferencePatch.openDefaultPetOnLaunch, true);
assert.equal(preferencePatch.speechBubblesEnabled, true);
assert.equal(defaultPetScale, 0.56);
-assert.deepEqual(petScaleOptions.map((option) => option.value), [0.44, 0.56, 0.72]);
-assert.equal(normalizePetScale(0.44), 0.44);
+assert.deepEqual(petScaleOptions.map((option) => option.value), [0.24, 0.32, 0.44, 0.56, 0.72, 0.88, 1.04, 1.2]);
+assert.equal(normalizePetScale(0.24), 0.24);
assert.equal(normalizePetScale(0.56), 0.56);
-assert.equal(normalizePetScale(0.72), 0.72);
-assert.equal(normalizePetScale(1), defaultPetScale);
+assert.equal(normalizePetScale(1.2), 1.2);
+assert.equal(normalizePetScale(0.16), 0.16);
+assert.equal(normalizePetScale(1.4), 1.4);
+assert.equal(normalizePetScale(0.1), 0.16);
+assert.equal(normalizePetScale(2.0), 2.0);
+assert.equal(normalizePetScale(12.0), 10);
+assert.equal(normalizePetScale(1), 1);
assert.equal(normalizePetScale("0.56"), defaultPetScale);
assert.equal(normalizePetScale(Number.NaN), defaultPetScale);
assert.equal(normalizePetScale(Number.POSITIVE_INFINITY), defaultPetScale);
diff --git a/apps/desktop/tests/prompt-memory-extraction.test.ts b/apps/desktop/tests/prompt-memory-extraction.test.ts
new file mode 100644
index 00000000..a9cc42f6
--- /dev/null
+++ b/apps/desktop/tests/prompt-memory-extraction.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+
+import { extractPromptMemoryCandidates } from "../src/prompt-memory-extraction.js";
+
+function assertCandidate(prompt: string, expectedText: string, index = 0): void {
+ const candidates = extractPromptMemoryCandidates(prompt);
+ const candidate = candidates[index];
+ assert.ok(candidate, `Expected at least ${index + 1} memory candidate(s) for: ${prompt}`);
+ assert.equal(candidate.text, expectedText, `Unexpected memory text for: ${prompt}`);
+}
+
+function assertNoCandidate(prompt: string): void {
+ const candidates = extractPromptMemoryCandidates(prompt);
+ assert.equal(candidates.length, 0, `Expected no memory candidates for: ${prompt}`);
+}
+
+// Classic explicit remember commands
+assertCandidate("remember that my name is Alice", "User's name is Alice.", 0);
+assertCandidate("Remember my name is Bob", "User's name is Bob.", 0);
+assertCandidate("/remember that I prefer dark mode", "User prefers dark mode.", 0);
+assertCandidate("#memorize pizza is my favorite food", "pizza is my favorite food", 0);
+assertCandidate("!note the server runs on port 3000", "the server runs on port 3000", 0);
+assertCandidate("note: always use TypeScript", "always use TypeScript", 0);
+assertCandidate("remember - I like hiking", "User likes hiking.", 0);
+assertCandidate("remember, I hate spinach", "User hates spinach.", 0);
+assertCandidate("don't forget that my favorite color is blue", "User's favorite color is blue.", 0);
+assertCandidate("Don't forget I love summer", "User loves summer.", 0);
+
+// Identity
+assertCandidate("call me Charlie", "User's name is Charlie.", 0);
+assertCandidate("my name is Diana", "User's name is Diana.", 0);
+
+// Preferences
+assertCandidate("my favorite programming language is Python", "User's favorite programming language is Python.", 0);
+assertCandidate("I prefer tea over coffee", "User prefers tea over coffee.", 0);
+assertCandidate("I like sci-fi movies", "User likes sci-fi movies.", 0);
+assertCandidate("I love hiking in the mountains", "User loves hiking in the mountains.", 0);
+assertCandidate("I dislike waiting", "User dislikes waiting.", 0);
+assertCandidate("I hate noisy environments", "User hates noisy environments.", 0);
+
+// Multi-line prompts should extract per line
+const multiLine = "remember that I work late\nmy favorite editor is VS Code";
+assert.equal(extractPromptMemoryCandidates(multiLine).length, 2, "Multi-line prompt should extract two candidates");
+assertCandidate(multiLine, "I work late", 0);
+assertCandidate(multiLine, "User's favorite editor is VS Code.", 1);
+
+// Non-command prompts should not produce candidates
+assertNoCandidate("What is the weather today?");
+assertNoCandidate("Hello, how are you?");
+assertNoCandidate("Tell me a joke");
+
+console.log("Prompt memory extraction validation passed.");
diff --git a/docs/phases/phase-14-pet-scale-setting.md b/docs/phases/phase-14-pet-scale-setting.md
index ad6be355..9f1e19eb 100644
--- a/docs/phases/phase-14-pet-scale-setting.md
+++ b/docs/phases/phase-14-pet-scale-setting.md
@@ -22,8 +22,9 @@ Make the existing `petScale` preference real: Settings should let users choose p
## Acceptance criteria
-- Settings exposes pet scale choices: Small, Medium, Large.
-- Default scale is **Medium**, matching the current polished pet size after the bugfix.
+- Settings exposes pet scale choices: XXS, XS, Small, Medium, Large, XL, XXL, XXXL.
+- Default scale is **Medium** (`0.56`).
+- A drag-to-resize handle appears on the pet window for interactive scale adjustment.
- Persisted `preferences.petScale` accepts only supported numeric values and normalizes invalid/old values to the default.
- `validatePreferencePatch()` accepts only supported `petScale` values from Settings IPC; unsupported/non-finite values are rejected.
- Current and newly created state files use the new default scale.
@@ -57,9 +58,14 @@ Make the existing `petScale` preference real: Settings should let users choose p
## Technical approach
1. Define supported scale values/default in a single pure source of truth, `app-state-core.ts`, and import/reuse where practical:
- - Small: `0.44`,
- - Medium: `0.56`,
- - Large: `0.72`.
+ - XXS: `0.24`
+ - XS: `0.32`
+ - Small: `0.44`
+ - Medium: `0.56`
+ - Large: `0.72`
+ - XL: `0.88`
+ - XXL: `1.04`
+ - XXXL: `1.20`
2. Make Medium (`0.56`) the default.
3. Preserve old/corrupt state safety by normalizing any unsupported `petScale` to Medium. Old persisted `petScale: 1` should normalize to Medium because `1` was never the actual post-bugfix visual render scale.
4. In Settings, add a `select id="pet-scale"` or equivalent simple control.
@@ -140,7 +146,9 @@ Verdict: conditionally approved after fixing scale sizing.
Fixed:
-- Reduced Large to `0.84` to fit the fixed viewport with bubble.
+- Added XXS, XS, XL, XXL, XXXL options.
+- Large kept at `0.72`; window dynamically sizes to fit larger scales.
+- Added a drag-to-resize handle on the pet window.
- Added explicit Settings IPC validation requirement.
- Added one-source-of-truth scale constants requirement.
- Documented `petScale: 1` migration to the default.
@@ -151,14 +159,16 @@ Fixed:
Implemented:
-- Added supported scale constants in `app-state.ts`: Small `0.44`, Medium `0.56`, Large `0.72`.
+- Added supported scale constants in `app-state-core.ts`: XXS `0.24`, XS `0.32`, Small `0.44`, Medium `0.56`, Large `0.72`, XL `0.88`, XXL `1.04`, XXXL `1.20`.
- Moved scale constants/normalization into pure `app-state-core.ts` so lightweight tests can cover scale normalization without importing Electron.
- Changed default/invalid scale normalization to Medium.
- Added real Settings Pet scale select and status feedback.
- Added `petScale` validation to Settings preference IPC.
- Refreshes default pet content only when scale changes.
- Pet renderer derives shell size, sprite transform, installed-card size, and bubble offset from the selected scale.
-- Explicit agent pet windows keep the Medium/default render scale for now; default pet windows use the saved preference.
+- Pet window dynamically resizes to accommodate larger scales so the pet and bubble are not cropped.
+- Drag-to-resize handle on the pet window allows interactive scale adjustment; changes are saved to preferences.
+- Explicit agent pet windows use the saved preference scale.
Validation passed:
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 338a8f38..c197b7af 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -2,10 +2,10 @@ import net from "node:net";
import { randomUUID } from "node:crypto";
import { parseIpcEndpoint, readDiscoveryFile, type OpenPetsDiscoveryFile } from "./discovery.js";
-import { connectTimeoutMs, maxIpcMessageBytes, openPetsIpcVersion, parseIpcResponse, responseTimeoutMs, validateReaction, OpenPetsClientError, type OpenPetsIpcMethod, type OpenPetsIpcRequest, type OpenPetsReaction } from "./protocol.js";
+import { connectTimeoutMs, maxIpcMessageBytes, openPetsIpcVersion, parseIpcResponse, responseTimeoutMs, validateReaction, OpenPetsClientError, type OpenPetsIpcMethod, type OpenPetsIpcRequest, type OpenPetsMemoryKind, type OpenPetsReaction } from "./protocol.js";
export { getDiscoveryFilePath, parseIpcEndpoint, readDiscoveryFile, validateDiscovery, validateEndpoint, type OpenPetsDiscoveryFile, type ParsedIpcEndpoint } from "./discovery.js";
-export { allowedReactions, OpenPetsClientError, type OpenPetsReaction } from "./protocol.js";
+export { allowedReactions, OpenPetsClientError, type OpenPetsMemoryKind, type OpenPetsReaction } from "./protocol.js";
export interface OpenPetsClientOptions {
readonly discoveryPath?: string;
@@ -62,6 +62,50 @@ export interface OpenPetsClient {
releaseLease(leaseId: string): Promise<{ readonly released: boolean }>;
react(reaction: OpenPetsReaction, options?: { readonly leaseId?: string }): Promise;
say(message: string, options?: { readonly reaction?: OpenPetsReaction; readonly leaseId?: string }): Promise;
+ listMemories?(options?: { readonly limit?: number }): Promise;
+ searchMemories?(query: string, options?: { readonly limit?: number }): Promise;
+ storeMemory?(input: { readonly text: string; readonly kind?: OpenPetsMemoryKind; readonly tags?: readonly string[]; readonly importance?: number }): Promise;
+ deleteMemory?(id: string): Promise;
+}
+
+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;
+}
+
+export interface OpenPetsMemoryListResult {
+ readonly ok: true;
+ readonly memories: readonly OpenPetsMemoryEntry[];
+}
+
+export interface OpenPetsMemorySearchResult {
+ readonly ok: true;
+ readonly query: string;
+ readonly memories: readonly OpenPetsMemorySearchHit[];
+}
+
+export interface OpenPetsMemoryStoreResult {
+ readonly ok: true;
+ readonly memory: OpenPetsMemoryEntry;
+}
+
+export interface OpenPetsMemoryDeleteResult {
+ readonly ok: true;
+ readonly id: string;
+ readonly deleted: boolean;
}
export function createOpenPetsClient(options: OpenPetsClientOptions = {}): OpenPetsClient {
@@ -85,6 +129,52 @@ export function createOpenPetsClient(options: OpenPetsClientOptions = {}): OpenP
releaseLease: (leaseId) => sendDiscoveredRequest("lease.release", { leaseId }, options),
react: (reaction, reactOptions) => sendDiscoveredRequest("pet.react", { reaction: validateReaction(reaction), leaseId: reactOptions?.leaseId }, options),
say: (message, sayOptions) => sendDiscoveredRequest("pet.say", { message, reaction: sayOptions?.reaction, leaseId: sayOptions?.leaseId }, options),
+ listMemories: async (memoryOptions) => parseMemoryListResult(await sendDiscoveredRequest("memory.list", { limit: memoryOptions?.limit }, options)),
+ searchMemories: async (query, memoryOptions) => parseMemorySearchResult(await sendDiscoveredRequest("memory.search", { query, limit: memoryOptions?.limit }, options)),
+ storeMemory: async (input) => parseMemoryStoreResult(await sendDiscoveredRequest("memory.store", input, options)),
+ deleteMemory: async (id) => parseMemoryDeleteResult(await sendDiscoveredRequest("memory.delete", { id }, options)),
+ };
+}
+
+export function parseMemoryListResult(value: unknown): OpenPetsMemoryListResult {
+ if (!isRecord(value) || value.ok !== true || !Array.isArray(value.memories)) {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory list response is invalid.");
+ }
+ return {
+ ok: true,
+ memories: value.memories.map(parseMemoryEntry),
+ };
+}
+
+export function parseMemorySearchResult(value: unknown): OpenPetsMemorySearchResult {
+ if (!isRecord(value) || value.ok !== true || typeof value.query !== "string" || !Array.isArray(value.memories)) {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory search response is invalid.");
+ }
+ return {
+ ok: true,
+ query: value.query,
+ memories: value.memories.map(parseMemorySearchHit),
+ };
+}
+
+export function parseMemoryStoreResult(value: unknown): OpenPetsMemoryStoreResult {
+ if (!isRecord(value) || value.ok !== true) {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory store response is invalid.");
+ }
+ return {
+ ok: true,
+ memory: parseMemoryEntry(value.memory),
+ };
+}
+
+export function parseMemoryDeleteResult(value: unknown): OpenPetsMemoryDeleteResult {
+ if (!isRecord(value) || value.ok !== true || typeof value.id !== "string" || typeof value.deleted !== "boolean") {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory delete response is invalid.");
+ }
+ return {
+ ok: true,
+ id: value.id,
+ deleted: value.deleted,
};
}
@@ -120,6 +210,47 @@ function parsePetListItem(value: unknown): OpenPetsPetListItem {
return { id: value.id, displayName: value.displayName, builtIn: value.builtIn, broken: value.broken };
}
+function parseMemorySearchHit(value: unknown): OpenPetsMemorySearchHit {
+ if (!isRecord(value) || typeof value.score !== "number") {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory search hit is invalid.");
+ }
+ return {
+ entry: parseMemoryEntry(value.entry),
+ score: value.score,
+ };
+}
+
+function parseMemoryEntry(value: unknown): OpenPetsMemoryEntry {
+ if (!isRecord(value)
+ || typeof value.id !== "string"
+ || typeof value.text !== "string"
+ || (value.kind !== "identity" && value.kind !== "preference" && value.kind !== "fact" && value.kind !== "note")
+ || !Array.isArray(value.tags)
+ || typeof value.importance !== "number"
+ || typeof value.createdAt !== "number"
+ || typeof value.updatedAt !== "number"
+ || typeof value.lastAccessedAt !== "number"
+ || typeof value.accessCount !== "number"
+ || (value.source !== "chat" && value.source !== "mcp")) {
+ throw new OpenPetsClientError("invalid_response", "OpenPets memory entry is invalid.");
+ }
+
+ const tags = value.tags
+ .filter((tag): tag is string => typeof tag === "string");
+ return {
+ id: value.id,
+ text: value.text,
+ kind: value.kind,
+ tags,
+ importance: value.importance,
+ createdAt: value.createdAt,
+ updatedAt: value.updatedAt,
+ lastAccessedAt: value.lastAccessedAt,
+ accessCount: value.accessCount,
+ source: value.source,
+ };
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts
index 8860484b..f4bdd3a9 100644
--- a/packages/client/src/protocol.ts
+++ b/packages/client/src/protocol.ts
@@ -19,7 +19,8 @@ export const allowedReactions = [
] 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 = "identity" | "preference" | "fact" | "note";
+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;
diff --git a/packages/mcp/src/check-mcp-contract.ts b/packages/mcp/src/check-mcp-contract.ts
index 7d42b183..ab1a56ba 100644
--- a/packages/mcp/src/check-mcp-contract.ts
+++ b/packages/mcp/src/check-mcp-contract.ts
@@ -48,6 +48,13 @@ async function checkMcpServerContract(): Promise {
releaseLease: async () => ({ released: true }),
react: async (reaction: string, options?: { readonly leaseId?: string }) => ({ ok: true, reaction, leaseId: options?.leaseId }),
say: async (message: string, options?: { readonly leaseId?: string }) => ({ ok: true, message, leaseId: options?.leaseId }),
+ listMemories: async () => ({ ok: true as const, memories: [{ id: "mem-1", text: "User likes tea.", kind: "preference" as const, tags: ["preference"], importance: 3, createdAt: Date.now(), updatedAt: Date.now(), lastAccessedAt: Date.now(), accessCount: 1, source: "chat" as const }] }),
+ searchMemories: async (query: string) => ({ ok: true as const, query, memories: [{ entry: { id: "mem-1", text: "User likes tea.", kind: "preference" as const, tags: ["preference"], importance: 3, createdAt: Date.now(), updatedAt: Date.now(), lastAccessedAt: Date.now(), accessCount: 1, source: "chat" as const }, score: 9.2 }] }),
+ storeMemory: async (input: { readonly text: string; readonly kind?: "identity" | "preference" | "fact" | "note" }) => {
+ const kind: "identity" | "preference" | "fact" | "note" = input.kind === "identity" || input.kind === "fact" || input.kind === "note" ? input.kind : "preference";
+ return { ok: true as const, memory: { id: "mem-2", text: input.text, kind, tags: [], importance: 4, createdAt: Date.now(), updatedAt: Date.now(), lastAccessedAt: Date.now(), accessCount: 1, source: "mcp" as const } };
+ },
+ deleteMemory: async (id: string) => ({ ok: true as const, id, deleted: true }),
hello: async () => ({ ok: true }),
};
const server = createOpenPetsMcpServer({ configuredPetId: "snoopy", client: fakeClient, lease: { lease: await fakeClient.acquireLease() }, leaseReady: Promise.resolve() });
@@ -57,7 +64,7 @@ async function checkMcpServerContract(): Promise {
try {
const tools = await client.listTools();
const names = tools.tools.map((tool) => tool.name).sort();
- if (names.join(",") !== "openpets_react,openpets_say,openpets_status") {
+ if (names.join(",") !== "openpets_memory_forget,openpets_memory_list,openpets_memory_search,openpets_memory_store,openpets_react,openpets_say,openpets_status") {
throw new Error(`Unexpected MCP tool list: ${names.join(",")}`);
}
@@ -78,6 +85,20 @@ async function checkMcpServerContract(): Promise {
const invalidSay = await client.callTool({ name: "openpets_say", arguments: { message: "const secret = 1" } }, CallToolResultSchema);
if (!invalidSay.isError) throw new Error("Unsafe say message was not rejected.");
+ const memoryList = await client.callTool({ name: "openpets_memory_list", arguments: {} }, CallToolResultSchema);
+ if (memoryList.isError) throw new Error("Memory list unexpectedly failed.");
+ const memoryListText = Array.isArray(memoryList.content) && memoryList.content[0] && "text" in memoryList.content[0] ? String(memoryList.content[0].text) : "";
+ if (!memoryListText.includes("User likes tea.")) throw new Error("Memory list did not include stored memory text.");
+
+ const memorySearch = await client.callTool({ name: "openpets_memory_search", arguments: { query: "tea" } }, CallToolResultSchema);
+ if (memorySearch.isError) throw new Error("Memory search unexpectedly failed.");
+
+ const memoryStore = await client.callTool({ name: "openpets_memory_store", arguments: { text: "User prefers compact windows.", kind: "preference" } }, CallToolResultSchema);
+ if (memoryStore.isError) throw new Error("Memory store unexpectedly failed.");
+
+ const memoryForget = await client.callTool({ name: "openpets_memory_forget", arguments: { id: "mem-2" } }, CallToolResultSchema);
+ if (memoryForget.isError) throw new Error("Memory forget unexpectedly failed.");
+
const stale = createMcpStatus({ ok: false, appRunning: true, leaseId: "missing", leaseActive: false, staleReason: "unknown_lease" }, "snoopy", undefined, "missing", "missing");
if (stale.leaseActive !== false || stale.staleReason !== "unknown_lease" || stale.ok !== false) {
throw new Error("Stale MCP lease status was not preserved.");
@@ -100,7 +121,7 @@ async function checkStdioServerContract(): Promise {
try {
const tools = await client.listTools();
const names = tools.tools.map((tool) => tool.name).sort();
- if (names.join(",") !== "openpets_react,openpets_say,openpets_status") {
+ if (names.join(",") !== "openpets_memory_forget,openpets_memory_list,openpets_memory_search,openpets_memory_store,openpets_react,openpets_say,openpets_status") {
throw new Error(`Unexpected stdio MCP tool list: ${names.join(",")}`);
}
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index 919a97d8..1638536c 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -1,10 +1,10 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { handleReact, handleSay, handleStatus, reactSchema, saySchema, type ToolContext } from "./tools.js";
+import { handleMemoryForget, handleMemoryList, handleMemorySearch, handleMemoryStore, handleReact, handleSay, handleStatus, memoryForgetSchema, memoryListSchema, memorySearchSchema, memoryStoreSchema, reactSchema, saySchema, type ToolContext } from "./tools.js";
export function createOpenPetsMcpServer(context: ToolContext): McpServer {
const server = new McpServer({ name: "open-pets", version: "0.0.0" }, {
- instructions: "Interact with the user's OpenPets desktop companion. Use openpets_status first. Use openpets_say only for short status/personality messages, never code, logs, secrets, URLs, or file paths.",
+ instructions: "Interact with the user's OpenPets desktop companion. Use openpets_status first. Use openpets_say only for short status/personality messages, never code, logs, secrets, URLs, or file paths. Use the openpets_memory_* tools for durable user preferences or facts instead of overloading the speech bubble.",
});
server.registerTool("openpets_status", {
@@ -28,5 +28,33 @@ export function createOpenPetsMcpServer(context: ToolContext): McpServer {
annotations: { readOnlyHint: false, idempotentHint: false },
}, async (input) => handleSay(input, context));
+ server.registerTool("openpets_memory_list", {
+ title: "OpenPets Memory List",
+ description: "List recent long-term memories stored by OpenPets.",
+ inputSchema: memoryListSchema,
+ annotations: { readOnlyHint: true, idempotentHint: true },
+ }, async (input) => handleMemoryList(input, context));
+
+ server.registerTool("openpets_memory_search", {
+ title: "OpenPets Memory Search",
+ description: "Search OpenPets long-term memory for relevant facts or preferences.",
+ inputSchema: memorySearchSchema,
+ annotations: { readOnlyHint: true, idempotentHint: true },
+ }, async (input) => handleMemorySearch(input, context));
+
+ server.registerTool("openpets_memory_store", {
+ title: "OpenPets Memory Store",
+ description: "Store a durable user fact, preference, or note in OpenPets memory.",
+ inputSchema: memoryStoreSchema,
+ annotations: { readOnlyHint: false, idempotentHint: false },
+ }, async (input) => handleMemoryStore(input, context));
+
+ server.registerTool("openpets_memory_forget", {
+ title: "OpenPets Memory Forget",
+ description: "Delete a stored OpenPets memory by id.",
+ inputSchema: memoryForgetSchema,
+ annotations: { readOnlyHint: false, idempotentHint: false },
+ }, async (input) => handleMemoryForget(input, context));
+
return server;
}
diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts
index ecdde2fb..0a1f568c 100644
--- a/packages/mcp/src/tools.ts
+++ b/packages/mcp/src/tools.ts
@@ -1,5 +1,5 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { allowedReactions, createOpenPetsClient, OpenPetsClientError, type OpenPetsClient, type OpenPetsLeaseResult, type OpenPetsReaction, type OpenPetsStatusResult } from "@open-pets/client";
+import { allowedReactions, createOpenPetsClient, OpenPetsClientError, type OpenPetsClient, type OpenPetsLeaseResult, type OpenPetsMemoryDeleteResult, type OpenPetsMemoryEntry, type OpenPetsMemoryKind, type OpenPetsMemoryListResult, type OpenPetsMemorySearchResult, type OpenPetsMemoryStoreResult, type OpenPetsReaction, type OpenPetsStatusResult } from "@open-pets/client";
import { z } from "zod";
export const reactionSchema = z.enum(allowedReactions);
@@ -14,6 +14,22 @@ export const saySchema = z.object({
});
export const reactSchema = z.object({ reaction: reactionSchema });
+export const memoryListSchema = z.object({
+ limit: z.number().int().min(1).max(25).optional(),
+});
+export const memorySearchSchema = z.object({
+ query: z.string().trim().min(1).max(240),
+ limit: z.number().int().min(1).max(12).optional(),
+});
+export const memoryStoreSchema = z.object({
+ text: z.string().trim().min(1).max(480),
+ kind: z.enum(["identity", "preference", "fact", "note"]).optional(),
+ tags: z.array(z.string().trim().min(1).max(32)).max(8).optional(),
+ importance: z.number().int().min(1).max(5).optional(),
+});
+export const memoryForgetSchema = z.object({
+ id: z.string().trim().min(1).max(80),
+});
export interface OpenPetsMcpStatus {
readonly [key: string]: unknown;
@@ -123,6 +139,66 @@ export async function handleSay(input: unknown, context: ToolContext): Promise {
+ const parsed = memoryListSchema.safeParse(input ?? {});
+ if (!parsed.success) return toolError("Invalid memory list request.");
+
+ try {
+ const client = context.client ?? createOpenPetsClient();
+ const listMemories = client.listMemories;
+ if (!listMemories) return toolError("This OpenPets client build does not support memory tools yet. Update the app/integration and try again.");
+ const result = await listMemories({ limit: parsed.data.limit });
+ return createMemoryListResult(result);
+ } catch (error) {
+ return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`);
+ }
+}
+
+export async function handleMemorySearch(input: unknown, context: ToolContext): Promise {
+ const parsed = memorySearchSchema.safeParse(input);
+ if (!parsed.success) return toolError("Invalid memory search request.");
+
+ try {
+ const client = context.client ?? createOpenPetsClient();
+ const searchMemories = client.searchMemories;
+ if (!searchMemories) return toolError("This OpenPets client build does not support memory tools yet. Update the app/integration and try again.");
+ const result = await searchMemories(parsed.data.query, { limit: parsed.data.limit });
+ return createMemorySearchResult(result);
+ } catch (error) {
+ return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`);
+ }
+}
+
+export async function handleMemoryStore(input: unknown, context: ToolContext): Promise {
+ const parsed = memoryStoreSchema.safeParse(input);
+ if (!parsed.success) return toolError("Invalid memory store request.");
+
+ try {
+ const client = context.client ?? createOpenPetsClient();
+ const storeMemory = client.storeMemory;
+ if (!storeMemory) return toolError("This OpenPets client build does not support memory tools yet. Update the app/integration and try again.");
+ const result = await storeMemory(parsed.data);
+ return createMemoryStoreResult(result);
+ } catch (error) {
+ return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`);
+ }
+}
+
+export async function handleMemoryForget(input: unknown, context: ToolContext): Promise {
+ const parsed = memoryForgetSchema.safeParse(input);
+ if (!parsed.success) return toolError("Invalid memory delete request.");
+
+ try {
+ const client = context.client ?? createOpenPetsClient();
+ const deleteMemory = client.deleteMemory;
+ if (!deleteMemory) return toolError("This OpenPets client build does not support memory tools yet. Update the app/integration and try again.");
+ const result = await deleteMemory(parsed.data.id);
+ return createMemoryDeleteResult(result);
+ } catch (error) {
+ return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`);
+ }
+}
+
export function createMcpStatus(status: OpenPetsStatusResult, configuredPetId?: string, lease?: OpenPetsLeaseResult, degradedReason?: string, staleLeaseId?: string): OpenPetsMcpStatus {
if (status.leaseActive === false || staleLeaseId) {
return {
@@ -190,8 +266,47 @@ function sanitizeError(error: unknown): string {
return "Open OpenPets and try again.";
}
+function createMemoryListResult(result: OpenPetsMemoryListResult): CallToolResult {
+ const text = result.memories.length > 0
+ ? `OpenPets remembers:\n${result.memories.map((memory) => formatMemoryLine(memory)).join("\n")}`
+ : "OpenPets has no stored memories yet.";
+ return {
+ content: [{ type: "text", text }],
+ structuredContent: { ...result },
+ };
+}
+
+function createMemorySearchResult(result: OpenPetsMemorySearchResult): CallToolResult {
+ const text = result.memories.length > 0
+ ? `Memory matches for "${result.query}":\n${result.memories.map((hit) => `${formatMemoryLine(hit.entry)} (score ${hit.score})`).join("\n")}`
+ : `No stored memories matched "${result.query}".`;
+ return {
+ content: [{ type: "text", text }],
+ structuredContent: { ...result },
+ };
+}
+
+function createMemoryStoreResult(result: OpenPetsMemoryStoreResult): CallToolResult {
+ return {
+ content: [{ type: "text", text: `Stored memory ${result.memory.id}: ${result.memory.text}` }],
+ structuredContent: { ...result },
+ };
+}
+
+function createMemoryDeleteResult(result: OpenPetsMemoryDeleteResult): CallToolResult {
+ return {
+ content: [{ type: "text", text: result.deleted ? `Deleted memory ${result.id}.` : `No memory was deleted for id ${result.id}.` }],
+ structuredContent: { ...result },
+ };
+}
+
+function formatMemoryLine(memory: OpenPetsMemoryEntry): string {
+ const tagSuffix = memory.tags.length > 0 ? ` [${memory.tags.join(", ")}]` : "";
+ return `- ${memory.id} (${memory.kind}, importance ${memory.importance}) ${memory.text}${tagSuffix}`;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
-export type { OpenPetsReaction };
+export type { OpenPetsMemoryKind, OpenPetsReaction };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a772c5bb..8bb6db8d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
apps/desktop:
dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.29.0
+ version: 1.29.0(zod@4.4.3)
'@open-pets/agent-events':
specifier: workspace:*
version: link:../../packages/agent-events