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:
parent
baf1daee45
commit
08eff41255
41 changed files with 6712 additions and 95 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
299
FEATURES.md
Normal 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
105
FEATURES_OUR_CHANGES.md
Normal 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.
|
||||
58
README.md
58
README.md
|
|
@ -10,6 +10,10 @@
|
|||
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
15
apps/desktop/prompt-window-preload.cjs
Normal file
15
apps/desktop/prompt-window-preload.cjs
Normal 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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
244
apps/desktop/src/mcp-chat-client.ts
Normal file
244
apps/desktop/src/mcp-chat-client.ts
Normal 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));
|
||||
}
|
||||
395
apps/desktop/src/mcp-toolkit-installer.ts
Normal file
395
apps/desktop/src/mcp-toolkit-installer.ts
Normal 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);
|
||||
}
|
||||
1150
apps/desktop/src/openapi-chat.ts
Normal file
1150
apps/desktop/src/openapi-chat.ts
Normal file
File diff suppressed because it is too large
Load diff
564
apps/desktop/src/openpets-memory.ts
Normal file
564
apps/desktop/src/openpets-memory.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
141
apps/desktop/src/prompt-memory-extraction.ts
Normal file
141
apps/desktop/src/prompt-memory-extraction.ts
Normal 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();
|
||||
}
|
||||
812
apps/desktop/src/prompt-window.ts
Normal file
812
apps/desktop/src/prompt-window.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
};
|
||||
|
||||
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
533
apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
Normal file
533
apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
52
apps/desktop/tests/prompt-memory-extraction.test.ts
Normal file
52
apps/desktop/tests/prompt-memory-extraction.test.ts
Normal 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.");
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(",")}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue