feat(desktop): minimal prompt window, larger pet bubbles, improved command memory, screen-filling pet scale slider

- Prompt window opens at 140px compact height and expands to 456px when editor/history shown.
- Pet speech bubbles scale with larger pets and window sizes; removed text overflow clipping.
- Extracted prompt memory extraction into testable module; added /remember, /memorize, /note, don't forget patterns.
- Replaced pet scale dropdown with continuous slider from 0.16x to 10x.
- Added prompt-memory-extraction.test.ts and updated packaging/onboarding tests.
This commit is contained in:
OpenPets Dev 2026-06-12 03:02:21 +00:00
parent baf1daee45
commit 08eff41255
41 changed files with 6712 additions and 95 deletions

6
.gitignore vendored
View file

@ -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

299
FEATURES.md Normal file
View file

@ -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

105
FEATURES_OUR_CHANGES.md Normal file
View file

@ -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.

View file

@ -10,6 +10,10 @@
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
</p>
<p align="center">
<sub>This branch also includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>vanilla MCP tools</strong>, and <strong>expanded pet sizing</strong>. See <code>FEATURES.md</code> and <code>PULL_REQUEST.md</code> for details.</sub>
</p>
<p align="center">
<img src="assets/intro.png" alt="OpenPets reacting across multiple coding agent sessions" width="100%" />
</p>
@ -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.
<p align="center">
<img src="assets/integrations.png" alt="OpenPets desktop integrations screen" width="100%" />
@ -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

View file

@ -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);

View file

@ -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/**

View file

@ -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:*",

View file

@ -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");

View file

@ -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);

View file

@ -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",

View file

@ -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;
}

View file

@ -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 {

View file

@ -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"]>): 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<OpenPetsStateV1["preferences"]>): 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<string, unknown>): 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],

View file

@ -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.");

View file

@ -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<string, string>();
let sharpFactoryPromise: Promise<any | null> | null = null;
export interface CodexPetUiState {
readonly source: "codex";
@ -150,6 +149,9 @@ async function createCodexThumbnailDataUrl(path: string): Promise<string> {
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<string> {
return dataUrl;
}
async function loadSharpFactory(): Promise<any | null> {
if (!sharpFactoryPromise) {
sharpFactoryPromise = import("sharp")
.then((module) => module.default)
.catch(() => null);
}
return sharpFactoryPromise;
}
async function validateCodexRoot(): Promise<string> {
const root = resolve(codexPetsRoot);
const rootStats = await lstat(root);

View file

@ -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();
}

View file

@ -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);
}

View file

@ -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();
});

View file

@ -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 };
}

View file

@ -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<unknown> {
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);

View file

@ -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);

View file

@ -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<string, unknown>;
};
}
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<string, string>;
}
// 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<string, ServerSpawnConfig> {
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<string, ActiveServer>();
private serverConfigs: Record<string, ServerSpawnConfig>;
private homeDir: string;
constructor() {
this.homeDir = app.getPath("home");
this.serverConfigs = getVanillaChatServerConfigs(this.homeDir);
}
async startEnabledServers(enabledIds: readonly string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
for (const id of Array.from(this.activeServers.keys())) {
await this.stopServer(id);
}
}
listTools(): McpChatToolDefinition[] {
const openAiTools: McpChatToolDefinition[] = [];
const seenNames = new Set<string>();
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<string, unknown>,
},
});
}
}
return openAiTools;
}
async callTool(name: string, args: unknown): Promise<string> {
// 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<string, unknown> }, 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<Record<string, unknown>>; 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));
}

View file

@ -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<McpToolkitInstallResult> {
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<void> {
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<CommandResult> {
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<CommandResult> {
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<string>();
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);
}

File diff suppressed because it is too large Load diff

View file

@ -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<string>, 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<string>, 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<OpenPetsMemoryKind, number>;
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<string> {
return new Set(
normalizeMemoryComparisonValue(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2),
);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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<OpenPetsReaction, "idle">;
@ -57,15 +62,20 @@ const windowLoadChains = new WeakMap<BrowserWindow, Promise<void>>();
const windowLoadSequences = new WeakMap<BrowserWindow, number>();
const petMouseInteropRecovery = new WeakMap<BrowserWindow, (reason: string) => void>();
const petWindowDragging = new WeakMap<BrowserWindow, boolean>();
const petWindowScaling = new WeakMap<BrowserWindow, boolean>();
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<string, unknown> { 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<void> {
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}
</div>
</div>
<div class="scale-handle" aria-label="Resize pet" title="Drag to resize"></div>
</div>`;
}
@ -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 ? `<div class="bubble-header"><span class="bubble-status-icon${status.iconSvg ? " has-svg" : ""}" data-icon="${escapeHtml(status.icon ?? "")}" aria-hidden="true">${status.iconSvg ?? ""}</span><span class="bubble-status-label">${escapeHtml(status.label)}</span></div>` : "";
const divider = status && text ? `<div class="bubble-divider" aria-hidden="true"></div>` : "";
const body = text ? `<div class="bubble-body"><span class="bubble-text">${escapeHtml(text)}</span></div>` : "";
@ -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<void> {
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet";

View file

@ -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();
}

View file

@ -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 = `<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><meta name="viewport" content="width=device-width, initial-scale=1"><title>OpenPets Prompt</title><style>
:root{color-scheme:light dark;font-family:"Avenir Next",Avenir,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
html[data-theme="dark"]{--window-bg:linear-gradient(180deg,#232323 0,#1a1a1a 56%,#121212 100%);--panel-bg:linear-gradient(180deg,rgba(28,28,28,.98),rgba(18,18,18,.99));--surface-bg:rgba(13,13,13,.96);--surface-alt:rgba(24,24,24,.95);--border:rgba(255,255,255,.09);--border-strong:rgba(255,255,255,.14);--text-primary:#f2f4f7;--text-muted:#a9afba;--text-dim:#8c93a0;--shadow:0 16px 42px rgba(0,0,0,.4);--danger-bg:rgba(74,24,31,.92);--danger-border:rgba(248,113,113,.28);--danger-text:#ffd2d2;--button-bg:rgba(32,32,32,.94);--button-border:rgba(255,255,255,.1);--button-text:#e7ebf2;--button-hover:rgba(44,44,44,.98);--button-active:rgba(58,58,58,.98);--primary-bg:linear-gradient(135deg,#4b8dff,#2f6fea);--primary-text:#fff}
html[data-theme="light"]{--window-bg:linear-gradient(180deg,#fafbfc 0,#f0f3f7 56%,#e7edf4 100%);--panel-bg:linear-gradient(180deg,rgba(255,255,255,.98),rgba(242,246,250,.99));--surface-bg:rgba(255,255,255,.98);--surface-alt:rgba(246,248,251,.96);--border:rgba(110,122,139,.18);--border-strong:rgba(110,122,139,.26);--text-primary:#182332;--text-muted:#5f6c7c;--text-dim:#728092;--shadow:0 16px 38px rgba(31,41,55,.14);--danger-bg:rgba(255,243,243,.98);--danger-border:rgba(220,38,38,.18);--danger-text:#a11a1a;--button-bg:rgba(244,247,251,.98);--button-border:rgba(110,122,139,.16);--button-text:#223144;--button-hover:rgba(236,241,247,.98);--button-active:rgba(226,234,243,.98);--primary-bg:linear-gradient(135deg,#3878f4,#2f6fea);--primary-text:#fff}
html,body{margin:0;height:100%;background:var(--window-bg);color:var(--text-primary)}
body{display:flex;align-items:stretch;justify-content:stretch;overflow:hidden}
button,textarea{font:inherit}
textarea{font-family:inherit}
.shell{position:relative;display:flex;flex:1;flex-direction:column;min-height:100%;padding:8px 8px 6px;gap:6px;box-sizing:border-box;border:1px solid var(--border);border-radius:16px;overflow:hidden;background:var(--panel-bg);box-shadow:var(--shadow)}
.editor-shell{display:flex;min-height:0;flex:1 1 auto;overflow:hidden}
.editor-shell[hidden]{display:none}
.history-list{width:100%;box-sizing:border-box;flex:1 1 auto;min-height:164px;border:1px solid var(--border-strong);border-radius:12px;background:var(--surface-bg);color:var(--text-primary);padding:10px 10px 10px;overflow:auto;user-select:text;-webkit-user-select:text;display:flex;flex-direction:column;gap:8px}
.history-entry{display:flex;flex-direction:column;gap:3px;padding:8px 10px;border-radius:10px;background:var(--surface-alt);border:1px solid var(--border);animation:entry-in 160ms cubic-bezier(.2,0,0,1)}
.history-entry-user{background:rgba(59,130,246,.09);border-color:rgba(59,130,246,.14)}
.history-entry-assistant{background:rgba(16,185,129,.08);border-color:rgba(16,185,129,.13)}
.history-entry-system{background:rgba(245,158,11,.08);border-color:rgba(245,158,11,.13)}
.history-entry-error{background:rgba(239,68,68,.09);border-color:rgba(239,68,68,.14)}
.history-entry-header{display:flex;align-items:center;gap:8px}
.history-entry-role{font:700 9.5px/1.2 ui-sans-serif,system-ui,sans-serif;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:6px;background:var(--button-bg);color:var(--text-muted);border:1px solid var(--button-border);white-space:nowrap}
.history-entry-user .history-entry-role{background:rgba(59,130,246,.16);color:#93bbfc;border-color:rgba(59,130,246,.22)}
.history-entry-assistant .history-entry-role{background:rgba(16,185,129,.16);color:#6ee7b7;border-color:rgba(16,185,129,.22)}
.history-entry-system .history-entry-role{background:rgba(245,158,11,.16);color:#fcd34d;border-color:rgba(245,158,11,.22)}
.history-entry-error .history-entry-role{background:rgba(239,68,68,.18);color:#fca5a5;border-color:rgba(239,68,68,.24)}
.history-entry-time{font:500 9px/1.2 ui-sans-serif,system-ui,sans-serif;color:var(--text-dim);margin-left:auto}
.history-entry-text{font:500 11.5px/1.55 ui-sans-serif,system-ui,sans-serif;color:var(--text-primary);white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
.history-entry-empty{text-align:center;color:var(--text-dim);font:500 12px/1.5 ui-sans-serif,system-ui,sans-serif;padding:24px 8px}
.conversations-list{width:100%;box-sizing:border-box;flex:1 1 auto;min-height:164px;border:1px solid var(--border-strong);border-radius:12px;background:var(--surface-bg);color:var(--text-primary);padding:10px 10px 10px;overflow:auto;user-select:text;-webkit-user-select:text;display:flex;flex-direction:column;gap:6px}
.conversations-list[hidden]{display:none}
.conversation-item{display:flex;flex-direction:column;gap:3px;padding:9px 11px;border-radius:10px;background:var(--surface-alt);border:1px solid var(--border);cursor:pointer;transition:background .14s ease,border-color .14s ease;animation:entry-in 160ms cubic-bezier(.2,0,0,1)}
.conversation-item:hover{background:var(--button-hover);border-color:rgba(115,166,255,.25)}
.conversation-item.is-active{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.22)}
.conversation-item-header{display:flex;align-items:center;gap:8px;justify-content:space-between}
.conversation-item-title{font:600 11.5px/1.35 ui-sans-serif,system-ui,sans-serif;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto;min-width:0}
.conversation-item-meta{font:500 9px/1.2 ui-sans-serif,system-ui,sans-serif;color:var(--text-dim);white-space:nowrap}
.conversation-item-actions{display:flex;align-items:center;gap:6px;margin-top:2px}
.conversation-item-delete{font:500 9.5px/1.2 ui-sans-serif,system-ui,sans-serif;color:var(--danger-text);background:transparent;border:none;padding:2px 6px;border-radius:5px;cursor:pointer}
.conversation-item-delete:hover{background:var(--danger-bg)}
.conversation-empty{text-align:center;color:var(--text-dim);font:500 12px/1.5 ui-sans-serif,system-ui,sans-serif;padding:24px 8px}
@keyframes entry-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
.toolbar{display:flex;align-items:center;justify-content:flex-end;gap:6px;padding-top:1px;-webkit-app-region:drag}
.toolbar-actions{display:flex;align-items:center;gap:6px;-webkit-app-region:no-drag}
.icon-button,.send-button{display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--button-border);background:var(--button-bg);color:var(--button-text);cursor:pointer;transition:background .14s ease,border-color .14s ease,opacity .14s ease,transform .14s ease}
.icon-button{width:24px;height:24px;border-radius:8px;padding:0}
.icon-button:hover:not(:disabled){background:var(--button-hover);transform:translateY(-1px)}
.icon-button.is-active{background:var(--button-active);border-color:rgba(115,166,255,.3)}
.icon-button:disabled,.send-button:disabled{cursor:default;opacity:.55}
.icon{width:13px;height:13px;display:block}
.composer{display:flex;flex-direction:column;gap:0;-webkit-app-region:no-drag;position:relative}
.editor-shell:not([hidden])~.composer{flex:0 0 auto}
.editor-shell[hidden]~.composer{flex:1 1 auto}
.prompt-shell{flex:1 1 auto}
.prompt-shell{display:flex;align-items:stretch;min-width:0;border:1px solid var(--border-strong);border-radius:14px;background:var(--surface-bg);overflow:hidden}
.prompt-shell:focus-within{border-color:#73a6ff;box-shadow:0 0 0 3px rgba(59,130,246,.14)}
.input-wrap{flex:1 1 auto;min-width:0;display:flex;flex-direction:column}
.prompt-input{width:100%;box-sizing:border-box;border:none;background:transparent;color:var(--text-primary);outline:none;resize:none;padding:12px 13px 6px;min-height:60px;max-height:136px;font:500 12px/1.5 inherit;overflow:auto;flex:1 1 auto}
.shell.is-compact .prompt-input{min-height:44px}
.send-button{width:42px;min-width:42px;align-self:stretch;border:none;border-left:1px solid var(--button-border);border-radius:0;padding:0;background:var(--primary-bg);color:var(--primary-text);box-shadow:none}
.send-button:hover:not(:disabled){transform:translateY(-1px)}
.feedback{min-height:0;max-height:40px;padding:0 13px 10px;font-size:10px;line-height:1.42;color:var(--text-dim);white-space:pre-wrap;overflow-wrap:anywhere;overflow-y:auto}
.shell.is-compact .feedback{max-height:24px}
.feedback.error{padding-top:2px;color:var(--danger-text);background:linear-gradient(180deg,transparent 0,rgba(127,29,29,.12) 100%)}
.feedback:empty{display:none}
.resize-grip{position:absolute;right:6px;bottom:6px;width:16px;height:16px;border-radius:8px;cursor:nwse-resize;-webkit-app-region:no-drag;z-index:2}
.resize-grip::before{content:"";position:absolute;right:2px;bottom:2px;width:11px;height:11px;background:linear-gradient(135deg,transparent 0,transparent 42%,var(--text-dim) 42%,var(--text-dim) 50%,transparent 50%,transparent 64%,var(--text-dim) 64%,var(--text-dim) 72%,transparent 72%);opacity:.72}
@media (max-width:460px){.shell{padding:7px 7px 5px}.history-list{min-height:148px}.conversations-list{min-height:148px}.prompt-input{min-height:58px}.send-button{width:38px;min-width:38px}}
</style></head><body><div class="shell" id="shell"><div class="editor-shell" id="editorShell" hidden><div id="conversations" class="conversations-list" hidden aria-label="Conversations"></div><div id="history" class="history-list" aria-label="Conversation history"></div></div><div class="toolbar"><div class="toolbar-actions"><button class="icon-button" id="editor" type="button" aria-label="Toggle editor" title="Editor"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8"/><path d="M8 13h5"/></svg></button><button class="icon-button" id="newChat" type="button" aria-label="Start new chat" title="New chat"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button><button class="icon-button" id="historyButton" type="button" aria-label="Open history" title="History"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><path d="M12 7v6l4 2"/></svg></button><button class="icon-button" id="settings" type="button" aria-label="Open settings" title="Settings"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 0 1 0 1.7l-1.2 1.2a1.2 1.2 0 0 1-1.7 0l-.1-.1a1 1 0 0 0-1.1-.2 1 1 0 0 0-.6.9V20a1.2 1.2 0 0 1-1.2 1.2h-1.7A1.2 1.2 0 0 1 10.9 20v-.1a1 1 0 0 0-.6-.9 1 1 0 0 0-1.1.2l-.1.1a1.2 1.2 0 0 1-1.7 0l-1.2-1.2a1.2 1.2 0 0 1 0-1.7l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.9-.6H4A1.2 1.2 0 0 1 2.8 13v-2A1.2 1.2 0 0 1 4 9.8h.1a1 1 0 0 0 .9-.6 1 1 0 0 0-.2-1.1l-.1-.1a1.2 1.2 0 0 1 0-1.7l1.2-1.2a1.2 1.2 0 0 1 1.7 0l.1.1a1 1 0 0 0 1.1.2 1 1 0 0 0 .6-.9V4A1.2 1.2 0 0 1 10.6 2.8h1.7A1.2 1.2 0 0 1 13.5 4v.1a1 1 0 0 0 .6.9 1 1 0 0 0 1.1-.2l.1-.1a1.2 1.2 0 0 1 1.7 0l1.2 1.2a1.2 1.2 0 0 1 0 1.7l-.1.1a1 1 0 0 0-.2 1.1 1 1 0 0 0 .9.6H20a1.2 1.2 0 0 1 1.2 1.2v2A1.2 1.2 0 0 1 20 14.2h-.1a1 1 0 0 0-.9.8z"/></svg></button><button class="icon-button" id="close" type="button" aria-label="Close chat window" title="Close"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12"/><path d="M18 6L6 18"/></svg></button></div></div><div class="composer"><div class="prompt-shell" id="promptShell"><div class="input-wrap"><textarea id="prompt" class="prompt-input" placeholder="Ask anything." aria-label="Prompt input"></textarea><div class="feedback" id="feedback" aria-live="polite"></div></div><button class="send-button" id="send" type="button" aria-label="Send prompt" title="Send"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h12"/><path d="M13 6l6 6-6 6"/></svg></button></div></div><div class="resize-grip" id="resizeGrip" aria-hidden="true"></div></div></div><script>
const shellEl = document.getElementById('shell');
const api = window.openPetsPromptWindow;
const editorShellEl = document.getElementById('editorShell');
const historyEl = document.getElementById('history');
const conversationsEl = document.getElementById('conversations');
const promptEl = document.getElementById('prompt');
const feedbackEl = document.getElementById('feedback');
const sendButton = document.getElementById('send');
const editorButton = document.getElementById('editor');
const newChatButton = document.getElementById('newChat');
const historyButton = document.getElementById('historyButton');
const settingsButton = document.getElementById('settings');
const closeButton = document.getElementById('close');
const resizeGrip = document.getElementById('resizeGrip');
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
const compactHeight = ${promptWindowCompactHeight};
const expandedHeight = ${promptWindowExpandedHeight};
const minWidth = ${promptWindowMinWidth};
const maxWidth = ${promptWindowMaxWidth};
const minHeight = ${promptWindowMinHeight};
const maxHeight = ${promptWindowMaxHeight};
let busy = false;
let state = null;
let editorVisible = false;
let editorView = 'messages'; // 'messages' | 'conversations'
let resizeSession = null;
let pendingResize = null;
let resizeFrame = 0;
let currentCompactHeight = compactHeight;
let currentExpandedHeight = expandedHeight;
const getResolvedTheme = (mode) => {
if (mode === 'light' || mode === 'dark') return mode;
return systemThemeMedia.matches ? 'dark' : 'light';
};
const applyThemeMode = (mode) => {
document.documentElement.dataset.theme = getResolvedTheme(mode === 'light' || mode === 'dark' ? mode : 'system');
};
const normalizeErrorMessage = (error) => {
let message = error && typeof error.message === 'string' ? error.message : 'Prompt failed.';
const invokeMatch = message.match(/Error invoking remote method '[^']+': Error: (.+)$/);
if (invokeMatch) {
message = invokeMatch[1];
}
if (message.startsWith('Error: ')) {
message = message.slice(7);
}
return message.trim();
};
const setFeedback = (text, tone, reason = '') => {
feedbackEl.textContent = typeof text === 'string' ? text : '';
feedbackEl.className = tone === 'error' ? 'feedback error' : 'feedback';
feedbackEl.dataset.reason = reason;
fitWindowToContent();
};
const getCurrentHistory = () => state && Array.isArray(state.history) ? state.history : [];
const getCurrentConversations = () => state && Array.isArray(state.conversations) ? state.conversations : [];
const formatEntryRole = (entry) => {
if (!entry || entry.role === 'system') return 'System';
if (entry.role === 'assistant') return 'Pet';
return 'You';
};
const formatEntryRoleClass = (entry) => {
if (!entry) return 'history-entry-error';
if (entry.tone === 'error') return 'history-entry-error';
if (entry.role === 'system') return 'history-entry-system';
if (entry.role === 'assistant') return 'history-entry-assistant';
return 'history-entry-user';
};
const formatTime = (timestamp) => {
if (!timestamp || typeof timestamp !== 'number') return '';
const d = new Date(timestamp);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return h + ':' + m;
};
const escapeHtmlText = (text) => {
if (typeof text !== 'string') return '';
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
const renderHistory = () => {
const history = getCurrentHistory();
if (!history.length) {
historyEl.innerHTML = '<div class="history-entry-empty">No conversation yet.</div>';
return;
}
historyEl.innerHTML = history.map((entry, index) => {
const role = formatEntryRole(entry);
const roleClass = formatEntryRoleClass(entry);
const time = formatTime(entry.createdAt);
const text = typeof entry.text === 'string' ? entry.text : '';
const timeHtml = time ? '<span class="history-entry-time">' + time + '</span>' : '';
return '<div class="history-entry ' + roleClass + '" data-index="' + index + '"><div class="history-entry-header"><span class="history-entry-role">' + role + '</span>' + timeHtml + '</div><div class="history-entry-text">' + escapeHtmlText(text) + '</div></div>';
}).join('');
historyEl.scrollTop = historyEl.scrollHeight;
};
const formatDate = (timestamp) => {
if (!timestamp || typeof timestamp !== 'number') return '';
const d = new Date(timestamp);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return 'Today at ' + h + ':' + m;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return 'Yesterday at ' + h + ':' + m;
}
const options = { month: 'short', day: 'numeric' };
return d.toLocaleDateString('en-US', options);
};
const renderConversations = () => {
const conversations = getCurrentConversations();
if (!conversations.length) {
conversationsEl.innerHTML = '<div class="conversation-empty">No conversations yet.</div>';
return;
}
const currentId = state && state.currentConversationId ? state.currentConversationId : null;
conversationsEl.innerHTML = conversations.map((conv) => {
const isActive = conv.id === currentId;
const date = formatDate(conv.updatedAt || conv.createdAt);
const count = typeof conv.messageCount === 'number' ? conv.messageCount + ' messages' : '';
const meta = date + (count ? ' · ' + count : '');
return '<div class="conversation-item' + (isActive ? ' is-active' : '') + '" data-id="' + escapeHtmlText(conv.id) + '"><div class="conversation-item-header"><span class="conversation-item-title">' + escapeHtmlText(conv.title || 'Untitled') + '</span><span class="conversation-item-meta">' + escapeHtmlText(meta) + '</span></div><div class="conversation-item-actions"><button class="conversation-item-delete" data-delete-id="' + escapeHtmlText(conv.id) + '">Delete</button></div></div>';
}).join('');
};
const setBusy = (nextBusy) => {
busy = nextBusy;
const canSend = Boolean(state && state.hasCredential && !busy && promptEl.value.trim());
sendButton.disabled = !canSend;
editorButton.disabled = busy;
newChatButton.disabled = busy;
historyButton.disabled = busy;
settingsButton.disabled = busy;
promptEl.disabled = busy;
};
const queueWindowResize = (width, height, anchor) => {
pendingResize = {
width: Math.min(Math.max(Math.round(width), minWidth), maxWidth),
height: Math.min(Math.max(Math.round(height), minHeight), maxHeight),
anchor: anchor || 'top-left',
};
if (resizeFrame) return;
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = 0;
const nextResize = pendingResize;
pendingResize = null;
if (nextResize) {
void api.resizeWindow(nextResize);
}
});
};
const ensureExpandedWindow = () => {
if (window.innerHeight < currentExpandedHeight) {
queueWindowResize(window.innerWidth, currentExpandedHeight, 'bottom-left');
}
};
const shrinkToCompactWindow = () => {
if (window.innerHeight !== currentCompactHeight) {
queueWindowResize(window.innerWidth, currentCompactHeight, 'bottom-left');
}
};
const fitWindowToContent = () => {
if (!shellEl || !editorVisible) return;
const overflow = shellEl.scrollHeight - shellEl.clientHeight;
if (overflow <= 0) return;
const targetHeight = Math.min(maxHeight, Math.max(window.innerHeight + overflow + 4, currentExpandedHeight));
if (targetHeight > window.innerHeight) {
queueWindowResize(window.innerWidth, targetHeight, 'bottom-left');
}
};
const setEditorVisible = (nextVisible, options = {}) => {
const nextView = options.view || editorView;
editorVisible = Boolean(nextVisible);
editorView = nextView;
editorShellEl.hidden = !editorVisible;
shellEl.classList.toggle('is-compact', !editorVisible);
editorButton.classList.toggle('is-active', editorVisible && editorView === 'messages');
editorButton.setAttribute('aria-pressed', editorVisible && editorView === 'messages' ? 'true' : 'false');
historyButton.classList.toggle('is-active', editorVisible && editorView === 'conversations');
if (editorVisible) {
if (editorView === 'conversations') {
historyEl.hidden = true;
conversationsEl.hidden = false;
renderConversations();
} else {
historyEl.hidden = false;
conversationsEl.hidden = true;
renderHistory();
}
if (options.resize !== false) ensureExpandedWindow();
if (options.focusHistory) {
if (editorView === 'conversations') {
conversationsEl.focus();
} else {
historyEl.focus();
historyEl.scrollTop = historyEl.scrollHeight;
}
}
} else {
if (options.resize !== false) shrinkToCompactWindow();
promptEl.focus();
fitWindowToContent();
}
};
const adjustPromptHeight = () => {
promptEl.style.height = 'auto';
const nextHeight = Math.min(Math.max(promptEl.scrollHeight, 60), 136);
promptEl.style.height = String(nextHeight) + 'px';
fitWindowToContent();
};
const renderState = () => {
applyThemeMode(state && state.themeMode ? state.themeMode : 'system');
if (editorVisible) {
if (editorView === 'conversations') {
renderConversations();
} else {
renderHistory();
}
}
if (!state || !state.hasCredential) {
setFeedback('Add chat credential in Settings.', 'error', 'missing-key');
} else if (feedbackEl.dataset.reason === 'missing-key') {
setFeedback('', '', '');
}
setBusy(false);
adjustPromptHeight();
};
const refreshState = async () => {
state = await api.getState();
renderState();
};
const submitPrompt = async () => {
if (busy || !state || !state.hasCredential || !promptEl.value.trim()) return;
setBusy(true);
try {
state = await api.submitPrompt(promptEl.value);
promptEl.value = '';
setFeedback('', '', '');
renderState();
} catch (error) {
try {
state = await api.getState();
applyThemeMode(state && state.themeMode ? state.themeMode : 'system');
if (editorVisible) renderState();
} catch {}
setFeedback(normalizeErrorMessage(error), 'error', 'request');
setBusy(false);
adjustPromptHeight();
}
};
editorButton.addEventListener('click', () => {
if (busy) return;
if (editorVisible && editorView === 'messages') {
setEditorVisible(false);
} else {
setEditorVisible(true, { view: 'messages' });
}
});
newChatButton.addEventListener('click', () => {
void (async () => {
if (busy) return;
setBusy(true);
try {
state = await api.createConversation();
setFeedback('', '', '');
renderState();
if (editorVisible && editorView === 'conversations') {
renderConversations();
}
} catch (error) {
setFeedback(normalizeErrorMessage(error), 'error', 'request');
setBusy(false);
}
})();
});
historyButton.addEventListener('click', () => {
if (busy) return;
if (editorVisible && editorView === 'conversations') {
setEditorVisible(false);
} else {
setEditorVisible(true, { view: 'conversations', focusHistory: true });
}
});
settingsButton.addEventListener('click', () => {
if (busy) return;
void api.openSettings();
});
closeButton.addEventListener('click', () => {
void api.close();
});
sendButton.addEventListener('click', () => {
void submitPrompt();
});
promptEl.addEventListener('input', () => {
if (!busy && feedbackEl.dataset.reason === 'request') {
setFeedback('', '', '');
}
adjustPromptHeight();
setBusy(false);
});
promptEl.addEventListener('keydown', (event) => {
if (!event.isComposing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
event.preventDefault();
void submitPrompt();
return;
}
if (event.key === 'Escape' && !busy) {
event.preventDefault();
void api.close();
}
});
historyEl.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
void api.close();
}
});
conversationsEl.addEventListener('click', (event) => {
if (busy) return;
const item = event.target.closest('.conversation-item');
if (item && item.dataset.id) {
void (async () => {
setBusy(true);
try {
state = await api.switchConversation(item.dataset.id);
renderState();
setEditorVisible(true, { view: 'messages' });
} catch (error) {
setFeedback(normalizeErrorMessage(error), 'error', 'request');
setBusy(false);
}
})();
return;
}
const deleteBtn = event.target.closest('.conversation-item-delete');
if (deleteBtn && deleteBtn.dataset.deleteId) {
if (!window.confirm('Delete this conversation?')) return;
void (async () => {
setBusy(true);
try {
state = await api.deleteConversation(deleteBtn.dataset.deleteId);
renderState();
if (editorVisible && editorView === 'conversations') {
renderConversations();
}
} catch (error) {
setFeedback(normalizeErrorMessage(error), 'error', 'request');
setBusy(false);
}
})();
}
});
conversationsEl.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
void api.close();
}
});
resizeGrip.addEventListener('pointerdown', (event) => {
if (busy) return;
resizeSession = {
pointerId: event.pointerId,
startX: event.screenX,
startY: event.screenY,
width: window.innerWidth,
height: window.innerHeight,
};
resizeGrip.setPointerCapture(event.pointerId);
event.preventDefault();
});
resizeGrip.addEventListener('pointermove', (event) => {
if (!resizeSession || event.pointerId !== resizeSession.pointerId) return;
const nextWidth = resizeSession.width + (event.screenX - resizeSession.startX);
const nextHeight = Math.max(minHeight, resizeSession.height + (event.screenY - resizeSession.startY));
if (editorVisible) {
currentExpandedHeight = Math.min(Math.max(nextHeight, minHeight), maxHeight);
} else {
currentCompactHeight = Math.min(Math.max(nextHeight, minHeight), maxHeight);
}
queueWindowResize(nextWidth, nextHeight, 'top-left');
});
const finishResize = (event) => {
if (!resizeSession) return;
if (event && event.pointerId === resizeSession.pointerId && resizeGrip.hasPointerCapture(event.pointerId)) {
resizeGrip.releasePointerCapture(event.pointerId);
}
resizeSession = null;
};
resizeGrip.addEventListener('pointerup', finishResize);
resizeGrip.addEventListener('pointercancel', finishResize);
systemThemeMedia.addEventListener('change', () => {
if (state && state.themeMode === 'system') {
applyThemeMode('system');
}
});
window.addEventListener('focus', () => {
void refreshState().catch(() => undefined);
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
void api.close();
}
});
void refreshState().then(() => {
setEditorVisible(false, { resize: false });
promptEl.focus();
}).catch((error) => {
setFeedback(normalizeErrorMessage(error), 'error', 'request');
setBusy(false);
});
</script></body></html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load diff

View file

@ -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<McpToolkitTier, string> = {
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);
}

View file

@ -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; }

View file

@ -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<ReturnType<typeof getAppStateSnapshot>["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides">;
preferences: Pick<ReturnType<typeof getAppStateSnapshot>["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides" | "openApiChatModel" | "openApiChatSystemPrompt" | "openApiChatEndpoint" | "openApiChatTheme" | "vanillaChatMcpTools" | "openApiChatBaseInstructionsEnabled">;
petScaleOptions: typeof petScaleOptions;
openApiChat: ReturnType<typeof getOpenApiChatSettingsSnapshot>;
} {
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<typeof validateReactionAnimationOverrides> } {
function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides>; 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<typeof validateReactionAnimationOverrides> } = {};
const patch: { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides>; 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<string, unknown> {
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();
}

View file

@ -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);

View file

@ -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.");

View file

@ -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:

View file

@ -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<unknown>;
say(message: string, options?: { readonly reaction?: OpenPetsReaction; readonly leaseId?: string }): Promise<unknown>;
listMemories?(options?: { readonly limit?: number }): Promise<OpenPetsMemoryListResult>;
searchMemories?(query: string, options?: { readonly limit?: number }): Promise<OpenPetsMemorySearchResult>;
storeMemory?(input: { readonly text: string; readonly kind?: OpenPetsMemoryKind; readonly tags?: readonly string[]; readonly importance?: number }): Promise<OpenPetsMemoryStoreResult>;
deleteMemory?(id: string): Promise<OpenPetsMemoryDeleteResult>;
}
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<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -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;

View file

@ -48,6 +48,13 @@ async function checkMcpServerContract(): Promise<void> {
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<void> {
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<void> {
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<void> {
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(",")}`);
}

View file

@ -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;
}

View file

@ -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<C
}
}
export async function handleMemoryList(input: unknown, context: ToolContext): Promise<CallToolResult> {
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<CallToolResult> {
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<CallToolResult> {
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<CallToolResult> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}
export type { OpenPetsReaction };
export type { OpenPetsMemoryKind, OpenPetsReaction };

3
pnpm-lock.yaml generated
View file

@ -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