feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars

- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar.
- Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages.
- Rename plugin IDs and directories from openpets.* to familiaros.*.
- Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration.
- Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents.
- Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings.
- Add Familiar naming feature: preference, settings input, tray menu display.
- Update assets and packaging config; all desktop tests pass.
This commit is contained in:
OpenPets Dev 2026-06-17 01:42:08 +00:00
parent eae6e5074a
commit 6ab3bb64d8
404 changed files with 8975 additions and 8886 deletions

View file

@ -8,21 +8,21 @@ body:
value: |
Thanks for reporting a bug. Keep it short — we only need enough to reproduce it.
If the desktop app starts, please consider attaching your OpenPets log. It is not required, but it is highly recommended because it often explains startup, tray, pet display, and integration issues.
If the desktop app starts, please consider attaching your FamiliarOS log. It is not required, but it is highly recommended because it often explains startup, tray, familiar display, and integration issues.
To find it: open OpenPets from the tray/menu bar, choose **Open Logs Folder**, then attach `openpets.log` to this issue. If OpenPets cannot start, check these folders manually:
- macOS: `~/Library/Application Support/OpenPets/logs/`
- Windows: `%APPDATA%\OpenPets\logs\`
- Linux: `~/.config/OpenPets/logs/`
To find it: open FamiliarOS from the tray/menu bar, choose **Open Logs Folder**, then attach `familiaros.log` to this issue. If FamiliarOS cannot start, check these folders manually:
- macOS: `~/Library/Application Support/FamiliarOS/logs/`
- Windows: `%APPDATA%\FamiliarOS\logs\`
- Linux: `~/.config/FamiliarOS/logs/`
If the problem happened before the latest restart, `openpets.previous.log` may also be useful.
If the problem happened before the latest restart, `familiaros.previous.log` may also be useful.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Describe the bug. Include your OS/app version if you know it.
placeholder: "The desktop pet disappeared after..."
placeholder: "The desktop familiar disappeared after..."
validations:
required: true
@ -30,7 +30,7 @@ body:
id: logs
attributes:
label: Logs (optional, highly recommended)
description: Attach openpets.log if you can. If the app cannot start or you cannot find it, leave this blank.
placeholder: "Drag openpets.log here, or paste the most relevant lines."
description: Attach familiaros.log if you can. If the app cannot start or you cannot find it, leave this blank.
placeholder: "Drag familiaros.log here, or paste the most relevant lines."
validations:
required: false

View file

@ -1,5 +1,5 @@
name: Feature request
description: Suggest an improvement for OpenPets.
description: Suggest an improvement for FamiliarOS.
title: "Feature: "
labels: ["feature"]
body:
@ -13,7 +13,7 @@ body:
attributes:
label: What do you want?
description: Describe the feature or change you would like to see.
placeholder: "I want OpenPets to..."
placeholder: "I want FamiliarOS to..."
validations:
required: true

View file

@ -1,18 +1,18 @@
name: Submit a pet
description: Suggest a pet for the OpenPets gallery.
title: "Pet submission: "
labels: ["pet submission"]
name: Submit a familiar
description: Suggest a familiar for the FamiliarOS gallery.
title: "Familiar submission: "
labels: ["familiar submission"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a pet. Keep it short — we only need the essentials.
Thanks for suggesting a familiar. Keep it short — we only need the essentials.
- type: input
id: pet-idea
id: familiar-idea
attributes:
label: Pet idea or name
description: What should the pet be called, and what is it?
label: Familiar idea or name
description: What should the familiar be called, and what is it?
placeholder: "Pixel Penguin — a tiny penguin coding companion"
validations:
required: true

View file

@ -2,7 +2,7 @@
"metadata": {
"version": "1.0.0",
"last_run": "2026-05-24T09:29:48.346Z",
"root": "/Volumes/external/repos/pets",
"root": "/Volumes/external/repos/familiars",
"include_patterns": [
"apps/**/*.ts",
"packages/**/*.ts",
@ -32,21 +32,21 @@
"apps/desktop/contracts/local-ipc-protocol.contract.ts": "76d45670b202ebd96cbee64d1e8e53ec",
"apps/desktop/contracts/plugin-manifest.contract.ts": "2b0eaa64d1e02dce6b14cfee9c6d2b3d",
"apps/desktop/package.json": "b83883566725e5c9a97fc9d7b5fd189a",
"apps/desktop/src/agent-pet-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-familiar-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-setup.ts": "c2193dd06fa7686b4446ea7dc5686b92",
"apps/desktop/src/app-state-core.ts": "9f4ea019ddb620bf8ad70dfe83f27496",
"apps/desktop/src/app-state.ts": "89a68328e992076d114b90de5b8fbe1b",
"apps/desktop/src/assets.ts": "d59129388a45ae10b5319242fd7a67ee",
"apps/desktop/src/built-in-pet.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/built-in-familiar.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/catalog-validation.ts": "6bd6974357781c0f972ef2b2da76eb12",
"apps/desktop/src/catalog.ts": "37fe90b36fa2eee976c3617fe1ee5989",
"apps/desktop/src/check-cursor-desktop.ts": "80b0d5c1913a47143970447153762617",
"apps/desktop/src/check-opencode-desktop-setup.ts": "08b86952c137b2e917d296c0c4590e5c",
"apps/desktop/src/check-packaging-contract.ts": "c5f74134e8e5e98623e2c69e71a79eec",
"apps/desktop/src/claude-memory.ts": "37f0a29734fad5e2bb5bd97110e7ddfa",
"apps/desktop/src/codex-pets-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-pets.ts": "885f214e14db799953b1ccf13a264478",
"apps/desktop/src/default-pet-controller.ts": "a46002d908de6596947cdaa1d122ea84",
"apps/desktop/src/codex-familiars-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-familiars.ts": "885f214e14db799953b1ccf13a264478",
"apps/desktop/src/default-familiar-controller.ts": "a46002d908de6596947cdaa1d122ea84",
"apps/desktop/src/display.ts": "366ab73790c796771ee0df94affe0642",
"apps/desktop/src/lease-manager.ts": "a8a84622bbd73a3b680a15b709c8dff0",
"apps/desktop/src/lifecycle.ts": "a346d3ebe789e6d434858481af0757d0",
@ -55,9 +55,9 @@
"apps/desktop/src/local-ipc.ts": "a8f25fd0b47d560782f2332e30acc115",
"apps/desktop/src/logger.ts": "a92e8de9ede984afc378d2d29d5fce0e",
"apps/desktop/src/main.ts": "5678745b8ea5c311ada5259545bb694e",
"apps/desktop/src/pet-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/pet-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/pet-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/familiar-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/familiar-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/familiar-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/plugin-catalog-validation.ts": "507deea55077a1130f9d4623a8fe8342",
"apps/desktop/src/plugin-catalog.ts": "10cffa1505a621f2e4ec4d4e1379823c",
"apps/desktop/src/plugin-config.ts": "4f1f0b6a83d6f705c7ef7b98b2412677",
@ -66,7 +66,7 @@
"apps/desktop/src/plugin-manifest-reader.ts": "2ba5d272ed3ae1b7e0131d2efdaf4caf",
"apps/desktop/src/plugin-manifest.ts": "15fe4e34408053cebe8b1b42e84cc6e7",
"apps/desktop/src/plugin-package.ts": "2aa09526568e4fd4f961d883ef373a38",
"apps/desktop/src/plugin-pet-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-familiar-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-runtime.ts": "5065842b784ca91207bf8e6ce3d1fab5",
"apps/desktop/src/plugin-sdk-bridge.ts": "a925bdd85834f00474e058e2c711d097",
"apps/desktop/src/plugin-service.ts": "7a6ecfc1f62a35e5c6d440e876dac788",
@ -110,9 +110,9 @@
"packages/cursor/src/cursor-rules.ts": "a05f35efb92e61e2493ca122bcf42aab",
"packages/cursor/src/cursor-status.ts": "c0d0a4dc377998336cf034c82d5bf56a",
"packages/cursor/src/index.ts": "324347811550c7206820993f68651cba",
"packages/install-pet/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-pet/src/check-install-pet.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-pet/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/install-familiar/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-familiar/src/check-install-familiar.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-familiar/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/mcp/package.json": "220512b0569b05ea9ec17950df4eb429",
"packages/mcp/src/args.ts": "6b84e85ab049f35181294be5ad3efb7a",
"packages/mcp/src/check-mcp-contract.ts": "0029e9dc79f600482b4c3d1ff1a0e630",
@ -131,8 +131,8 @@
"packages/opencode/src/opencode-project-setup.ts": "84b378ea0b22f9a99eced35edd5e584b",
"packages/opencode/src/opencode-status.ts": "2640eed91327a35c57a7162ab6730ccc",
"packages/opencode/src/plugin.ts": "8265624c6f4d87274928dde47405d893",
"packages/pet-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/pet-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/familiar-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/familiar-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/pi/package.json": "e539f5df26ab1f7aabe73ef0f924ebb6",
"packages/pi/src/check-pi-compat.ts": "e2f02e876e051e7f302f9ccf835953e3",
"packages/pi/src/check-pi.ts": "47a4f7d28b07fffe130419975f1cc7a4",
@ -161,14 +161,14 @@
"packages/client/src": "a036a1f04099db816b2cd6b552fda034",
"packages/cursor": "bc481b08e9fbe5e0464e6aef98f8c2ef",
"packages/cursor/src": "e6ff31505d96d16dcbea0afcb680d2a3",
"packages/install-pet": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-pet/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/install-familiar": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-familiar/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/mcp": "786e8bc32d478dbaa34bc3d1831cc6ec",
"packages/mcp/src": "e89d476284c4ec2814c11a064fcb210b",
"packages/opencode": "dd2382941a7bfe672b2f27dcbfa51e45",
"packages/opencode/src": "ed024a6207b42efd076da44ab9587adb",
"packages/pet-format": "ca85af1412e98214135e22bdcba5f406",
"packages/pet-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/familiar-format": "ca85af1412e98214135e22bdcba5f406",
"packages/familiar-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/pi": "df51ca2497141871a94e3383d042f051",
"packages/pi/src": "2a70d56d1b7ce40ed8a0416141834dd4"
}

View file

@ -35,15 +35,15 @@ When plugin work is finished, update these docs if behavior, commands, manifests
## Logging for Fast DX
When working on desktop UI, renderer, IPC, catalog, plugin, or pet-window behavior, add targeted logging as part of the implementation when it helps diagnose issues quickly.
When working on desktop UI, renderer, IPC, catalog, plugin, or familiar-window behavior, add targeted logging as part of the implementation when it helps diagnose issues quickly.
Prefer concise, scoped logs that capture data shape, selected IDs, load/error states, and boundary decisions.
Route renderer diagnostics into the app log when possible so failures are visible in `openpets.log`, not only DevTools.
Route renderer diagnostics into the app log when possible so failures are visible in `familiaros.log`, not only DevTools.
Avoid noisy permanent logs, secrets, full payload dumps, or logging in tight animation/render loops.
## Control Center CSP
When adding any renderer-visible URL scheme, image source, dev server endpoint, or internal protocol, update the Control Center CSP in both `apps/desktop/vite.config.ts` and `apps/desktop/src/renderer/index.html`.
Common pet image protocols include `openpets-codex:`, `openpets-installed:`, and `openpets-pet-preview:`; forgetting CSP causes images to load as the default/fallback pet even when install/render logic is correct.
Common familiar image protocols include `familiaros-codex:`, `familiaros-installed:`, and `familiaros-familiar-preview:`; forgetting CSP causes images to load as the default/fallback familiar even when install/render logic is correct.
## Ubuntu VMware Testing
@ -51,16 +51,16 @@ An Ubuntu 24.04 ARM64 VMware/Vagrant development VM exists for Linux GUI testing
- VM directory: `/Volumes/external/vmware/ubuntu24`
- Provider: `vmware_desktop` / VMware Fusion on Apple Silicon
- Guest OpenPets checkout: `/home/vagrant/src/openpets`
- Guest helper aliases: `cdpets` and `openpets-dx`
- Guest FamiliarOS checkout: `/home/vagrant/src/familiaros`
- Guest helper aliases: `cdpets` and `familiaros-dx`
Do not mount the macOS OpenPets checkout into Ubuntu for development. The macOS `node_modules` tree contains platform-specific packages and ownership metadata; using it from Linux can break local macOS development. Ubuntu testing should use the isolated guest clone and its own Linux `node_modules`.
Do not mount the macOS FamiliarOS checkout into Ubuntu for development. The macOS `node_modules` tree contains platform-specific packages and ownership metadata; using it from Linux can break local macOS development. Ubuntu testing should use the isolated guest clone and its own Linux `node_modules`.
For Linux GUI bug reproduction or Electron desktop testing:
1. Start or inspect the VM from `/Volumes/external/vmware/ubuntu24` with `vagrant up` / `vagrant status`.
2. SSH with `vagrant ssh`.
3. In the guest, run `cdpets` then `openpets-dx` to update dependencies, fix Electron sandbox permissions, and launch OpenPets in the Ubuntu desktop session.
4. Check guest logs at `~/.config/@open-pets/desktop/logs/openpets.log`.
3. In the guest, run `cdpets` then `familiaros-dx` to update dependencies, fix Electron sandbox permissions, and launch FamiliarOS in the Ubuntu desktop session.
4. Check guest logs at `~/.config/@familiaros/desktop/logs/familiaros.log`.
The VM is configured to boot into the Ubuntu desktop (`graphical.target`) with GDM auto-login for the `vagrant` user. Prefer this VM when validating Linux-specific renderer, Electron, tray, pet-window, IPC, plugin, or packaging behavior.
The VM is configured to boot into the Ubuntu desktop (`graphical.target`) with GDM auto-login for the `vagrant` user. Prefer this VM when validating Linux-specific renderer, Electron, tray, familiar-window, IPC, plugin, or packaging behavior.

View file

@ -1,12 +1,12 @@
# OpenPets Control Center: Design System & Engineering Manual
# FamiliarOS Control Center: Design System & Engineering Manual
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the OpenPets Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the FamiliarOS Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
---
## 1. Visual Direction & Heritage
The OpenPets Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop pet managers with a modern, glassmorphic utility interface.
The FamiliarOS Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop familiar managers with a modern, glassmorphic utility interface.
- **The Core Tension**: Playful nostalgia meets crisp, modern desktop productivity.
- **Atmospheric Depth**: Instead of flat, sterile surfaces, interfaces leverage rich color gradients, subtle mesh backdrops, structured panels, and tactile control elements.
@ -43,7 +43,7 @@ Surfaces are layered to establish clear spatial hierarchy and keep the interface
The desktop shell is designed to be self-contained, fitting exactly within the viewport height without scrolling the entire window.
- **Page Shell**: Max-width of `1160px` centered, utilizing a full-height flexbox column (`h-screen p-6`).
- **Hero Header**: A balanced layout placing title text and description on the left (`.hero-content`) and the wider brand artwork (`openpets.webp`) on the right (`.hero-logo-container`). Keep artwork large enough to feel intentional, use `object-contain`, and avoid rounded wrappers or distortion.
- **Hero Header**: A balanced layout placing title text and description on the left (`.hero-content`) and the wider brand artwork (`familiaros.webp`) on the right (`.hero-logo-container`). Keep artwork large enough to feel intentional, use `object-contain`, and avoid rounded wrappers or distortion.
- **Layout Split**: A clean grid split, typically:
- **Left Gallery/List Panel**: `1.25fr` — for browsing, searching, and filtering.
- **Right Detail/Action Panel**: `0.75fr` — for contextual inspection and heavy operations.
@ -61,7 +61,7 @@ The desktop shell is designed to be self-contained, fitting exactly within the v
- **Eyebrow / Section Header**: `text-xs font-bold uppercase tracking-[.18em] text-brand`
- **Card Title**: `text-sm font-semibold text-navy`
- **Metadata / Descriptions**: `text-xs text-slate-500`
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default pet"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default familiar"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
---
@ -78,14 +78,14 @@ All colors are mapped to functional roles to ensure consistency across themes.
| **Success / Ready** | `#059669` / `#34d399` | `emerald` | Active indicators, successfully installed badges |
| **Warning / Import** | `#d97706` / `#fbbf24` | `amber` | Codex source indicators, imports, attention states |
| **Featured** | `#7e22ce` / `#a855f7` | `purple` | Featured catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original OpenPets catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original FamiliarOS catalog filters and badges |
| **Danger / Remove** | `#dc2626` / `#ff6b6b` | `red` | Destructive actions, broken states, error messages |
---
## 6. Tactile Button System
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical pet hardware and OS controls.
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical familiar hardware and OS controls.
### Visual Architecture
- **Borders**: Darker semi-transparent bottom/side borders (`rgba(..., 0.32)`).
@ -130,12 +130,12 @@ Buttons are the core interactive elements. They reject flat modern trends in fav
- Amber/yellow-orange for `Originals`
- Match catalog v3/web gallery taxonomy where possible: `Featured` excludes originals, and category filters like `Western`/`Asian` exclude featured and originals.
### Pet Cards & Badges
### Familiar Cards & Badges
- Card grids need inner padding so selected rings and focus states are not clipped by the scroll container.
- Use badges to expose catalog identity, not just install state:
- `Original` for original/OpenPets pets
- `Featured` for featured non-original catalog pets
- `Western` / `Asian` for standard category pets
- `Original` for original/FamiliarOS familiars
- `Featured` for featured non-original catalog familiars
- `Western` / `Asian` for standard category familiars
- `Default`, `Installed`, and `Codex` for local state/source
- Avoid low-value detail badges such as `Available`; reserve detail badges for meaningful state or catalog identity.
@ -192,8 +192,8 @@ When refactoring other Control Center views, apply the following structural mapp
### B. Plugins Page
- **Old Layout**: Flat list of available integrations.
- **New Layout**: Grid-based layout matching the `.pets-grid` structure.
- **Cards**: Use the `.pet-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
- **New Layout**: Grid-based layout matching the `.familiars-grid` structure.
- **Cards**: Use the `.familiar-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
### C. Onboarding
- **Old Layout**: Full screen wizard.

View file

@ -1,6 +1,6 @@
# OpenPets Feature Inventory
# FamiliarOS Feature Inventory
This file is the current feature roll-up for this working fork of OpenPets.
This file is the current feature roll-up for this working fork of FamiliarOS.
It combines:
- the shipped baseline described in `README.md`
@ -15,34 +15,34 @@ It combines:
## 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
- Desktop familiar that can idle, react, wave, move, and speak
- Default familiar plus installed non-default familiars
- Per-agent familiar routing when integrations request a specific installed familiar
- Manual familiar dismissal with lease-aware recovery rules
- Native right-click familiar context menu
- Always-on-top familiar window with click-through background behavior
- Drag support with mouse passthrough recovery for Windows/Linux edge cases
- Adaptive pet scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default pet position
- Adaptive familiar scale setting (continuous slider from 0.16x to 10x in Settings)
- Reset default familiar 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
## Familiar 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
- Built-in default familiar
- Installed familiar pack loading
- Local familiar import from ZIP
- Local familiar import from folder
- Codex familiar import flow
- Familiar catalog browsing
- Familiar preview thumbnails and spritesheets
- Default familiar selection
- Familiar gallery linkout
## Reactions And Speech
- Explicit pet speech bubbles
- Explicit familiar speech bubbles
- Reaction-only decorative bubble messages
- Reaction animation mapping
- User-configurable reaction-to-animation overrides
@ -51,9 +51,9 @@ It combines:
- Safer speech rules for agent-driven bubble content
- Bubble behavior that avoids showing code, logs, URLs, paths, or secrets in normal integration speech
## Pet Text-to-Speech (Phase 2)
## Familiar Text-to-Speech (Phase 2)
- Settings > **Text-to-Speech** panel controls voice output for the pet and floating chat.
- Settings > **Text-to-Speech** panel controls voice output for the familiar and floating chat.
- **System voice** uses the OS speech engine through the renderer (`window.speechSynthesis`) with optional voice-name matching and a 0.5×2.0× speed multiplier.
- **Cloud TTS providers**: OpenAI TTS, ElevenLabs, and an **OpenAI-compatible** preset for OpenRouter, LiteLLM, WaveSpeedAI, or a custom endpoint.
- **Local TTS provider**: Piper (spawned as a local process) for fully offline speech.
@ -61,25 +61,25 @@ It combines:
- Per-provider model, voice, speed, endpoint preset, and custom endpoint controls.
- Dynamic voice list fetching for ElevenLabs; static voice lists for OpenAI / OpenAI-compatible.
- In-app **Test voice** and **Stop** buttons to preview the configured voice.
- Assistant replies and other speech are spoken through the pet window renderer via a shared TTS service (`apps/desktop/src/tts-service.ts`).
- Assistant replies and other speech are spoken through the familiar window renderer via a shared TTS service (`apps/desktop/src/tts-service.ts`).
- Respects quiet-hours: speech is skipped while quiet hours are active.
- Audio returned by cloud providers is validated (MP3/WAV magic bytes) and played through a renderer `<audio>` element.
- Endpoint validation enforces HTTPS for cloud providers, allows `http://localhost` for local proxies, and blocks private IP endpoints for cloud providers.
- Input capped at 2,000 characters; clear error messages when synthesis fails.
- Plugin SDK bridge: plugins granted the `voice:speak` permission can ask the pet to speak text through the same TTS pipeline.
- Plugin SDK bridge: plugins granted the `voice:speak` permission can ask the familiar to speak text through the same TTS pipeline.
## Virtual Pet Care
## Virtual Familiar Care
- Bundled `openpets.virtual-pet` plugin tracks hunger, energy, happiness, and affection
- Stats decay slowly while the app is running and recover when you feed, play, pet, or nap
- Status values live in the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level); no on-pet HUD overlay
- Single-click the pet to pet it (increases bond and happiness)
- Feed, play, and nap actions are also available from the pet context menu
- Bundled `familiaros.virtual-familiar` plugin tracks hunger, energy, happiness, and affection
- Stats decay slowly while the app is running and recover when you feed, play, familiar, or nap
- Status values live in the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level); no on-familiar HUD overlay
- Single-click the familiar to familiar it (increases bond and happiness)
- Feed, play, and nap actions are also available from the familiar context menu
- Automatic nudges when a stat gets low
## Floating Chat Surface
- Double-click the pet to open a floating prompt window
- Double-click the familiar to open a floating prompt window
- Floating prompt window stays always on top
- Compact prompt-first mode at 96px height; expanded editor/history mode at 280px
- Prompt bar is sized for 23 lines of text
@ -103,9 +103,9 @@ It combines:
- 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
- History stays in the prompt/editor surface rather than in the familiar bubble
- Familiar bubble only shows one assistant reply at a time
- Per-entry styling with role badges (You / Familiar / 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
@ -116,7 +116,7 @@ It combines:
## OpenAPI And BYOK Chat
- Main-process OpenAPI chat service
- User-entered prompt stays out of the pet renderer
- User-entered prompt stays out of the familiar 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
@ -141,12 +141,12 @@ It combines:
- Custom local template
- Generic HTTPS template
## Pet Character And Personality
## Familiar Character And Personality
- System-prompt style pet character field in General Settings
- System-prompt style familiar 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
- Base instructions toggle — optionally include or exclude the default FamiliarOS behavior instructions in every chat
- Theme setting shared between control center and floating chat window
- Auto theme mode
- Light theme mode
@ -155,7 +155,7 @@ It combines:
## Memory System
- Local-first persistent OpenPets memory store
- Local-first persistent FamiliarOS memory store
- Main-process memory writer
- In-memory retrieval during chat
- On-disk persistence for durable memories
@ -168,43 +168,43 @@ It combines:
- 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`)
- Two separate databases: explicit memories (`familiaros-memory.json`) and chat history (`familiaros-chat-history.json`)
## Knowledge Store
- Local-first file and memory knowledge repository for the desktop app
- Store files from Settings or directly from floating-chat attachments
- File metadata index persisted in `openpets-knowledge-store.json`
- File metadata index persisted in `familiaros-knowledge-store.json`
- Stored file contents copied to `userData/knowledge/files/` with sanitized names
- Automatic UTF-8 text extraction from text-like files (TXT, MD, JSON, code files, CSV, YAML, etc.)
- MIME type inference from file extension when not supplied
- File search by token and phrase overlap across file names and extracted text
- Relevance scoring with phrase bonus, recency weighting, and size penalty
- Relevant file context injected into OpenAPI chat system instructions
- Combined search across stored files and OpenPets memories
- Combined search across stored files and FamiliarOS memories
- Dedicated **Knowledge Store** tab in Settings with upload, search, list, and delete
- Store attached prompt-window files into the Knowledge Store via toolbar button
- File count cap (100 files) and per-file size cap (5 MB)
- Invalid-file rejection: empty files, non-Buffer data, missing names, and path-traversal names
- Electron-free core class (`knowledge-store-core.ts`) for deterministic Node unit tests
## OpenPets MCP Server
## FamiliarOS 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`
- Central **OpenPets MCP Server** panel in Control Center → Integrations
- MCP server for FamiliarOS desktop control
- Explicit target-familiar lease routing
- Default-familiar fallback when a requested familiar is unavailable
- `familiaros_status`
- `familiaros_react`
- `familiaros_say`
- `familiaros_memory_list`
- `familiaros_memory_search`
- `familiaros_memory_store`
- `familiaros_memory_forget`
- Central **FamiliarOS MCP Server** panel in Control Center → Integrations
- Command mode selector (published / bundled / local) in one place
- Node path override in the central panel
- Pet routing selector in the central panel
- Familiar routing selector in the central panel
- One-click **Test Server** health check
- One-click **Copy MCP JSON** for external hosts
@ -223,10 +223,10 @@ It combines:
- OpenCode plugin support
- Cursor MCP config preview
- Cursor rules preview
- Pet routing centralized in the OpenPets MCP Server panel
- Published package / bundled / local command modes centralized in the OpenPets MCP Server panel
- Familiar routing centralized in the FamiliarOS MCP Server panel
- Published package / bundled / local command modes centralized in the FamiliarOS MCP Server panel
- Agent-specific command path overrides (Claude command, OpenCode command)
- Node command path centralized in the OpenPets MCP Server panel
- Node command path centralized in the FamiliarOS MCP Server panel
## MCP Tool Servers (Built-in Chat)
@ -303,14 +303,14 @@ The control-center Integrations page now also includes a curated **Curated MCP T
## Dashboard And Control Center
- Dashboard overview
- Pet count
- Familiar count
- Plugin health summary
- Update status summary
- Reaction mix charting
- Top companion activity
- Activity counters
- Settings view
- Pets view
- Familiars view
- Plugins view
- Integrations view
- Persistent route handling inside the control center window
@ -323,7 +323,7 @@ The control-center Integrations page now also includes a curated **Curated MCP T
- Verbose dev logging toggle through environment
- IPC request logging
- Lease acquisition logging
- Pet show lifecycle logging
- Familiar show lifecycle logging
- Renderer console logging capture
## Packaging And Distribution
@ -332,15 +332,15 @@ The control-center Integrations page now also includes a curated **Curated MCP T
- 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 preload scripts for control center, familiar 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
- Prompt text is kept out of the familiar renderer
- Automatic agent speech is decorative and best effort
- OpenPets speech bubbles are not meant to carry code, logs, URLs, file paths, or secrets
- FamiliarOS 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
@ -348,6 +348,6 @@ The control-center Integrations page now also includes a curated **Curated MCP T
This fork now gives you three layers at once:
- a playful desktop pet
- a playful desktop familiar
- 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
- a curated MCP/workbench surface for building a serious developer stack around the familiar instead of only driving it from a terminal

View file

@ -1,10 +1,10 @@
# OpenPets — Our Changes (Isolated Feature List)
# FamiliarOS — 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.
- Double-click the familiar to open a frameless, always-on-top floating prompt window.
- Compact prompt-first UX at a minimal 96px height; expanded editor/history mode at 280px.
- Prompt bar sized for 23 lines of text.
- Resizable with a corner grip; size is remembered per mode (compact/expanded).
@ -14,11 +14,11 @@ This document lists only the features, UI surfaces, and capabilities added by ou
## 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.
- Per-entry role badges: You, Familiar, 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`).
- Persists to disk across app restarts (`familiaros-chat-history.json`).
## OpenAPI BYOK Chat
@ -29,15 +29,15 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- Built-in presets: OpenAI, OpenRouter, Azure template, LiteLLM, vLLM, Moonshot/Kimi, custom/local templates.
- Localhost-only allowance for plain `http`.
## Pet Character & Base Instructions
## Familiar Character & Base Instructions
- User-editable system-prompt style pet character field in General Settings.
- User-editable system-prompt style familiar 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.
- Base instructions toggle to include/exclude the default FamiliarOS behavior instructions in every chat.
## Pet Text-to-Speech (Phase 2)
## Familiar Text-to-Speech (Phase 2)
- Settings > **Text-to-Speech** panel for the pet and floating chat.
- Settings > **Text-to-Speech** panel for the familiar and floating chat.
- **System voice** via the OS speech engine with voice matching and 0.5×2.0× speed control.
- **Cloud providers**: OpenAI TTS, ElevenLabs, OpenAI-compatible (OpenRouter / LiteLLM / WaveSpeedAI / custom).
- **Local provider**: Piper for offline speech.
@ -50,7 +50,7 @@ This document lists only the features, UI surfaces, and capabilities added by ou
## Memory System
- Local-first durable OpenPets memory store.
- Local-first durable FamiliarOS 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.
@ -59,7 +59,7 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- 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`).
- Separate databases for explicit memories (`familiaros-memory.json`) and chat history (`familiaros-chat-history.json`).
## MCP Tool Servers (Built-in Chat)
@ -71,12 +71,12 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- 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.
## OpenPets MCP Server (External-Agent Bridge)
## FamiliarOS MCP Server (External-Agent Bridge)
- Central **OpenPets MCP Server** panel in Control Center → Integrations.
- Central **FamiliarOS MCP Server** panel in Control Center → Integrations.
- Command mode selector (published / bundled / local) in one place.
- Node path override in the central panel.
- Pet routing selector in the central panel.
- Familiar routing selector in the central panel.
- One-click **Test Server** health check.
- One-click **Copy MCP JSON** for external hosts.
@ -100,21 +100,21 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- Text extraction from UTF-8 text-like files; MIME inference from extension.
- Indexed metadata with file count cap (100) and per-file size cap (5 MB).
- Relevance search across file names and extracted text; results injected into OpenAPI chat instructions.
- Combined search across stored files and OpenPets memories.
- Combined search across stored files and FamiliarOS memories.
- Dedicated Settings tab for upload, search, list, and delete.
- Toolbar button in the floating prompt window stores attachments into the Knowledge Store.
- Electron-free core (`knowledge-store-core.ts`) + unit tests (`knowledge-store.test.ts`).
## Pet Scale & Sizing
## Familiar Scale & Sizing
- Settings slider for pet scale from 0.16x up to 10x, large enough to fill the screen.
- 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.
- Settings slider for familiar scale from 0.16x up to 10x, large enough to fill the screen.
- Familiar window dynamically resizes to fit larger scales without clipping.
- Speech bubbles grow with the familiar 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.
- Familiar positioning uses `screen.getDisplayNearestPoint()` instead of always clamping to the primary display.
- `clampToNearestWorkArea()` keeps the familiar within the work area of the display it is currently on.
## Multi-Conversation Chat
@ -124,22 +124,22 @@ This document lists only the features, UI surfaces, and capabilities added by ou
- 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`.
- Conversation metadata stored in `familiaros-chat-conversations.json`.
## Settings & Control Center
- Memory viewer section in Settings.
- MCP Tool Servers panel in Integrations for built-in chat tool activation.
- OpenPets MCP Server panel in Integrations for external-agent bridge configuration.
- FamiliarOS MCP Server panel in Integrations for external-agent bridge configuration.
- OpenAPI chat settings: endpoint, model, credential, theme, base instructions toggle.
- Moonshot/Kimi API endpoint preset.
## Virtual Pet Status HUD
## Virtual Familiar Status HUD
- Bundled `openpets.virtual-pet` plugin tracks hunger, energy, happiness, and affection.
- Status values are shown as read-only labels inside the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level), so they never block pet clicks or double-click chat.
- Single-click the pet to pet it (increases bond and happiness).
- Feed / play / pet / nap commands live in the pet context menu.
- Bundled `familiaros.virtual-familiar` plugin tracks hunger, energy, happiness, and affection.
- Status values are shown as read-only labels inside the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level), so they never block familiar clicks or double-click chat.
- Single-click the familiar to familiar it (increases bond and happiness).
- Feed / play / familiar / nap commands live in the familiar context menu.
- Low-stat nudges continue to work in the background.
## Build & Packaging

View file

@ -1,6 +1,6 @@
# OpenPets Feature Registry
# FamiliarOS Feature Registry
This is the canonical feature registry for the VectorShell working fork of OpenPets. It maps every user-facing capability to the files, branches/PRs, and commits that implement it. It is kept in addition to `FEATURES.md` and `FEATURES_OUR_CHANGES.md` so that feature ownership, status, and history can be traced quickly.
This is the canonical feature registry for the VectorShell working fork of FamiliarOS. It maps every user-facing capability to the files, branches/PRs, and commits that implement it. It is kept in addition to `FEATURES.md` and `FEATURES_OUR_CHANGES.md` so that feature ownership, status, and history can be traced quickly.
- **Current branch:** `v3.1.0-integrated`
- **Current HEAD:** `bb758e9`
@ -9,21 +9,21 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
---
## 1. Companion / Pet core
## 1. Companion / Familiar core
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Tray-first desktop companion | shipped | upstream | — | `apps/desktop/src/main.ts` | Baseline from upstream. |
| Default + installed pets | shipped | upstream | — | `apps/desktop/src/pet-installation.ts` | Baseline. |
| Always-on-top pet window | shipped | upstream | — | `apps/desktop/src/pet-window.ts` | Baseline. |
| Drag with mouse passthrough | shipped | `fix/pet-window-stability` | `11ed7b3` | `apps/desktop/src/pet-window.ts`, `apps/desktop/pet-preload.cjs` | Removes post-drag resize jump; anchors resize around pet foot/center. |
| Adaptive pet scale 0.16x10x | shipped | `feat/scale-preset-dropdown` | `53b8435` | `apps/desktop/src/app-state-core.ts`, `apps/desktop/src/renderer/src/main.tsx` | Continuous slider + abbreviated preset dropdown. |
| Default + installed familiars | shipped | upstream | — | `apps/desktop/src/familiar-installation.ts` | Baseline. |
| Always-on-top familiar window | shipped | upstream | — | `apps/desktop/src/familiar-window.ts` | Baseline. |
| Drag with mouse passthrough | shipped | `fix/familiar-window-stability` | `11ed7b3` | `apps/desktop/src/familiar-window.ts`, `apps/desktop/familiar-preload.cjs` | Removes post-drag resize jump; anchors resize around familiar foot/center. |
| Adaptive familiar scale 0.16x10x | shipped | `feat/scale-preset-dropdown` | `53b8435` | `apps/desktop/src/app-state-core.ts`, `apps/desktop/src/renderer/src/main.tsx` | Continuous slider + abbreviated preset dropdown. |
| Scale slider draggable thumb | shipped | `pr/scale-slider-draggable` | `2472950` | `apps/desktop/src/renderer/src/main.tsx` | CSS thumb styling so slider is grabbable. |
| Scale handle + keyboard nudges | **removed/replaced** | `fix/scale-handle-and-keyboard` | `be21907`, `26d446f` | `apps/desktop/src/pet-window.ts` | Handle added, then removed in favor of Settings `+`/`-` nudge buttons. |
| Multi-screen positioning | shipped | main PR | `08eff41` | `apps/desktop/src/pet-window.ts` | Uses `screen.getDisplayNearestPoint()` / `clampToNearestWorkArea()`. |
| Speech-bubble layout | shipped | main PR + OP2 | `08eff41`, `8648cad`, `f514960` | `apps/desktop/src/pet-window.ts` | Larger bubbles, top-of-screen flip, responsive max-width for large pets. |
| Single-click petting | shipped | `pr/single-click-petting` | `46028c7`, refined `be580ba` | `apps/desktop/pet-preload.cjs`, `plugins/official/openpets.virtual-pet/index.js` | Single click emits `pet:clicked`; double click opens chat. **Not a v3.1.0 follow-up fix; pre-existing feature refined by OP1.** |
| Virtual-pet context menu | shipped | `pr/virtual-pet-context-menu` + `refactor/virtual-pet-context-menu` | `98b7875`, `ab62d9c`, `35e4d74` | `plugins/official/openpets.virtual-pet/index.js`, `plugins/official/openpets.virtual-pet/locales/en.json` | HUD moved from triple-click overlay to right-click context menu; care actions reordered/renamed. |
| Scale handle + keyboard nudges | **removed/replaced** | `fix/scale-handle-and-keyboard` | `be21907`, `26d446f` | `apps/desktop/src/familiar-window.ts` | Handle added, then removed in favor of Settings `+`/`-` nudge buttons. |
| Multi-screen positioning | shipped | main PR | `08eff41` | `apps/desktop/src/familiar-window.ts` | Uses `screen.getDisplayNearestPoint()` / `clampToNearestWorkArea()`. |
| Speech-bubble layout | shipped | main PR + OP2 | `08eff41`, `8648cad`, `f514960` | `apps/desktop/src/familiar-window.ts` | Larger bubbles, top-of-screen flip, responsive max-width for large familiars. |
| Single-click petting | shipped | `pr/single-click-petting` | `46028c7`, refined `be580ba` | `apps/desktop/familiar-preload.cjs`, `plugins/official/familiaros.virtual-familiar/index.js` | Single click emits `familiar:clicked`; double click opens chat. **Not a v3.1.0 follow-up fix; pre-existing feature refined by OP1.** |
| Virtual-familiar context menu | shipped | `pr/virtual-familiar-context-menu` + `refactor/virtual-familiar-context-menu` | `98b7875`, `ab62d9c`, `35e4d74` | `plugins/official/familiaros.virtual-familiar/index.js`, `plugins/official/familiaros.virtual-familiar/locales/en.json` | HUD moved from triple-click overlay to right-click context menu; care actions reordered/renamed. |
## 2. Floating chat / prompt window
@ -45,20 +45,20 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| OpenAPI chat service | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts` | Main-process chat with responses + chat/completions. |
| Provider presets | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/renderer/src/main.tsx` | OpenAI, OpenRouter, Azure, LiteLLM, vLLM, Moonshot/Kimi, custom. |
| Secure credential storage | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts` | `safeStorage` with plain fallback. |
| Pet character / system prompt | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | User-editable character field. |
| Base-instructions toggle | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | Include/exclude default OpenPets behavior instructions. |
| Familiar character / system prompt | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | User-editable character field. |
| Base-instructions toggle | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | Include/exclude default FamiliarOS behavior instructions. |
| Knowledge context injection | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/knowledge-store.ts` | Relevant stored files appended to system instructions. |
## 4. Memory system
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Local memory engine | shipped | main PR | `08eff41` | `apps/desktop/src/openpets-memory.ts` | JSON store + markdown mirror. |
| Memory capture patterns | shipped | main PR | `08eff41` | `apps/desktop/src/openpets-memory.ts` | `remember`, `my name is`, `I prefer`, etc. |
| Memory kinds + importance + tags | shipped | main PR | `08eff41` | `apps/desktop/src/openpets-memory.ts` | preference, identity, fact, note. |
| Local memory engine | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | JSON store + markdown mirror. |
| Memory capture patterns | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | `remember`, `my name is`, `I prefer`, etc. |
| Memory kinds + importance + tags | shipped | main PR | `08eff41` | `apps/desktop/src/familiaros-memory.ts` | preference, identity, fact, note. |
| In-app memory viewer | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx` | Search, edit, delete. |
| Memory hardening | shipped | `pr/memory-hardening` | `2de484d` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/openpets-memory.ts` | Capture logged + fails safely; recall-intent fallback. |
| Chat-history memory | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/openpets-memory.ts` | Cross-conversation excerpts injected into prompt. |
| Memory hardening | shipped | `pr/memory-hardening` | `2de484d` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/familiaros-memory.ts` | Capture logged + fails safely; recall-intent fallback. |
| Chat-history memory | shipped | main PR | `08eff41` | `apps/desktop/src/openapi-chat.ts`, `apps/desktop/src/familiaros-memory.ts` | Cross-conversation excerpts injected into prompt. |
## 5. Knowledge Store
@ -74,14 +74,14 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
## 6. MCP edition
### 6a. OpenPets MCP server
### 6a. FamiliarOS MCP server
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| MCP server package | shipped | upstream / main PR | `3f8fa1a`+ | `packages/mcp/src/server.ts`, `packages/mcp/src/tools.ts` | `open-pets` MCP server. |
| MCP server package | shipped | upstream / main PR | `3f8fa1a`+ | `packages/mcp/src/server.ts`, `packages/mcp/src/tools.ts` | `familiaros` MCP server. |
| Status / react / say tools | shipped | upstream | — | `packages/mcp/src/tools.ts` | Baseline. |
| Memory tools (list/search/store/forget) | shipped | upstream | — | `packages/mcp/src/tools.ts` | Baseline. |
| Lease routing + default-pet fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
| Lease routing + default-familiar fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
### 6b. MCP Tool Servers (built-in chat)
@ -93,13 +93,13 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| Supported tool servers | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts` | filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite. |
| ReAct-style tool loop | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts`, `apps/desktop/src/openapi-chat.ts` | Max 5 iterations. |
### 6c. OpenPets MCP Server (external-agent bridge)
### 6c. FamiliarOS MCP Server (external-agent bridge)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Central "OpenPets MCP Server" panel | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/agent-setup.ts` | Configures command mode, node path, pet routing, test server, and MCP JSON for external hosts. |
| Central "FamiliarOS MCP Server" panel | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/agent-setup.ts` | Configures command mode, node path, familiar routing, test server, and MCP JSON for external hosts. |
| Server health test | shipped | main PR | current | `apps/desktop/src/agent-setup.ts`, `apps/desktop/src/windows.ts` | `--version` smoke test via IPC. |
| MCP JSON copy | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | One-click copy of the `mcpServers.openpets` entry. |
| MCP JSON copy | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | One-click copy of the `mcpServers.familiaros` entry. |
### 6d. Curated MCP Toolkit
@ -124,21 +124,21 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| Plugin catalog / install / enable | shipped | upstream | — | `apps/desktop/src/plugin-*.ts` | Baseline. |
| Official plugin suite | shipped | upstream | — | `plugins/official/` | Baseline: focus-buddy, reminders, launch-buddy, water-reminder, mood-check-in, day-routine, magic-8-ball, fortune-cookie, virtual-pet. |
| Virtual-pet plugin bundling | shipped | `pr/virtual-pet-context-menu` | `98b7875` | `plugins/official/openpets.virtual-pet/index.js` | Re-enabled after temporary disable. |
| Official plugin suite | shipped | upstream | — | `plugins/official/` | Baseline: focus-buddy, reminders, launch-buddy, water-reminder, mood-check-in, day-routine, magic-8-ball, fortune-cookie, virtual-familiar. |
| Virtual-familiar plugin bundling | shipped | `pr/virtual-familiar-context-menu` | `98b7875` | `plugins/official/familiaros.virtual-familiar/index.js` | Re-enabled after temporary disable. |
## 9. Text-to-Speech (Phase 2)
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|---------|--------|-------------|-------------|-----------|-------|
| TTS settings panel | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/renderer/src/main.tsx` | Provider, voice, speed, model, endpoint preset, credential, and test/stop UI. |
| System voice playback | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/pet-preload.cjs`, `apps/desktop/prompt-window-preload.cjs` | OS speech engine via Web Speech API; rate 0.5×2.0× and voice-name matching. |
| System voice playback | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/familiar-preload.cjs`, `apps/desktop/prompt-window-preload.cjs` | OS speech engine via Web Speech API; rate 0.5×2.0× and voice-name matching. |
| Cloud TTS providers | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-engine.ts` | OpenAI TTS, ElevenLabs, and OpenAI-compatible endpoints. |
| Piper local TTS | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-engine.ts` | Spawns local `piper` binary; WAV magic-byte validation. |
| Credential storage | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-credentials.ts` | Per-provider API key stored with `safeStorage`; plain fallback. |
| Endpoint presets | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-catalog.ts` | OpenRouter, LiteLLM, WaveSpeedAI, Custom. |
| TTS service & quiet hours | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/tts-service.ts` | Shared dispatcher; skips speech during quiet hours. |
| TTS IPC handlers | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/windows.ts` | `openpets:get-tts-settings`, `openpets:get-tts-voices`, `openpets:save-tts-credential`, `openpets:test-tts`, `openpets:tts-stop`. |
| TTS IPC handlers | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/windows.ts` | `familiaros:get-tts-settings`, `familiaros:get-tts-voices`, `familiaros:save-tts-credential`, `familiaros:test-tts`, `familiaros:tts-stop`. |
| TTS engine tests | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/tests/tts-engine.test.ts` | Provider catalog, defaults, and preset validation. |
| Plugin voice bridge | merged | TTS Phase 2 / `v3.1.0-integrated` | current | `apps/desktop/src/plugin-voice.ts` | `voice:speak` permission lets plugins speak through the same pipeline. |
@ -150,7 +150,7 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| asarUnpack for preloads | shipped | `pr/packaging-and-docs` | `743163d` | `apps/desktop/electron-builder.yml` | Preload scripts unpacked for sandboxed packaged builds. |
| Packaging contract | shipped | `pr/packaging-and-docs` + `pr/knowledge-store-hardening` | `743163d`, `f514960` | `apps/desktop/src/check-packaging-contract.ts` | Assertions for preload presence, bubble CSS, etc. |
| Windows code signing | shipped | TTS Phase 2 / `v3.1.0-integrated` | `bb758e9` build | `apps/desktop/dist-electron/` | Signed with host certificate via `signtool.exe`. |
| Signed installer delivery | shipped | TTS Phase 2 / `v3.1.0-integrated` | `bb758e9` | `C:\openpets-build\apps\desktop\dist-electron\OpenPets-3.1.0-win-x64-setup.exe` | SHA256: `79b0c01b08d6121f54675c176a5d7c22d1f8e3591b191d760aae6d6300d4b5bb`. |
| Signed installer delivery | shipped | TTS Phase 2 / `v3.1.0-integrated` | `bb758e9` | `C:\familiaros-build\apps\desktop\dist-electron\FamiliarOS-3.1.0-win-x64-setup.exe` | SHA256: `79b0c01b08d6121f54675c176a5d7c22d1f8e3591b191d760aae6d6300d4b5bb`. |
---
@ -158,9 +158,9 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| Capability | What happened | Replacement | Commits |
|------------|---------------|-------------|---------|
| On-pet drag-to-resize handle | Removed | Settings scale slider + `+`/`-` nudge buttons + keyboard arrows | `4aa60a3`, `be21907`, `26d446f` |
| Virtual-pet triple-click HUD overlay | Removed | Right-click context menu **Pet Status** | `4aa60a3`, `98b7875` |
| Triple-click pet action | Removed | Single-click petting + double-click chat | `46028c7` |
| On-familiar drag-to-resize handle | Removed | Settings scale slider + `+`/`-` nudge buttons + keyboard arrows | `4aa60a3`, `be21907`, `26d446f` |
| Virtual-familiar triple-click HUD overlay | Removed | Right-click context menu **Familiar Status** | `4aa60a3`, `98b7875` |
| Triple-click familiar action | Removed | Single-click petting + double-click chat | `46028c7` |
---
@ -173,11 +173,11 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
| `FEATURE_REGISTRY.md` | This file — feature-to-branch/commit/files mapping. |
| `PULL_REQUEST.md` | Main PR description for the big feature set. |
| `README_OUR_CHANGES.md` | Isolated user-facing README of additions. |
| `docs/pull-requests/OPENPETS_3_1_0_FIXES_PULL_REQUESTS.md` | Categorized fix PRs for v3.1.0. |
| `docs/pull-requests/OPENPETS_3_1_0_MOD_NICHE_PULL_REQUESTS.md` | Same fixes organized by mod niche. |
| `docs/pull-requests/FAMILIAROS_3_1_0_FIXES_PULL_REQUESTS.md` | Categorized fix PRs for v3.1.0. |
| `docs/pull-requests/FAMILIAROS_3_1_0_MOD_NICHE_PULL_REQUESTS.md` | Same fixes organized by mod niche. |
| `docs/pull-requests/OP_1_6_PULL_REQUESTS.md` | OP1OP6 user-reported tweak branches. |
| `docs/pull-requests/PR_SINGLE_CLICK_PETTING.md` | Single-click petting PR (pre-v3.1.0). |
| `docs/pull-requests/PR_VIRTUAL_PET_CONTEXT_MENU.md` | Virtual-pet context-menu PR (pre-v3.1.0). |
| `docs/pull-requests/PR_VIRTUAL_PET_CONTEXT_MENU.md` | Virtual-familiar context-menu PR (pre-v3.1.0). |
| `docs/pull-requests/PR_MEMORY_HARDENING.md` | Memory hardening PR. |
| `docs/pull-requests/PR_BUBBLE_CLIP_FIX.md` | Bubble clip fix PR. |
| `docs/pull-requests/PR_SCALE_SLIDER_DRAGGABLE.md` | Scale slider draggable thumb PR. |
@ -192,7 +192,7 @@ This is the canonical feature registry for the VectorShell working fork of OpenP
- `main` — upstream-aligned baseline.
- `v3.1.0-integrated` — integration branch with all v3.1.0 work plus TTS Phase 2 (HEAD `bb758e9`).
- `feat/scale-preset-dropdown`, `fix/pet-window-stability`, `fix/prompt-window-hardening`, `fix/prompt-window-signed-build`, `fix/scale-handle-and-keyboard`, `refactor/virtual-pet-context-menu` — topic branches from the six original PRs.
- `op1/pet-drag-threshold` … `op6/chat-file-attachments` — user-reported tweak branches.
- `pr/bubble-clip-fix`, `pr/memory-hardening`, `pr/packaging-and-docs`, `pr/scale-slider-draggable`, `pr/single-click-petting`, `pr/virtual-pet-context-menu` — PR branch pointers.
- `feat/scale-preset-dropdown`, `fix/familiar-window-stability`, `fix/prompt-window-hardening`, `fix/prompt-window-signed-build`, `fix/scale-handle-and-keyboard`, `refactor/virtual-familiar-context-menu` — topic branches from the six original PRs.
- `op1/familiar-drag-threshold` … `op6/chat-file-attachments` — user-reported tweak branches.
- `pr/bubble-clip-fix`, `pr/memory-hardening`, `pr/packaging-and-docs`, `pr/scale-slider-draggable`, `pr/single-click-petting`, `pr/virtual-familiar-context-menu` — PR branch pointers.
- `pr/knowledge-store-hardening` — Knowledge Store PR branch pointer (at `f514960`).

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 OpenPets
Copyright (c) 2026 FamiliarOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,6 +1,6 @@
# Pull Request: Floating Chat, Memory, Knowledge Store, MCP Servers, Curated MCP Toolkit, TTS Phase 2 & Pet Sizing
# Pull Request: Floating Chat, Memory, Knowledge Store, MCP Servers, Curated MCP Toolkit, TTS Phase 2 & Familiar Sizing
**Title:** feat(desktop): add floating chat, local memory, Knowledge Store, MCP Tool Servers, OpenPets MCP Server panel, Curated MCP Toolkit, Text-to-Speech Phase 2, expanded pet sizing, and multi-screen support
**Title:** feat(desktop): add floating chat, local memory, Knowledge Store, MCP Tool Servers, FamiliarOS MCP Server panel, Curated MCP Toolkit, Text-to-Speech Phase 2, expanded familiar sizing, and multi-screen support
**Author:** VectorShell working fork
**Base:** upstream `main` at `cba81b7`
@ -10,47 +10,47 @@
## Summary
This PR adds a complete in-app chat layer, a durable local memory system, interactive MCP tool activation, and several companion-quality improvements to the desktop pet. Every change is additive and preserves existing MCP/agent integration behavior.
This PR adds a complete in-app chat layer, a durable local memory system, interactive MCP tool activation, and several companion-quality improvements to the desktop familiar. Every change is additive and preserves existing MCP/agent integration behavior.
## Motivation
- Users wanted to chat with the pet without a terminal or external agent.
- Users wanted to chat with the familiar without a terminal or external agent.
- Long-running companions benefit from remembering facts, preferences, and identity.
- A curated but practical MCP surface helps users adopt tools without unsafe one-click installs.
- Pet sizing was limited to three presets with no continuous or interactive adjustment.
- Familiar sizing was limited to three presets with no continuous or interactive adjustment.
## What changed
### New surfaces
- **Floating Prompt Window** (`apps/desktop/src/prompt-window.ts`)
- Frameless, always-on-top chat UI opened by double-clicking the pet.
- Frameless, always-on-top chat UI opened by double-clicking the familiar.
- Compact mode at 96px height; expanded editor/history mode at 280px.
- Built as inline HTML/CSS/JS with a dedicated preload bridge.
- **Chat History List UI**
- Replaced plain-text history textarea with styled per-entry cards.
- Role badges (You / Pet / System / Error), color coding, timestamps.
- Persists to disk (`openpets-chat-history.json`).
- Role badges (You / Familiar / System / Error), color coding, timestamps.
- Persists to disk (`familiaros-chat-history.json`).
- **Memory Viewer**
- New Settings section to search, edit, and delete OpenPets memories.
- New Settings section to search, edit, and delete FamiliarOS memories.
- **MCP Tool Servers Panel**
- New top-level Integrations card to activate MCP tools for the built-in floating chat.
- Works without Claude, Cursor, OpenCode, or Pi installed.
- Tiered chip selector (Starter / Terminal & Systems / Advanced).
- **OpenPets MCP Server Panel**
- **FamiliarOS MCP Server Panel**
- New central Integrations card for the external-agent bridge.
- Command mode, node path, and pet routing in one place.
- Command mode, node path, and familiar routing in one place.
- One-click server health test and MCP JSON copy.
- **Text-to-Speech (Phase 2)**
- Settings > **Text-to-Speech** panel with provider, voice, model, speed, endpoint, and credential controls.
- Providers: system OS voice, OpenAI TTS, ElevenLabs, Piper (local), and OpenAI-compatible (OpenRouter / LiteLLM / WaveSpeedAI / custom).
- Credentials stored with `safeStorage` (plain fallback) and tied to the selected provider.
- Shared TTS service (`tts-service.ts`) routes speech to the pet / floating-chat renderer via `openpets:tts-speak`, `openpets:tts-audio`, and `openpets:tts-stop` IPC.
- Shared TTS service (`tts-service.ts`) routes speech to the familiar / floating-chat renderer via `familiaros:tts-speak`, `familiaros:tts-audio`, and `familiaros:tts-stop` IPC.
- Cloud audio validated for MP3/WAV; endpoints validated for HTTPS / localhost rules.
- Skips speech during quiet hours.
@ -58,10 +58,10 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
- `apps/desktop/src/openapi-chat.ts` — OpenAPI BYOK chat with responses + chat/completions support.
- `apps/desktop/src/mcp-chat-client.ts` — Internal stdio MCP client for floating chat tools.
- `apps/desktop/src/openpets-memory.ts` — Local memory engine with JSON store and markdown mirror.
- `apps/desktop/src/familiaros-memory.ts` — Local memory engine with JSON store and markdown mirror.
- `apps/desktop/src/knowledge-store-core.ts` — Electron-free file + memory knowledge repository with search and context building.
- `apps/desktop/src/knowledge-store.ts` — Electron wrapper that wires the core to `app.getPath('userData')`.
- `apps/desktop/src/tts-service.ts` — Shared TTS dispatcher for pet / floating chat with quiet-hours check.
- `apps/desktop/src/tts-service.ts` — Shared TTS dispatcher for familiar / floating chat with quiet-hours check.
- `apps/desktop/src/tts-engine.ts` — Provider-specific synthesis and voice fetching (OpenAI, ElevenLabs, Piper, OpenAI-compatible).
- `apps/desktop/src/tts-catalog.ts` — Provider catalog, defaults, and endpoint presets.
- `apps/desktop/src/tts-credentials.ts` — Secure credential storage for TTS providers.
@ -69,19 +69,19 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
- `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` — Catalog data for the toolkit UI.
- `apps/desktop/prompt-window-preload.cjs` — Preload for the floating chat renderer.
### Pet improvements
### Familiar improvements
- Continuous pet scale slider from 0.16x to 10x in Settings.
- Continuous familiar scale slider from 0.16x to 10x in Settings.
- Dynamic window sizing so larger scales are never clipped.
- Multi-screen positioning (`clampToNearestWorkArea`).
- Removed the on-pet drag-to-resize handle in favor of the Settings slider.
- Virtual-pet status (`openpets.virtual-pet`) moved from an on-pet triple-click HUD overlay to the right-click pet context menu, so it never blocks chat clicks. Single-clicking the pet triggers the `pet()` care action.
- Removed the on-familiar drag-to-resize handle in favor of the Settings slider.
- Virtual-familiar status (`familiaros.virtual-familiar`) moved from an on-familiar triple-click HUD overlay to the right-click familiar context menu, so it never blocks chat clicks. Single-clicking the familiar triggers the `familiar()` care action.
### Settings additions
- OpenAPI chat: endpoint preset, model, credential, theme, base-instructions toggle.
- Moonshot/Kimi endpoint preset.
- Pet character / system prompt field.
- Familiar character / system prompt field.
- Memory viewer with search/edit/delete.
- **Knowledge Store** tab: upload files, search stored knowledge, delete files, and add manual memory entries.
@ -100,12 +100,12 @@ This PR adds a complete in-app chat layer, a durable local memory system, intera
### Updated files
- `README.md` — added floating chat, memory, MCP Tool Servers, OpenPets MCP Server, and Curated MCP Toolkit bullets.
- `README.md` — added floating chat, memory, MCP Tool Servers, FamiliarOS MCP Server, and Curated MCP Toolkit bullets.
- `FEATURES.md` — comprehensive inventory of new capabilities.
- `docs/pr-chat-memory-mcp-toolkit.md` — implementation explainer.
- `docs/phases/phase-14-pet-scale-setting.md` — expanded with new sizes and drag resize.
- `apps/desktop/src/pet-window.ts` — removed scale handle; IPC handlers; dynamic window sizing.
- `apps/desktop/pet-preload.cjs` — single/double-click detection; single-click emits `pet:clicked` for virtual-pet petting, double-click opens floating chat.
- `docs/phases/phase-14-familiar-scale-setting.md` — expanded with new sizes and drag resize.
- `apps/desktop/src/familiar-window.ts` — removed scale handle; IPC handlers; dynamic window sizing.
- `apps/desktop/familiar-preload.cjs` — single/double-click detection; single-click emits `familiar:clicked` for virtual-familiar petting, double-click opens floating chat.
- `apps/desktop/src/app-state-core.ts` — expanded scale range.
- `apps/desktop/src/renderer/src/main.tsx` — Settings/Integrations UI additions.
- `apps/desktop/src/windows.ts` — memory IPC, toolkit IPC, preference handling.
@ -122,7 +122,7 @@ apps/desktop/prompt-window-preload.cjs
apps/desktop/src/mcp-chat-client.ts
apps/desktop/src/mcp-toolkit-installer.ts
apps/desktop/src/openapi-chat.ts
apps/desktop/src/openpets-memory.ts
apps/desktop/src/familiaros-memory.ts
apps/desktop/src/prompt-window.ts
apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts
apps/desktop/src/knowledge-store-core.ts
@ -139,25 +139,25 @@ apps/desktop/tests/tts-engine.test.ts
```bash
pnpm install
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
pnpm --filter @familiaros/desktop build
pnpm --filter @familiaros/desktop test
```
All existing tests pass. Additional manual verification:
1. Launch the desktop app and double-click the pet — floating chat should open.
1. Launch the desktop app and double-click the familiar — floating chat should open.
2. Add an OpenAI-compatible API key in Settings > OpenAPI Chat.
3. Send a message; assistant reply should appear in the pet bubble and in the history list.
3. Send a message; assistant reply should appear in the familiar bubble and in the history list.
4. Say "remember that my favorite color is blue" — memory should be captured.
5. Open Settings > Memory — the memory should be searchable, editable, and deletable.
6. Open Integrations > MCP Tool Servers — activate Filesystem + Fetch/Web, then chat "list my home directory files" — tools should run.
7. Open Integrations > OpenPets MCP Server — choose a command mode, click Test Server, and verify the server responds.
7. Open Integrations > FamiliarOS MCP Server — choose a command mode, click Test Server, and verify the server responds.
8. Open Integrations > Curated MCP Toolkit to browse copy-paste snippets and optional persistent bundles.
9. Open Settings > Text-to-Speech, choose System voice, click **Speak** — the pet should speak the test phrase.
10. Add an OpenAI TTS key, select a voice, click **Speak** — audio should play through the pet window.
9. Open Settings > Text-to-Speech, choose System voice, click **Speak** — the familiar should speak the test phrase.
10. Add an OpenAI TTS key, select a voice, click **Speak** — audio should play through the familiar window.
11. Store a `.txt` file in Settings > Knowledge Store and ask the assistant about its content — the reply should reference the stored file.
12. Right-click the pet → choose Virtual Pet ▸ Feed/Play/Pet/Nap; stats update and the context menu reflects the new values.
13. Change display — pet should stay on the display it is currently near.
12. Right-click the familiar → choose Virtual Familiar ▸ Feed/Play/Familiar/Nap; stats update and the context menu reflects the new values.
13. Change display — familiar should stay on the display it is currently near.
## Backwards compatibility
@ -176,5 +176,5 @@ All existing tests pass. Additional manual verification:
1. Floating chat + OpenAPI BYOK
2. Memory system + memory viewer
3. Vanilla chat MCP tools
4. Pet sizing expansion + drag handle
4. Familiar sizing expansion + drag handle
5. Multi-screen positioning

194
README.md
View file

@ -1,5 +1,5 @@
<p align="center">
<img src="assets/openpets.png" alt="OpenPets - pixel art desktop companion" width="100%" />
<img src="assets/familiaros.png" alt="FamiliarOS - pixel art desktop companion" width="100%" />
</p>
<p align="center">
@ -7,15 +7,15 @@
</p>
<p align="center">
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
A playful familiar 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>Knowledge Store</strong>, <strong>MCP Tool Servers</strong>, an <strong>OpenPets MCP Server</strong> panel, a <strong>Curated MCP Toolkit</strong>, <strong>Text-to-Speech (Phase 2)</strong>, and <strong>expanded pet sizing</strong>. See <code>FEATURES.md</code>, <code>FEATURE_REGISTRY.md</code>, and <code>PULL_REQUEST.md</code> for details.</sub>
<sub>This branch also includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>Knowledge Store</strong>, <strong>MCP Tool Servers</strong>, an <strong>FamiliarOS MCP Server</strong> panel, a <strong>Curated MCP Toolkit</strong>, <strong>Text-to-Speech (Phase 2)</strong>, and <strong>expanded familiar sizing</strong>. See <code>FEATURES.md</code>, <code>FEATURE_REGISTRY.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%" />
<img src="assets/intro.png" alt="FamiliarOS reacting across multiple coding agent sessions" width="100%" />
</p>
<div align="center">
@ -33,116 +33,116 @@
## 2-minute Quick Start
Download and launch the desktop app from [OpenPets Releases](https://github.com/alvinunreal/openpets/releases/latest). A companion pet appears immediately; bundled abilities make it feel alive without requiring an agent setup.
Download and launch the desktop app from [FamiliarOS Releases](https://github.com/alvinunreal/familiaros/releases/latest). A companion familiar appears immediately; bundled abilities make it feel alive without requiring an agent setup.
If you also want coding-agent integration, install the OpenPets Agent Skill with [skills.sh](https://skills.sh/):
If you also want coding-agent integration, install the FamiliarOS Agent Skill with [skills.sh](https://skills.sh/):
```bash
npx skills add alvinunreal/openpets --skill openpets
npx skills add alvinunreal/familiaros --skill familiaros
```
Then open Claude Code, OpenCode, Codex, or another skill-aware agent and say:
```text
Use the OpenPets skill. Install OpenPets for me, connect this agent, and verify the integration works.
Use the FamiliarOS skill. Install FamiliarOS for me, connect this agent, and verify the integration works.
```
For project setup, open your agent inside the repo and say:
```text
Use the OpenPets skill. Help me choose or install a pet, configure it for this project, and verify the project integration.
Use the FamiliarOS skill. Help me choose or install a familiar, configure it for this project, and verify the project integration.
```
Useful prompts:
```text
Use the OpenPets skill. Configure this project for Claude Code with a pet.
Use the OpenPets skill. Configure this project for OpenCode with a pet.
Use the OpenPets skill. Debug why openpets_status is unavailable.
Use the FamiliarOS skill. Configure this project for Claude Code with a familiar.
Use the FamiliarOS skill. Configure this project for OpenCode with a familiar.
Use the FamiliarOS skill. Debug why familiaros_status is unavailable.
```
## Star OpenPets
## Star FamiliarOS
Here is an extra GIF of me starring my own repo to encourage you to do the same. If OpenPets makes your coding setup a little more fun, please give the repo a star.
Here is an extra GIF of me starring my own repo to encourage you to do the same. If FamiliarOS makes your coding setup a little more fun, please give the repo a star.
<p align="center">
<img src="assets/star-repo.gif" alt="Starring the OpenPets repository" width="100%" />
<img src="assets/star-repo.gif" alt="Starring the FamiliarOS repository" width="100%" />
</p>
## What is OpenPets?
## What is FamiliarOS?
OpenPets is a tray-first desktop companion app. A pet appears on your desktop, keeps you company, and can use bundled abilities for ambient presence, breaks, playful actions, and focus sessions. Coding-agent integrations are still supported as an advanced developer layer.
FamiliarOS is a tray-first desktop companion app. A familiar appears on your desktop, keeps you company, and can use bundled abilities for ambient presence, breaks, playful actions, and focus sessions. Coding-agent integrations are still supported as an advanced developer layer.
- **Desktop companion** - a small pet that idles, reacts, and gives OpenPets a friendly presence even before developer tools are configured.
- **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 Server activation.
- **Virtual-pet status** - right-click the pet and open the Virtual Pet submenu to see hunger, energy, happiness, bond, mood, and level. No on-pet HUD overlay, so chat clicks stay unobstructed.
- **Single-click petting** - click the pet once to pet it; the virtual-pet bond and happiness increase.
- **Local memory** - the pet remembers facts, preferences, and notes across sessions.
- **Text-to-Speech (Phase 2)** - configurable system, OpenAI, ElevenLabs, Piper, or OpenAI-compatible TTS for the pet and floating chat, with voice/model/speed controls and a test preview.
- **Pet-pack friendly** - loads installed animated pet packs and can route a selected agent/project to its own pet window.
- **Desktop companion** - a small familiar that idles, reacts, and gives FamiliarOS a friendly presence even before developer tools are configured.
- **Bundled abilities** - first-party plugins can add ambient check-ins, break nudges, playful familiar 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 familiar.
- **MCP ready** - any MCP-capable agent can send short safe speech bubbles and reactions through the FamiliarOS MCP server.
- **Floating chat** - double-click the familiar to open an always-on-top chat window with BYOK OpenAPI-compatible providers, conversation history, and optional MCP Tool Server activation.
- **Virtual-familiar status** - right-click the familiar and open the Virtual Familiar submenu to see hunger, energy, happiness, bond, mood, and level. No on-familiar HUD overlay, so chat clicks stay unobstructed.
- **Single-click petting** - click the familiar once to familiar it; the virtual-familiar bond and happiness increase.
- **Local memory** - the familiar remembers facts, preferences, and notes across sessions.
- **Text-to-Speech (Phase 2)** - configurable system, OpenAI, ElevenLabs, Piper, or OpenAI-compatible TTS for the familiar and floating chat, with voice/model/speed controls and a test preview.
- **Familiar-pack friendly** - loads installed animated familiar packs and can route a selected agent/project to its own familiar 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.
## Manage your pets
## Manage your familiars
Browse installed pets, preview their animations, and choose which companion should follow each coding agent from the OpenPets desktop app.
Browse installed familiars, preview their animations, and choose which companion should follow each coding agent from the FamiliarOS desktop app.
<p align="center">
<img src="assets/manage-pets.png" alt="Managing pets in the OpenPets desktop app" width="100%" />
<img src="assets/manage-familiars.png" alt="Managing familiars in the FamiliarOS desktop app" width="100%" />
</p>
## Quick start
Install the desktop app, then optionally connect your coding agent.
### 1. Install OpenPets Desktop
### 1. Install FamiliarOS Desktop
Download the latest app from [OpenPets Releases](https://github.com/alvinunreal/openpets/releases/latest):
Download the latest app from [FamiliarOS Releases](https://github.com/alvinunreal/familiaros/releases/latest):
- **macOS Apple Silicon**: `OpenPets-*-mac-arm64.dmg`
- **macOS Intel**: `OpenPets-*-mac-x64.dmg`
- **Windows**: `OpenPets-*-win-x64-setup.exe`
- **Linux**: `OpenPets-*-linux-x86_64.AppImage`
- **macOS Apple Silicon**: `FamiliarOS-*-mac-arm64.dmg`
- **macOS Intel**: `FamiliarOS-*-mac-x64.dmg`
- **Windows**: `FamiliarOS-*-win-x64-setup.exe`
- **Linux**: `FamiliarOS-*-linux-x86_64.AppImage`
Launch OpenPets. You should see the desktop pet and the OpenPets tray/menu-bar icon.
Launch FamiliarOS. You should see the desktop familiar and the FamiliarOS tray/menu-bar icon.
> Current builds may be unsigned. macOS or Windows may show a security warning the first time you open the app.
If macOS says the app is damaged or should be moved to Trash, remove the quarantine flag and open it again:
```bash
xattr -dr com.apple.quarantine /Applications/OpenPets.app
open /Applications/OpenPets.app
xattr -dr com.apple.quarantine /Applications/FamiliarOS.app
open /Applications/FamiliarOS.app
```
### 2. Optional: connect your agent
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.
- **Claude Code** - installs FamiliarOS MCP, Claude memory instructions, and optional Claude hooks.
- **OpenCode** - installs FamiliarOS MCP, an OpenCode instruction file, and the `@familiaros/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%" />
<img src="assets/integrations.png" alt="FamiliarOS desktop integrations screen" width="100%" />
</p>
For project-local setup, run the CLI from the project you want to configure:
```bash
npx -y @open-pets/cli@latest configure --agent claude --pet <petId>
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
npx -y @familiaros/cli@latest configure --agent claude --familiar <petId>
npx -y @familiaros/cli@latest configure --agent opencode --familiar <petId>
```
If you prefer a permanent `openpets` shell command, install the CLI once with `npm install -g @open-pets/cli` and replace `npx -y @open-pets/cli@latest` with `openpets`.
If you prefer a permanent `familiaros` shell command, install the CLI once with `npm install -g @familiaros/cli` and replace `npx -y @familiaros/cli@latest` with `familiaros`.
Project-local setup can create project files such as `.claude/settings.local.json` or `.opencode/opencode.jsonc`. Review them before committing because they may include the selected pet id.
Project-local setup can create project files such as `.claude/settings.local.json` or `.opencode/opencode.jsonc`. Review them before committing because they may include the selected familiar id.
## Advanced: agent integrations
OpenPets integrations have three layers:
FamiliarOS integrations have three layers:
1. **MCP tools** for explicit agent actions.
2. **Agent instructions** so agents know when to use those tools.
@ -152,21 +152,21 @@ OpenPets integrations have three layers:
Claude Code integration supports:
- `openpets` MCP setup via Claude Code.
- Managed Claude memory instructions in `~/.claude/CLAUDE.md` and `~/.claude/openpets.md`.
- `familiaros` MCP setup via Claude Code.
- Managed Claude memory instructions in `~/.claude/CLAUDE.md` and `~/.claude/familiaros.md`.
- Managed Claude hooks in `~/.claude/settings.json`.
- Project-local setup through `npx -y @open-pets/cli@latest configure --agent claude --pet <petId>` or the optional global `openpets` CLI.
- Project-local setup through `npx -y @familiaros/cli@latest configure --agent claude --familiar <petId>` or the optional global `familiaros` CLI.
Typical global MCP command shape:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest
claude mcp add --scope user familiaros -- npx -y @familiaros/mcp@latest
```
With a selected pet:
With a selected familiar:
```bash
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest --pet <petId>
claude mcp add --scope user familiaros -- npx -y @familiaros/mcp@latest --familiar <petId>
```
See [`docs/claude-integration.md`](docs/claude-integration.md) for the full file layout, hook mapping, project-local behavior, and safety rules.
@ -175,44 +175,44 @@ See [`docs/claude-integration.md`](docs/claude-integration.md) for the full file
OpenCode integration supports:
- An MCP entry using `@open-pets/cli mcp`.
- A managed `openpets.md` instruction file.
- The `@open-pets/opencode` plugin for automatic reactions.
- An MCP entry using `@familiaros/cli mcp`.
- A managed `familiaros.md` instruction file.
- The `@familiaros/opencode` plugin for automatic reactions.
- Global desktop setup and project-local `.opencode` setup.
Project-local setup:
```bash
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
npx -y @familiaros/cli@latest configure --agent opencode --familiar <petId>
```
See [`docs/opencode.md`](docs/opencode.md) for global config selection, plugin behavior, project-local setup, and safety rules.
### Generic MCP clients
Any MCP-capable editor or coding agent can talk to OpenPets through the MCP server while the desktop app is running.
Any MCP-capable editor or coding agent can talk to FamiliarOS through the MCP server while the desktop app is running.
```json
{
"mcpServers": {
"openpets": {
"familiaros": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/mcp@latest"]
"args": ["-y", "@familiaros/mcp@latest"]
}
}
}
```
To target a specific installed non-default pet:
To target a specific installed non-default familiar:
```json
{
"mcpServers": {
"openpets": {
"familiaros": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@open-pets/mcp@latest", "--pet", "<petId>"]
"args": ["-y", "@familiaros/mcp@latest", "--familiar", "<petId>"]
}
}
}
@ -220,27 +220,27 @@ To target a specific installed non-default pet:
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.
- `familiaros_status` - check whether FamiliarOS is reachable and which familiar is targeted.
- `familiaros_react` - set a short reaction on the target familiar.
- `familiaros_say` - show a short safe speech bubble, optionally with a reaction.
- `familiaros_memory_list` - list recent long-term memories stored by FamiliarOS.
- `familiaros_memory_search` - search familiar memory for relevant facts, preferences, or notes.
- `familiaros_memory_store` - store a durable memory for the familiar.
- `familiaros_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.
`familiaros_say` messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths.
### MCP Tool Servers
The desktop app includes a dedicated **MCP Tool Servers** panel under **Integrations** for the built-in floating chat. Activate tool servers such as filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, and sqlite so the pet can use them directly. This works independently of whether Claude, Cursor, OpenCode, or Pi are installed.
The desktop app includes a dedicated **MCP Tool Servers** panel under **Integrations** for the built-in floating chat. Activate tool servers such as filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, and sqlite so the familiar can use them directly. This works independently of whether Claude, Cursor, OpenCode, or Pi are installed.
### OpenPets MCP Server
### FamiliarOS MCP Server
A central **OpenPets MCP Server** panel under **Integrations** configures the external-agent bridge. It collects command mode (published / bundled / local), node path, and pet routing in one place, and provides a **Test Server** check and a **Copy MCP JSON** button for pasting into Claude Code, Cursor, Codex CLI, or any other MCP host.
A central **FamiliarOS MCP Server** panel under **Integrations** configures the external-agent bridge. It collects command mode (published / bundled / local), node path, and familiar routing in one place, and provides a **Test Server** check and a **Copy MCP JSON** button for pasting into Claude Code, Cursor, Codex CLI, or any other MCP host.
### Curated MCP toolkit
The desktop app also includes a manual-but-curated **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 desktop app also includes a manual-but-curated **Curated MCP Toolkit** panel under **Integrations**. It is there to help you build a practical broader stack without stuffing FamiliarOS full of unsafe one-click third-party installs.
The toolkit offers two paths:
@ -288,18 +288,18 @@ It also maps the next terminal-oriented layers that usually matter once your wor
```text
Claude Code / OpenCode / Pi / MCP client
-> @open-pets/mcp, @open-pets/cli mcp, @open-pets/claude hook, @open-pets/opencode plugin, or @open-pets/pi extension
-> @open-pets/client
-> OpenPets desktop local IPC discovery file
-> OpenPets desktop IPC socket/pipe
-> default pet or selected agent pet window
-> @familiaros/mcp, @familiaros/cli mcp, @familiaros/claude hook, @familiaros/opencode plugin, or @familiaros/pi extension
-> @familiaros/client
-> FamiliarOS desktop local IPC discovery file
-> FamiliarOS desktop IPC socket/pipe
-> default familiar or selected agent familiar window
```
The desktop app writes a local discovery file containing an IPC endpoint and a per-run token. Clients must send that token with every request.
For Windows desktop + WSL agent setups, see [`docs/wsl-ipc.md`](docs/wsl-ipc.md) for the opt-in TCP transport.
When an integration is configured with `--pet <petId>`, OpenPets asks the desktop app for a short-lived lease. Valid installed non-default pets open as explicit agent pet windows. Missing, invalid, broken, built-in, or default pet requests fall back to the desktop default pet.
When an integration is configured with `--familiar <petId>`, FamiliarOS asks the desktop app for a short-lived lease. Valid installed non-default familiars open as explicit agent familiar windows. Missing, invalid, broken, built-in, or default familiar requests fall back to the desktop default familiar.
## Reactions and speech
@ -320,14 +320,14 @@ Generic shell activity is intentionally quiet by default. Hook/plugin speech is
### Pi extension package
OpenPets includes an experimental Pi extension package at `@open-pets/pi`. Pi support is extension-first rather than MCP-first: the extension listens to Pi lifecycle/tool events and sends local best-effort reactions through `@open-pets/client`.
FamiliarOS includes an experimental Pi extension package at `@familiaros/pi`. Pi support is extension-first rather than MCP-first: the extension listens to Pi lifecycle/tool events and sends local best-effort reactions through `@familiaros/client`.
```bash
pi install npm:@open-pets/pi
pi install -l npm:@open-pets/pi
pi install npm:@familiaros/pi
pi install -l npm:@familiaros/pi
```
Inside Pi, the extension registers `/openpets status`, `/openpets test`, `/openpets react <reaction>`, and `/openpets say <message>`. Automatic events do not forward prompts, assistant text, tool output, file contents, paths, URLs, or secrets. Real Pi CLI install validation is still required before marking the integration fully supported.
Inside Pi, the extension registers `/familiaros status`, `/familiaros test`, `/familiaros react <reaction>`, and `/familiaros say <message>`. Automatic events do not forward prompts, assistant text, tool output, file contents, paths, URLs, or secrets. Real Pi CLI install validation is still required before marking the integration fully supported.
## Development
@ -354,7 +354,7 @@ pnpm dev:desktop
Equivalent package command:
```bash
pnpm --filter @open-pets/desktop dev
pnpm --filter @familiaros/desktop dev
```
### Checks
@ -366,7 +366,7 @@ pnpm build
pnpm test
```
OpenPets currently uses lightweight Node contract checks instead of a full test framework. See [`docs/testing.md`](docs/testing.md).
FamiliarOS currently uses lightweight Node contract checks instead of a full test framework. See [`docs/testing.md`](docs/testing.md).
### Package desktop builds
@ -381,15 +381,15 @@ Release process details live in [`docs/release.md`](docs/release.md).
```text
apps/desktop Electron desktop app
packages/client @open-pets/client, local IPC client
packages/mcp @open-pets/mcp, MCP stdio server
packages/claude @open-pets/claude, Claude command and hook helpers
packages/opencode @open-pets/opencode, OpenCode config and plugin integration
packages/pi @open-pets/pi, Pi extension package
packages/client @familiaros/client, local IPC client
packages/mcp @familiaros/mcp, MCP stdio server
packages/claude @familiaros/claude, Claude command and hook helpers
packages/opencode @familiaros/opencode, OpenCode config and plugin integration
packages/pi @familiaros/pi, Pi extension package
packages/agent-events Shared safe agent event speech helpers
packages/cli @open-pets/cli, user-run CLI, plugin scaffolder/validator, MCP/hook entrypoints
packages/sdk @open-pets/plugin-sdk, plugin SDK v3 types + testing harness
packages/pet-format @open-pets/pet-format, pet/catalog format types
packages/cli @familiaros/cli, user-run CLI, plugin scaffolder/validator, MCP/hook entrypoints
packages/sdk @familiaros/plugin-sdk, plugin SDK v3 types + testing harness
packages/familiar-format @familiaros/familiar-format, familiar/catalog format types
plugins/official First-party plugin source (bundled + catalog)
docs/ Documentation
```
@ -407,9 +407,9 @@ docs/ Documentation
## Safety and privacy notes
- OpenPets local IPC is local-only and protected by a per-run token.
- FamiliarOS local IPC is local-only and protected by a per-run token.
- Hook/plugin errors are swallowed unless debug logging is enabled.
- Automatic speech is static and local; it does not include model-generated prompt text.
- Tool inputs and command text are used only for coarse reaction classification.
- Managed setup preserves unrelated user config and removes only OpenPets-managed entries.
- Managed setup preserves unrelated user config and removes only FamiliarOS-managed entries.
- Speech validation rejects code-like, secret-like, URL-like, path-like, or multiline messages.

View file

@ -1,10 +1,10 @@
# OpenPets — Our Additions (Isolated README)
# FamiliarOS — Our Additions (Isolated README)
This README describes only the capabilities added by our fork. It is kept separate from the upstream `README.md` so the original maintainer can review and adopt changes independently.
## New: Floating Chat Window
Double-click the desktop pet to open an always-on-top floating chat window.
Double-click the desktop familiar to open an always-on-top floating chat window.
- BYOK OpenAPI-compatible chat (OpenAI, OpenRouter, Moonshot/Kimi, LiteLLM, vLLM, Azure, custom).
- Conversation history rendered as a styled list with role badges and timestamps.
@ -16,13 +16,13 @@ Double-click the desktop pet to open an always-on-top floating chat window.
## New: Local Memory
OpenPets now remembers things for you.
FamiliarOS now remembers things for you.
- Durable local memory store with automatic capture from chat prompts.
- Relevance-based retrieval into future system prompts.
- Memory kinds: preference, identity, fact, note.
- In-app memory viewer in Settings for search, edit, and delete.
- Also exposed through existing OpenPets MCP tools (`openpets_memory_*`).
- Also exposed through existing FamiliarOS MCP tools (`familiaros_memory_*`).
## New: Knowledge Store
@ -31,7 +31,7 @@ Store files and add manual knowledge entries that the assistant can reference du
- Upload files from Settings > Knowledge Store or store attachments directly from the floating chat window.
- Text-like files are indexed and searched by file name and extracted content.
- Relevant files are injected into the assistant's system instructions automatically.
- Combined search across stored files and OpenPets memories.
- Combined search across stored files and FamiliarOS memories.
## New: MCP Tool Servers (Built-in Chat)
@ -42,56 +42,56 @@ Activate MCP tool servers for the built-in floating chat, independently of exter
- Tiered browser: Starter, Terminal & Systems, Advanced.
- Tools run through an internal stdio MCP client in the main process.
## New: OpenPets MCP Server Panel
## New: FamiliarOS MCP Server Panel
A central panel in Control Center → Integrations configures the external-agent bridge.
- Command mode selector (published / bundled / local) in one place.
- Node path override and pet routing in one place.
- Node path override and familiar routing in one place.
- One-click **Test Server** health check.
- One-click **Copy MCP JSON** for Claude Code, Cursor, Codex CLI, or generic MCP hosts.
## New: Pet Scale
## New: Familiar Scale
- Continuous scale slider from 0.16x to 10x in Settings.
- Window dynamically resizes so the pet is never clipped.
- Window dynamically resizes so the familiar is never clipped.
## New: Virtual Pet Status
## New: Virtual Familiar Status
- Bundled virtual-pet plugin tracks hunger, energy, happiness, and affection.
- Status values live in the right-click pet context menu (Virtual Pet ▸ Mood, Food, Energy, Play, Bond, Level) instead of an on-pet HUD overlay, so double-click chat stays unobstructed.
- Feed, play, pet, and nap commands are available from the pet context menu.
- Bundled virtual-familiar plugin tracks hunger, energy, happiness, and affection.
- Status values live in the right-click familiar context menu (Virtual Familiar ▸ Mood, Food, Energy, Play, Bond, Level) instead of an on-familiar HUD overlay, so double-click chat stays unobstructed.
- Feed, play, familiar, and nap commands are available from the familiar context menu.
## New: Pet Character & Base Instructions
## New: Familiar Character & Base Instructions
- Give your pet a custom system-prompt style character in Settings.
- Toggle whether the default OpenPets behavior instructions are included in every chat.
- Give your familiar a custom system-prompt style character in Settings.
- Toggle whether the default FamiliarOS behavior instructions are included in every chat.
## New: Text-to-Speech (Phase 2)
The pet can now speak aloud using a configurable TTS provider.
The familiar can now speak aloud using a configurable TTS provider.
- Settings > **Text-to-Speech** chooses between system OS voice, OpenAI TTS, ElevenLabs, Piper (local), or an OpenAI-compatible endpoint.
- Per-provider voice, model, speed, endpoint preset, and API-key storage.
- Test and Stop buttons preview the voice in Settings.
- Speech is skipped during quiet hours and uses the same safety boundaries as bubbles.
- Plugins with the `voice:speak` permission can ask the pet to speak.
- Plugins with the `voice:speak` permission can ask the familiar to speak.
## New: Multi-Screen Awareness
The pet now stays on the display it is currently near, rather than always snapping to the primary display.
The familiar now stays on the display it is currently near, rather than always snapping to the primary display.
## Installing these changes
No additional setup is required beyond the normal OpenPets desktop app. The new surfaces appear automatically:
No additional setup is required beyond the normal FamiliarOS desktop app. The new surfaces appear automatically:
- Single-click the pet → pet the virtual pet.
- Double-click the pet → floating chat.
- Right-click the pet → Virtual Pet status and care commands.
- Single-click the familiar → familiar the virtual familiar.
- Double-click the familiar → floating chat.
- Right-click the familiar → Virtual Familiar status and care commands.
- Settings → OpenAPI Chat for model/credential setup.
- Settings → Text-to-Speech for voice provider and test preview.
- Settings → Memory for memory management.
- Settings → Knowledge Store for file-based knowledge.
- Integrations → MCP Tool Servers for built-in chat tool activation.
- Integrations → OpenPets MCP Server for external-agent bridge configuration.
- Integrations → FamiliarOS MCP Server for external-agent bridge configuration.
- Integrations → Curated MCP Toolkit for optional persistent bundles and copy-paste snippets.

View file

@ -2,27 +2,27 @@
## Responsibility
Container for deployable application packages selected by the pnpm workspace `apps/*` glob. Currently hosts the OpenPets desktop Electron application, which integrates shared packages, local IPC, pet windows, and desktop plugin support into the user-facing app.
Container for deployable application packages selected by the pnpm workspace `apps/*` glob. Currently hosts the FamiliarOS desktop Electron application, which integrates shared packages, local IPC, familiar windows, and desktop plugin support into the user-facing app.
## Design Patterns
- **Workspace App Boundary**: `pnpm-workspace.yaml` includes `apps/*`, so each app directory is an independently buildable workspace package.
- **Workspace Dependencies**: apps consume shared `packages/` modules through `workspace:*` dependencies.
- **Electron-First Architecture**: the desktop app uses an Electron main process, tray-centric UX, and isolated renderer windows rather than a traditional main window.
- **Service-Oriented Desktop Modules**: desktop features are split into main-process services for state, IPC, pet controllers, catalog installation, setup flows, and plugin management/runtime.
- **Service-Oriented Desktop Modules**: desktop features are split into main-process services for state, IPC, familiar controllers, catalog installation, setup flows, and plugin management/runtime.
- **Security-First Renderers**: CSP headers, sandboxed renderers, context isolation, and disabled `nodeIntegration` protect UI surfaces including plugin-related windows.
## Data & Control Flow
1. Workspace tooling discovers application packages via the `apps/*` pnpm workspace glob.
2. The desktop app bootstraps from `apps/desktop/src/main.ts`, initializes user data and app state, then creates tray and renderer windows.
3. Agent commands flow through the local IPC server into lease-managed pet controllers and window updates.
4. Pet assets flow from built-in assets, local development sources, or downloaded catalog packages into installation/state services and renderer windows.
5. Desktop plugin manifests/configuration flow through plugin loader, package, catalog, state, service, and runtime modules before exposing controlled pet APIs and plugin UI.
3. Agent commands flow through the local IPC server into lease-managed familiar controllers and window updates.
4. Familiar assets flow from built-in assets, local development sources, or downloaded catalog packages into installation/state services and renderer windows.
5. Desktop plugin manifests/configuration flow through plugin loader, package, catalog, state, service, and runtime modules before exposing controlled familiar APIs and plugin UI.
## Integration Points
- **Workspace packages**: consumes `@open-pets/agent-events`, `@open-pets/claude`, `@open-pets/cli`, `@open-pets/cursor`, `@open-pets/mcp`, `@open-pets/opencode`, and IPC/client-facing shared APIs.
- **Desktop submodules**: `apps/desktop/src/` provides lifecycle, state, tray/windows, setup integrations, pet installation, local IPC, and plugin runtime services.
- **External services**: GitHub Releases API for update checks, `openpets.dev` for catalog data, and `zip.openpets.dev` for pet downloads.
- **Workspace packages**: consumes `@familiaros/agent-events`, `@familiaros/claude`, `@familiaros/cli`, `@familiaros/cursor`, `@familiaros/mcp`, `@familiaros/opencode`, and IPC/client-facing shared APIs.
- **Desktop submodules**: `apps/desktop/src/` provides lifecycle, state, tray/windows, setup integrations, familiar installation, local IPC, and plugin runtime services.
- **External services**: GitHub Releases API for update checks, `familiaros.dev` for catalog data, and `zip.familiaros.dev` for familiar downloads.
- **System surfaces**: Claude Code CLI, OpenCode CLI, OS tray/dock, renderer windows, and filesystem locations such as `userData`, `~/.codex`, and `~/.claude`.

View file

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View file

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 397 KiB

View file

@ -1,41 +1,41 @@
{
"version": 2,
"generatedAt": "2026-05-05T08:55:53.722Z",
"pets": [
"familiars": [
{
"id": "snoopy",
"displayName": "Snoopy",
"description": "A tiny black-and-white beagle with a red collar for calm coding sessions.",
"preview": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip"
"preview": "https://familiaros.dev/familiars/snoopy-23e05847/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/snoopy-23e05847/snoopy.zip"
},
{
"id": "clippit",
"displayName": "Clippy",
"description": "A classic paperclip assistant rebuilt from Microsoft Agent animation frames.",
"preview": "https://openpets.dev/pets/clippit-904b393f/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/clippit-904b393f/clippit.zip"
"preview": "https://familiaros.dev/familiars/clippit-904b393f/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/clippit-904b393f/clippit.zip"
},
{
"id": "tux",
"displayName": "Tux",
"description": "A tiny pixel-adjacent Linux mascot penguin for calm coding sessions.",
"preview": "https://openpets.dev/pets/tux-de2f300f/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/tux-de2f300f/tux.zip"
"preview": "https://familiaros.dev/familiars/tux-de2f300f/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/tux-de2f300f/tux.zip"
},
{
"id": "wall-e",
"displayName": "Wall-E",
"description": "A tiny weathered trash-compactor robot companion with binocular eyes, treads, and a gentle waving arm.",
"preview": "https://openpets.dev/pets/wall-e-779d5202/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/wall-e-779d5202/wall-e.zip"
"preview": "https://familiaros.dev/familiars/wall-e-779d5202/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/wall-e-779d5202/wall-e.zip"
},
{
"id": "dobby",
"displayName": "Dobby",
"description": "An earnest, genuinely helpful tiny house-elf Codex pet with huge ears, wide kind eyes, and a simple worn tunic.",
"preview": "https://openpets.dev/pets/dobby-3f6746e0/spritesheet.webp",
"zip": "https://zip.openpets.dev/pets/dobby-3f6746e0/dobby.zip"
"description": "An earnest, genuinely helpful tiny house-elf Codex familiar with huge ears, wide kind eyes, and a simple worn tunic.",
"preview": "https://familiaros.dev/familiars/dobby-3f6746e0/spritesheet.webp",
"zip": "https://zip.familiaros.dev/familiars/dobby-3f6746e0/dobby.zip"
}
]
}

View file

@ -2,11 +2,11 @@
## Responsibility
OpenPets desktop companion application. Tray-first Electron app providing animated desktop pets that react to coding agent events. Manages pet installations, the React/Tailwind Control Center, plugin automation/runtime, agent integrations (Claude Code, OpenCode, Cursor, Pi guidance), and local IPC for CLI communication.
FamiliarOS desktop companion application. Tray-first Electron app providing animated desktop familiars that react to coding agent events. Manages familiar installations, the React/Tailwind Control Center, plugin automation/runtime, agent integrations (Claude Code, OpenCode, Cursor, Pi guidance), and local IPC for CLI communication.
## Design
- **Tray-First UX**: No default main window; tray actions open the singleton React/Tailwind Control Center and route directly to Dashboard, Pets, Integrations, Plugins, and Settings.
- **Tray-First UX**: No default main window; tray actions open the singleton React/Tailwind Control Center and route directly to Dashboard, Familiars, Integrations, Plugins, and Settings.
- **Single Instance**: Uses `app.requestSingleInstanceLock()` with second-instance focusing
- **Security Model**:
- Sandboxed renderers with contextIsolation
@ -15,13 +15,13 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- Mock keychain to prevent OS credential prompts
- IPC network security: loopback/private address filtering for TCP mode
- **State Management**: File-based JSON state with atomic writes (temp + rename)
- **Pet Architecture**:
- Default pet (always visible when enabled)
- Agent pets (lease-based, appear on explicit agent requests)
- Built-in fallback pet (bundled spritesheet)
- **Familiar Architecture**:
- Default familiar (always visible when enabled)
- Agent familiars (lease-based, appear on explicit agent requests)
- Built-in fallback familiar (bundled spritesheet)
- Speech bubbles with reaction messages and status badges
- User-configurable reaction-to-animation mapping
- **Lease Manager**: 15s TTL leases for agent pet routing with heartbeat renewal
- **Lease Manager**: 15s TTL leases for agent familiar routing with heartbeat renewal
- **Logging**: Structured logging with scopes, log rotation (2MB max), and sensitive data redaction
- **Plugin Subsystem**: Declarative manifest plugins and JavaScript plugin hosting with permission approval, config schemas, command/status surfaces, catalog/local installs, SDK bridge quotas, storage, schedules, restricted HTTPS fetch, and safe path/ZIP/manifest validation
@ -29,33 +29,33 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
**Startup**: `main.ts``installAppLifecycle()``initializeAppState()``initializeLogger()``createAppTray()``startLocalIpcServer()` → initialize plugin service with JavaScript host/SDK bridge → optionally `showDefaultPet()`
**Pet Display**: IPC Request → `local-ipc.ts``LeaseManager.acquire()``agent-pet-controller.ts` → `pet-window.ts` → HTML/CSS spritesheet animation with reaction-to-animation mapping
**Familiar Display**: IPC Request → `local-ipc.ts``LeaseManager.acquire()``agent-familiar-controller.ts` → `familiar-window.ts` → HTML/CSS spritesheet animation with reaction-to-animation mapping
**Installation**: Catalog fetch (V3 with pagination fallback to V2) → ZIP download → `yauzl` extraction → validation → state update → tray refresh
**Agent Setup**: UI → `agent-setup.ts` → Claude/OpenCode/Cursor CLI detection → MCP config modification → hooks installation → memory file management
**Control Center**: Tray route → `openControlCenterWindow(route)``windows.ts` loads Vite renderer and sends route events → `control-center-preload.cjs` exposes narrow page APIs → React Dashboard/Pets/Integrations/Plugins/Settings routes render snapshots and invoke actions.
**Control Center**: Tray route → `openControlCenterWindow(route)``windows.ts` loads Vite renderer and sends route events → `control-center-preload.cjs` exposes narrow page APIs → React Dashboard/Familiars/Integrations/Plugins/Settings routes render snapshots and invoke actions.
**Plugins**: Control Center plugins route → `plugin-service.ts` → catalog or local manifest/entry loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers or starts `plugin-js-host.ts``plugin-sdk-bridge.ts` applies approved SDK calls to pet/schedule/storage/command/status/network APIs
**Plugins**: Control Center plugins route → `plugin-service.ts` → catalog or local manifest/entry loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers or starts `plugin-js-host.ts``plugin-sdk-bridge.ts` applies approved SDK calls to familiar/schedule/storage/command/status/network APIs
## Integration Points
- **Workspace Packages**: `@open-pets/agent-events`, `@open-pets/claude`, `@open-pets/cli`, `@open-pets/cursor`, `@open-pets/mcp`, `@open-pets/opencode`
- **Workspace Packages**: `@familiaros/agent-events`, `@familiaros/claude`, `@familiaros/cli`, `@familiaros/cursor`, `@familiaros/mcp`, `@familiaros/opencode`
- **External Services**:
- `https://openpets.dev/pets/catalog.v2.json` (pet catalog V2)
- `https://openpets.dev/pets/catalog.v3.json` (pet catalog V3 with pagination)
- `https://openpets.dev/plugins/catalog.v1.json` (plugin catalog V1)
- `https://zip.openpets.dev/pets/{id}.zip` (pet downloads)
- `https://zip.openpets.dev/plugins/{id}.zip` (plugin downloads)
- `https://familiaros.dev/familiars/catalog.v2.json` (familiar catalog V2)
- `https://familiaros.dev/familiars/catalog.v3.json` (familiar catalog V3 with pagination)
- `https://familiaros.dev/plugins/catalog.v1.json` (plugin catalog V1)
- `https://zip.familiaros.dev/familiars/{id}.zip` (familiar downloads)
- `https://zip.familiaros.dev/plugins/{id}.zip` (plugin downloads)
- GitHub API (release checks)
- **System Integration**:
- Claude Code: `~/.claude/CLAUDE.md`, `~/.claude/settings.json`, `claude mcp` commands
- OpenCode: `~/.opencode/config.json`
- Cursor: `~/.cursor/mcp.json`, `.cursor/rules/openpets.mdc`
- Codex: `~/.codex/pets/` (local pet development)
- Cursor: `~/.cursor/mcp.json`, `.cursor/rules/familiaros.mdc`
- Codex: `~/.codex/familiars/` (local familiar development)
- IPC: Discovery file at platform-specific path, Unix socket/Windows named pipe/TCP
- Logs: `userData/logs/openpets.log`
- Logs: `userData/logs/familiaros.log`
- **Build**: `electron-builder` with ASAR, cross-platform (macOS/Windows/Linux)
## Key Files
@ -63,32 +63,32 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- `main.ts`: Entry point, lifecycle coordination
- `tray.ts`: System tray icon and menu
- `windows.ts`: Control Center BrowserWindow management, Dashboard snapshot, route targeting, IPC handlers, and internal protocols
- `renderer/`: React/Tailwind Control Center for Dashboard, Pets, Integrations, Plugins, and Settings
- `renderer/`: React/Tailwind Control Center for Dashboard, Familiars, Integrations, Plugins, and Settings
- `local-ipc.ts`: TCP/Unix socket server for CLI communication
- `lease-manager.ts`: Pet routing lease lifecycle
- `pet-window.ts`: Pet rendering (transparent frameless windows, CSS sprite animation, speech bubbles, status badges)
- `default-pet-controller.ts`/`agent-pet-controller.ts`: Pet visibility/state management with transient displays
- `lease-manager.ts`: Familiar routing lease lifecycle
- `familiar-window.ts`: Familiar rendering (transparent frameless windows, CSS sprite animation, speech bubbles, status badges)
- `default-familiar-controller.ts`/`agent-familiar-controller.ts`: Familiar visibility/state management with transient displays
- `app-state.ts`: Persistent state management (JSON file)
- `agent-setup.ts`: Claude/OpenCode/Cursor integration logic
- `plugin-service.ts`: Plugin orchestration for snapshots, enable/config/reload, command execution, catalog install/update/uninstall, local loading, permission approval, JavaScript host wiring, and runtime reloads
- `plugin-manifest.ts`: `openpets.plugin.json` v1/v2 schema/types/validator for declarative timer plugins and JavaScript SDK plugins, config fields, permissions, commands/status/network, and actions
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timers and starts JavaScript plugin hosts for approved pet/schedule/storage/command/status/network actions
- `plugin-manifest.ts`: `familiaros.plugin.json` v1/v2 schema/types/validator for declarative timer plugins and JavaScript SDK plugins, config fields, permissions, commands/status/network, and actions
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timers and starts JavaScript plugin hosts for approved familiar/schedule/storage/command/status/network actions
- `plugin-state.ts`: Atomic JSON state store for installed plugins, enabled flag, approved permissions, config, broken state, and update metadata
- `plugin-config.ts`: Plugin default/effective config validation and config reference resolution
- `plugin-catalog.ts`/`plugin-catalog-validation.ts`: Plugin catalog fetch/cache and strict catalog entry validation
- `plugin-package.ts`: Catalog plugin ZIP download, SHA-256 verification, manifest extraction, install, and safe uninstall path resolution
- `plugin-local-loader.ts`: Local developer plugin folder validation and manifest snapshotting into app data
- `plugin-manifest-reader.ts`: Safe installed-manifest reader enforcing allowed roots, size limits, path containment, and expected id/version
- `plugin-pet-api.ts`: Runtime bridge from plugin actions to default pet speech/reaction APIs
- `plugin-familiar-api.ts`: Runtime bridge from plugin actions to default familiar speech/reaction APIs
- `plugin-js-host.ts`: Hidden sandboxed BrowserWindow host for JavaScript plugin entry modules, SDK IPC tokening, session hardening, startup handshake, and teardown
- `plugin-sdk-bridge.ts`: Permission-checked SDK API for JavaScript plugins with quotas, plugin storage, schedules, config listeners, commands/status, logs, and restricted HTTPS fetch
- `pet-installation.ts`: Catalog ZIP download and extraction
- `codex-pets.ts`: Local Codex pet import
- `familiar-installation.ts`: Catalog ZIP download and extraction
- `codex-familiars.ts`: Local Codex familiar import
- `catalog.ts`: Remote catalog fetching with V3 pagination and fixture fallback
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet, state, tray, ui)
- `logger.ts`: Structured logging with scopes (app, ipc, lease, familiar, state, tray, ui)
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping with user overrides
- `reaction-messages.ts`: Message pools for each reaction type
- `control-center-preload.cjs`/`pet-preload.cjs`/`plugin-sdk-preload.cjs`: Narrow contextBridge APIs for the Control Center, pet windows, and plugin SDK host; the legacy `preload.cjs` task-window bridge and `plugins-window.ts` UI have been removed
- `control-center-preload.cjs`/`familiar-preload.cjs`/`plugin-sdk-preload.cjs`: Narrow contextBridge APIs for the Control Center, familiar windows, and plugin SDK host; the legacy `preload.cjs` task-window bridge and `plugins-window.ts` UI have been removed
- `electron-builder.yml`: Packaging configuration
- `scripts/release-local.mjs`: macOS-local release automation with GitHub draft creation
- `contracts/catalog-fixture.contract.ts`: Catalog V2 validation contract tests against fixture data
@ -97,7 +97,7 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
## Test Structure
- **Behavior tests** (`tests/*.test.ts`): Unit tests for lease manager, state management, version checking, ZIP safety, Codex pets, Claude memory, and reaction animation mapping. Compiled to `.test-dist/tests/`.
- **Behavior tests** (`tests/*.test.ts`): Unit tests for lease manager, state management, version checking, ZIP safety, Codex familiars, Claude memory, and reaction animation mapping. Compiled to `.test-dist/tests/`.
- **Contract tests** (`contracts/*.contract.ts`): Public API boundary validation for catalog fixtures, IPC protocol, and plugin manifest schema. Compiled to `.test-dist/contracts/`.
- **Runtime checks** (`src/check-*.ts`): Remaining runtime validation checks compiled to `dist/`.
- **Test runner** (`scripts/run-tests.mjs`): Orchestrates preload syntax checks → test compilation → behavior tests → contract tests → dist checks.

View file

@ -8,11 +8,11 @@ validateCatalogV2(fixture);
const generatedAt = new Date().toISOString();
const invalidCases: readonly unknown[] = [
{ version: 2, generatedAt, pets: [{ id: "Bad ID", displayName: "Bad", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt, pets: [{ id: "dup", displayName: "Dup", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }, { id: "dup", displayName: "Dup 2", description: "", preview: "https://openpets.dev/pets/y/spritesheet.webp", zip: "https://zip.openpets.dev/pets/y/y.zip" }] },
{ version: 2, generatedAt, pets: [{ id: "http", displayName: "Http", description: "", preview: "http://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt, pets: [{ id: "host", displayName: "Host", description: "", preview: "https://evil.example/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt, pets: [{ id: "builtin", displayName: "Builtin", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "Bad ID", displayName: "Bad", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "dup", displayName: "Dup", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }, { id: "dup", displayName: "Dup 2", description: "", preview: "https://familiaros.dev/familiars/y/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/y/y.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "http", displayName: "Http", description: "", preview: "http://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "host", displayName: "Host", description: "", preview: "https://evil.example/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
{ version: 2, generatedAt, familiars: [{ id: "builtin", displayName: "Builtin", description: "", preview: "https://familiaros.dev/familiars/x/spritesheet.webp", zip: "https://zip.familiaros.dev/familiars/x/x.zip" }] },
];
for (const invalidCase of invalidCases) {

View file

@ -2,7 +2,7 @@
## Responsibility
Public API boundary contract tests for the OpenPets desktop application. These contracts validate that critical external-facing interfaces (catalog data, IPC protocol) conform to expected schemas and behavior. Contract tests are compiled to `.test-dist/contracts/` and executed during `pnpm test`.
Public API boundary contract tests for the FamiliarOS desktop application. These contracts validate that critical external-facing interfaces (catalog data, IPC protocol) conform to expected schemas and behavior. Contract tests are compiled to `.test-dist/contracts/` and executed during `pnpm test`.
## Design Patterns
@ -32,8 +32,8 @@ Test parseIpcRequest() with valid/invalid tokens, versions, methods
**Plugin Manifest Contract** (`plugin-manifest.contract.ts`):
```
Assert openpets.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and pet actions
Assert familiaros.plugin.json filename constant
→ Validate a declarative timer manifest with permissions, config schema, and familiar actions
→ Test config references, unknown fields, runtime/permission constraints, deferred config types/features, defaults, options, timer intervals, and required permissions
→ Assert each invalid manifest reports the expected error code
```

View file

@ -12,10 +12,10 @@ const valid = {
};
parseIpcRequest(JSON.stringify(valid), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "pets.list" }), token);
parseIpcRequest(JSON.stringify({ ...valid, method: "familiars.list" }), token);
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, token: "bad" }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, version: 2 }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, method: "pet.install" }), token));
assert.throws(() => parseIpcRequest(JSON.stringify({ ...valid, method: "familiar.install" }), token));
assert.throws(() => parseIpcRequest("not json", token));
validateReaction("testing");

View file

@ -1,6 +1,6 @@
import { openPetsPluginManifestFilename, validatePluginManifest } from "../src/plugin-manifest.js";
assertEqual(openPetsPluginManifestFilename, "openpets.plugin.json");
assertEqual(openPetsPluginManifestFilename, "familiaros.plugin.json");
const validManifest = {
manifestVersion: 1,
@ -8,7 +8,7 @@ const validManifest = {
name: "Stretch Timer",
version: "1.0.0",
runtime: "declarative",
permissions: ["timer", "pet:speak", "pet:reaction"],
permissions: ["timer", "familiar:speak", "familiar:reaction"],
configSchema: {
intervalMinutes: { type: "number", label: "Interval", default: 15 },
message: { type: "text", label: "Message", default: "Time to stretch!" },
@ -24,8 +24,8 @@ const validManifest = {
on: "timer",
everyMinutes: { config: "intervalMinutes" },
actions: [
{ type: "pet.speak", message: "Time to stretch!" },
{ type: "pet.react", reaction: "celebrating" },
{ type: "familiar.speak", message: "Time to stretch!" },
{ type: "familiar.react", reaction: "celebrating" },
],
},
],
@ -34,18 +34,18 @@ const validManifest = {
assertValid(validManifest);
assertValid({ ...validManifest, icon: "bell" });
assertInvalid({ ...validManifest, icon: "https://example.com/icon.svg" }, "invalid_icon");
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } });
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "<svg>", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_icon");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_runtime");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["pet:speak"] }, "invalid_entry");
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak", "network"], network: { hosts: ["api.example.com"] } });
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "<svg>", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak"] }, "invalid_icon");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["familiar:speak"] }, "invalid_runtime");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["familiar:speak"] }, "invalid_entry");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["*.example.com"] } }, "invalid_network_host");
assertValid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message" } }, { type: "pet.react", reaction: { config: "mood" } }] }] });
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message", extra: true } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "missing" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "mood" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "message" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", default: "celebrate", options: [{ label: "Celebrate", value: "celebrate" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", options: [{ label: "Celebrate", value: "celebrating" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertValid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "message" } }, { type: "familiar.react", reaction: { config: "mood" } }] }] });
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "message", extra: true } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "missing" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: { config: "mood" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "message" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", default: "celebrate", options: [{ label: "Celebrate", value: "celebrate" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", options: [{ label: "Celebrate", value: "celebrating" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, extra: true }, "unknown_field");
assertInvalid(
{
@ -64,7 +64,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: "Hi", unsafe: true }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: "Hi", unsafe: true }] }],
},
"unknown_field",
);
@ -77,7 +77,7 @@ assertInvalid(
);
assertInvalid({ ...validManifest, runtime: "javascript" }, "unsupported_runtime");
assertInvalid({ ...validManifest, permissions: ["timer", "network"] }, "invalid_permission");
assertInvalid({ ...validManifest, permissions: ["timer", "pet:speak", "timer", "pet:reaction"] }, "duplicate_permission");
assertInvalid({ ...validManifest, permissions: ["timer", "familiar:speak", "timer", "familiar:reaction"] }, "duplicate_permission");
assertInvalid(
{
...validManifest,
@ -96,7 +96,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.badge", text: "Busy" }] }],
triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.badge", text: "Busy" }] }],
},
"invalid_action",
);
@ -175,7 +175,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["pet:speak", "pet:reaction"],
permissions: ["familiar:speak", "familiar:reaction"],
},
"missing_permission",
);
@ -183,7 +183,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "pet:reaction"],
permissions: ["timer", "familiar:reaction"],
},
"missing_permission",
);
@ -191,7 +191,7 @@ assertInvalid(
assertInvalid(
{
...validManifest,
permissions: ["timer", "pet:speak"],
permissions: ["timer", "familiar:speak"],
},
"missing_permission",
);

View file

@ -1,82 +1,82 @@
const { contextBridge, ipcRenderer } = require("electron");
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"),
getI18n: () => ipcRenderer.invoke("openpets:get-i18n"),
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),
getUpdateStatus: () => ipcRenderer.invoke("openpets:get-update-status"),
checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"),
openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"),
resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"),
getPluginsSnapshot: () => ipcRenderer.invoke("openpets:plugins-snapshot"),
getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("openpets:plugins-catalog-snapshot", refresh),
setPluginEnabled: (id, enabled) => ipcRenderer.invoke("openpets:plugins-set-enabled", id, enabled),
savePluginConfig: (id, config) => ipcRenderer.invoke("openpets:plugins-save-config", id, config),
pickPluginConfigSound: (id) => ipcRenderer.invoke("openpets:plugins-pick-config-sound", id),
reloadPlugin: (id) => ipcRenderer.invoke("openpets:plugins-reload", id),
executePluginCommand: (id, commandId, args) => ipcRenderer.invoke("openpets:plugins-execute-command", id, commandId, args),
loadLocalPlugin: () => ipcRenderer.invoke("openpets:plugins-load-local"),
installCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-install-catalog", id),
updateCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-update-catalog", id),
uninstallPlugin: (id) => ipcRenderer.invoke("openpets:plugins-uninstall", id),
getPluginInspector: (id) => ipcRenderer.invoke("openpets:plugins-inspector", id),
getPluginPlatformSettings: () => ipcRenderer.invoke("openpets:plugin-platform-settings-get"),
updatePluginPlatformSettings: (patch) => ipcRenderer.invoke("openpets:plugin-platform-settings-update", patch),
setPluginAiApiKey: (key) => ipcRenderer.invoke("openpets:plugin-platform-ai-key-set", key),
getPluginAiApiKeyStatus: () => ipcRenderer.invoke("openpets:plugin-platform-ai-key-status"),
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"),
getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"),
setDefaultPet: (petId) => ipcRenderer.invoke("openpets:set-default-pet", petId),
installPet: (petId) => ipcRenderer.invoke("openpets:install-pet", petId),
installLocalPet: () => ipcRenderer.invoke("openpets:install-local-pet"),
importCodexPet: (petId) => ipcRenderer.invoke("openpets:import-codex-pet", petId),
openGallery: () => ipcRenderer.invoke("openpets:open-gallery"),
removePet: (petId) => ipcRenderer.invoke("openpets:remove-pet", petId),
getPetsState: () => ipcRenderer.invoke("familiaros:get-familiars-state"),
getDashboardSnapshot: () => ipcRenderer.invoke("familiaros:get-dashboard-snapshot"),
getSettingsState: () => ipcRenderer.invoke("familiaros:get-settings-state"),
getOpenApiChatSettings: () => ipcRenderer.invoke("familiaros:get-openapi-chat-settings"),
getI18n: () => ipcRenderer.invoke("familiaros:get-i18n"),
updatePreferences: (patch) => ipcRenderer.invoke("familiaros:update-preferences", patch),
saveOpenApiCredential: (apiKey) => ipcRenderer.invoke("familiaros:save-openapi-credential", apiKey),
clearOpenApiCredential: () => ipcRenderer.invoke("familiaros:clear-openapi-credential"),
getReactionAnimationSettings: () => ipcRenderer.invoke("familiaros:get-reaction-animation-settings"),
getLaunchAtLogin: () => ipcRenderer.invoke("familiaros:get-launch-at-login"),
setLaunchAtLogin: (enabled) => ipcRenderer.invoke("familiaros:set-launch-at-login", enabled),
getUpdateStatus: () => ipcRenderer.invoke("familiaros:get-update-status"),
checkForUpdates: () => ipcRenderer.invoke("familiaros:check-for-updates"),
openUpdateReleasePage: () => ipcRenderer.invoke("familiaros:open-update-release-page"),
resetDefaultPetPosition: () => ipcRenderer.invoke("familiaros:reset-default-familiar-position"),
getPluginsSnapshot: () => ipcRenderer.invoke("familiaros:plugins-snapshot"),
getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("familiaros:plugins-catalog-snapshot", refresh),
setPluginEnabled: (id, enabled) => ipcRenderer.invoke("familiaros:plugins-set-enabled", id, enabled),
savePluginConfig: (id, config) => ipcRenderer.invoke("familiaros:plugins-save-config", id, config),
pickPluginConfigSound: (id) => ipcRenderer.invoke("familiaros:plugins-pick-config-sound", id),
reloadPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-reload", id),
executePluginCommand: (id, commandId, args) => ipcRenderer.invoke("familiaros:plugins-execute-command", id, commandId, args),
loadLocalPlugin: () => ipcRenderer.invoke("familiaros:plugins-load-local"),
installCatalogPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-install-catalog", id),
updateCatalogPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-update-catalog", id),
uninstallPlugin: (id) => ipcRenderer.invoke("familiaros:plugins-uninstall", id),
getPluginInspector: (id) => ipcRenderer.invoke("familiaros:plugins-inspector", id),
getPluginPlatformSettings: () => ipcRenderer.invoke("familiaros:plugin-platform-settings-get"),
updatePluginPlatformSettings: (patch) => ipcRenderer.invoke("familiaros:plugin-platform-settings-update", patch),
setPluginAiApiKey: (key) => ipcRenderer.invoke("familiaros:plugin-platform-ai-key-set", key),
getPluginAiApiKeyStatus: () => ipcRenderer.invoke("familiaros:plugin-platform-ai-key-status"),
getCatalog: () => ipcRenderer.invoke("familiaros:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("familiaros:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("familiaros:get-catalog-search"),
getCodexPets: () => ipcRenderer.invoke("familiaros:get-codex-familiars"),
setDefaultPet: (petId) => ipcRenderer.invoke("familiaros:set-default-familiar", petId),
installPet: (petId) => ipcRenderer.invoke("familiaros:install-familiar", petId),
installLocalPet: () => ipcRenderer.invoke("familiaros:install-local-familiar"),
importCodexPet: (petId) => ipcRenderer.invoke("familiaros:import-codex-familiar", petId),
openGallery: () => ipcRenderer.invoke("familiaros:open-gallery"),
removePet: (petId) => ipcRenderer.invoke("familiaros:remove-familiar", petId),
onRouteChange: (callback) => {
const listener = (_event, route) => callback(route);
ipcRenderer.on("openpets:control-center-route", listener);
return () => ipcRenderer.removeListener("openpets:control-center-route", listener);
ipcRenderer.on("familiaros:control-center-route", listener);
return () => ipcRenderer.removeListener("familiaros:control-center-route", listener);
},
onPluginsRefresh: (callback) => {
const listener = () => callback();
ipcRenderer.on("openpets:plugins-refresh", listener);
return () => ipcRenderer.removeListener("openpets:plugins-refresh", listener);
ipcRenderer.on("familiaros:plugins-refresh", listener);
return () => ipcRenderer.removeListener("familiaros:plugins-refresh", listener);
},
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),
getOpenPetsMcpServerPreview: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:openpets-mcp-server-preview", selectedPetId, commandMode),
testOpenPetsMcpServer: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:test-openpets-mcp-server", selectedPetId, commandMode),
getTtsSettings: () => ipcRenderer.invoke("openpets:get-tts-settings"),
getTtsVoices: (provider) => ipcRenderer.invoke("openpets:get-tts-voices", provider),
saveTtsCredential: (provider, credential) => ipcRenderer.invoke("openpets:save-tts-credential", provider, credential),
clearTtsCredential: (provider) => ipcRenderer.invoke("openpets:clear-tts-credential", provider),
testTtsSpeak: (text) => ipcRenderer.invoke("openpets:test-tts", text),
stopTts: () => ipcRenderer.invoke("openpets:tts-stop"),
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),
knowledgeList: (limit) => ipcRenderer.invoke("openpets:knowledge-list", limit),
knowledgeSearch: (query, limit) => ipcRenderer.invoke("openpets:knowledge-search", query, limit),
knowledgeDeleteFile: (id) => ipcRenderer.invoke("openpets:knowledge-delete-file", id),
knowledgeAddMemory: (text, kind, tags, importance) => ipcRenderer.invoke("openpets:knowledge-add-memory", text, kind, tags, importance),
knowledgeStoreFile: () => ipcRenderer.invoke("openpets:knowledge-store-file"),
getIntegrationsState: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:agent-setup-snapshot", selectedPetId, commandMode),
runIntegrationAction: (action, selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:agent-setup-action", action, selectedPetId, commandMode),
updateIntegrationCommandPaths: (patch) => ipcRenderer.invoke("familiaros:agent-setup-command-paths", patch),
getFamiliarOSMcpServerPreview: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:familiaros-mcp-server-preview", selectedPetId, commandMode),
testFamiliarOSMcpServer: (selectedPetId, commandMode) => ipcRenderer.invoke("familiaros:test-familiaros-mcp-server", selectedPetId, commandMode),
getTtsSettings: () => ipcRenderer.invoke("familiaros:get-tts-settings"),
getTtsVoices: (provider) => ipcRenderer.invoke("familiaros:get-tts-voices", provider),
saveTtsCredential: (provider, credential) => ipcRenderer.invoke("familiaros:save-tts-credential", provider, credential),
clearTtsCredential: (provider) => ipcRenderer.invoke("familiaros:clear-tts-credential", provider),
testTtsSpeak: (text) => ipcRenderer.invoke("familiaros:test-tts", text),
stopTts: () => ipcRenderer.invoke("familiaros:tts-stop"),
copyText: (text) => ipcRenderer.invoke("familiaros:copy-text", text),
openExternalUrl: (url) => ipcRenderer.invoke("familiaros:open-external-url", url),
installMcpToolkit: (target) => ipcRenderer.invoke("familiaros:install-mcp-toolkit", target),
getVanillaChatMcpTools: () => ipcRenderer.invoke("familiaros:get-vanilla-chat-mcp-tools"),
setVanillaChatMcpTools: (toolIds) => ipcRenderer.invoke("familiaros:set-vanilla-chat-mcp-tools", toolIds),
getMemories: (query, limit) => ipcRenderer.invoke("familiaros:get-memories", query, limit),
storeMemory: (text, kind, tags, importance) => ipcRenderer.invoke("familiaros:store-memory", text, kind, tags, importance),
updateMemory: (id, text, kind, tags, importance) => ipcRenderer.invoke("familiaros:update-memory", id, text, kind, tags, importance),
deleteMemory: (id) => ipcRenderer.invoke("familiaros:delete-memory", id),
knowledgeList: (limit) => ipcRenderer.invoke("familiaros:knowledge-list", limit),
knowledgeSearch: (query, limit) => ipcRenderer.invoke("familiaros:knowledge-search", query, limit),
knowledgeDeleteFile: (id) => ipcRenderer.invoke("familiaros:knowledge-delete-file", id),
knowledgeAddMemory: (text, kind, tags, importance) => ipcRenderer.invoke("familiaros:knowledge-add-memory", text, kind, tags, importance),
knowledgeStoreFile: () => ipcRenderer.invoke("familiaros:knowledge-store-file"),
};
contextBridge.exposeInMainWorld("openPetsControlCenter", api);

View file

@ -1,17 +1,17 @@
appId: dev.openpets.app
productName: OpenPets
executableName: openpets
copyright: Copyright © 2026 OpenPets
appId: dev.familiaros.app
productName: FamiliarOS
executableName: familiaros
copyright: Copyright © 2026 FamiliarOS
directories:
output: dist-electron
artifactName: OpenPets-${version}-${os}-${arch}.${ext}
artifactName: FamiliarOS-${version}-${os}-${arch}.${ext}
files:
- dist/**
- control-center-preload.cjs
- pet-preload.cjs
- familiar-preload.cjs
- prompt-window-preload.cjs
- plugin-sdk-preload.cjs
- plugin-command-form-preload.cjs
@ -27,7 +27,7 @@ asar: true
asarUnpack:
- node_modules/**
- control-center-preload.cjs
- pet-preload.cjs
- familiar-preload.cjs
- prompt-window-preload.cjs
- plugin-sdk-preload.cjs
- plugin-command-form-preload.cjs
@ -59,13 +59,13 @@ win:
verifyUpdateCodeSignature: false
nsis:
artifactName: OpenPets-${version}-win-${arch}-setup.${ext}
artifactName: FamiliarOS-${version}-win-${arch}-setup.${ext}
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
portable:
artifactName: OpenPets-${version}-win-${arch}-portable.${ext}
artifactName: FamiliarOS-${version}-win-${arch}-portable.${ext}
linux:
icon: assets/app-icon.png
@ -75,4 +75,4 @@ linux:
- rpm
- tar.gz
category: Utility
maintainer: OpenPets
maintainer: FamiliarOS

View file

@ -28,23 +28,23 @@ 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 || scaling;
const stillInteractive = Boolean(newTarget && newTarget.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling;
reportInteractiveHit(stillInteractive, "bubble-dismiss", true);
ipcRenderer.send("openpets:bubble-dismissed", dismissToken);
ipcRenderer.send("familiaros: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;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("openpets:pet-open-prompt");
ipcRenderer.send("familiaros:familiar-open-prompt");
};
ipcRenderer.on("openpets:pet-motion", (_event, state) => {
ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
if (!allowedMotionStates.has(state)) {
return;
}
@ -60,7 +60,7 @@ ipcRenderer.on("openpets:pet-motion", (_event, state) => {
}
});
ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
if (!allowedReactionStates.has(state)) {
return;
}
@ -76,7 +76,7 @@ ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => {
}
});
ipcRenderer.on("openpets:pet-content-state", (_event, state) => {
ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) {
return;
}
@ -95,13 +95,13 @@ 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(".familiar-hitbox, .familiar-shell, .bubble");
};
const reportInteractiveHit = (interactive, source, force = false) => {
if (!force && lastInteractiveHit === interactive) return;
lastInteractiveHit = interactive;
ipcRenderer.send("openpets:pet-hit-test", interactive, source);
ipcRenderer.send("familiaros:familiar-hit-test", interactive, source);
};
const setInteractiveHit = (interactive, source = "mouse") => {
@ -129,15 +129,15 @@ const applyScalePreview = (scale) => {
};
const sendScalePreview = (scale) => {
ipcRenderer.send("openpets:pet-scale-preview", { scale });
ipcRenderer.send("familiaros:familiar-scale-preview", { scale });
};
ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => {
ipcRenderer.on("familiaros:familiar-probe-hit-test", (_event, point) => {
if (!point || typeof point.clientX !== "number" || typeof point.clientY !== "number" || !Number.isFinite(point.clientX) || !Number.isFinite(point.clientY)) return;
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 || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
reportInteractiveHit(Boolean(target && target.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
});
// --- Plugin bubble interactions (actions, inline inputs) -------------------
@ -159,7 +159,7 @@ const handleBubbleInteraction = (event) => {
if (actionButton) {
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("openpets:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
ipcRenderer.send("familiaros:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
return true;
}
const submitButton = target.closest("[data-bubble-submit]");
@ -167,20 +167,20 @@ const handleBubbleInteraction = (event) => {
event.preventDefault();
event.stopPropagation();
const bubble = submitButton.closest(".bubble");
ipcRenderer.send("openpets:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
ipcRenderer.send("familiaros:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
return true;
}
if (target.closest(".bubble-input-control")) return true;
return false;
};
// --- Pet senses: clicks, hover, drops ---------------------------------------
// --- Familiar senses: clicks, hover, drops ---------------------------------------
let lastHoverSentAt = 0;
let suppressClickUntil = 0;
const sendPetEvent = (name, payload) => {
ipcRenderer.send("openpets:pet-event", name, payload || {});
ipcRenderer.send("familiaros:familiar-event", name, payload || {});
};
const installPetSenses = () => {
@ -198,9 +198,9 @@ const installPetSenses = () => {
const flushClicks = () => {
resetClickTimer();
if (clickCount === 1) {
sendPetEvent("pet:clicked", {});
sendPetEvent("familiar:clicked", {});
} else if (clickCount === 2 && firstClickEvent) {
sendPetEvent("pet:doubleClicked", {});
sendPetEvent("familiar:doubleClicked", {});
requestPromptWindow(firstClickEvent);
}
clickCount = 0;
@ -211,7 +211,7 @@ const installPetSenses = () => {
if (event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest(".pet-hitbox, .pet-shell")) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
if (Date.now() < suppressClickUntil) return;
clickCount += 1;
@ -225,22 +225,22 @@ const installPetSenses = () => {
document.addEventListener("mouseover", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
const now = Date.now();
if (now - lastHoverSentAt < 2000) return;
lastHoverSentAt = now;
sendPetEvent("pet:hover", {});
sendPetEvent("familiar:hover", {});
}, { passive: true });
const maxDropTextBytes = 256 * 1024;
const maxDropFileBytes = 5 * 1024 * 1024;
document.addEventListener("dragover", (event) => {
const target = event.target;
if (target instanceof Element && target.closest(".pet-hitbox, .pet-shell")) event.preventDefault();
if (target instanceof Element && target.closest(".familiar-hitbox, .familiar-shell")) event.preventDefault();
});
document.addEventListener("drop", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) return;
@ -251,19 +251,19 @@ const installPetSenses = () => {
sizeBytes: file.size,
text: file.size <= maxDropFileBytes ? await file.text().catch(() => "") : "",
truncated: file.size > maxDropFileBytes,
}))).then((read) => sendPetEvent("pet:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
}))).then((read) => sendPetEvent("familiar:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
return;
}
const text = String(transfer.getData("text/plain") || "").slice(0, maxDropTextBytes);
if (text) sendPetEvent("pet:drop", { kind: "text", text });
if (text) sendPetEvent("familiar:drop", { kind: "text", text });
});
};
// --- Plugin sprite/scale overrides ------------------------------------------
let spriteOverrideElement = null;
ipcRenderer.on("openpets:pet-sprite-override", (_event, override) => {
const shell = document.querySelector(".pet-shell");
ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
const shell = document.querySelector(".familiar-shell");
if (!shell) return;
const base = shell.querySelector(".sprite, .installed-card");
if (spriteOverrideElement) { spriteOverrideElement.remove(); spriteOverrideElement = null; }
@ -293,14 +293,14 @@ ipcRenderer.on("openpets:pet-sprite-override", (_event, override) => {
probe.src = override.fileUrl;
});
ipcRenderer.on("openpets:pet-scale-override", (_event, scale) => {
ipcRenderer.on("familiaros:familiar-scale-override", (_event, scale) => {
const value = Number(scale);
if (!Number.isFinite(value) || value < 0.25 || value > 3) return;
const sprite = document.querySelector(".sprite, .installed-sprite");
if (sprite) sprite.style.transform = `scale(${value})`;
});
ipcRenderer.on("openpets:pet-bubble-layout", (_event, layout) => {
ipcRenderer.on("familiaros:familiar-bubble-layout", (_event, layout) => {
if (!layout || typeof layout !== "object") return;
const root = document.documentElement;
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
@ -320,7 +320,7 @@ let activeAudioElements = [];
const audioLog = (level, message, fields) => {
try {
const safeFields = fields && Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
const line = `[openpets:pet-audio] ${message}`;
const line = `[familiaros:familiar-audio] ${message}`;
if (level === "warn") console.warn(line, safeFields || {});
else console.debug(line, safeFields || {});
} catch { /* diagnostics must never affect playback */ }
@ -342,7 +342,7 @@ const namedSoundRecipes = {
error: [{ freq: 311.13, type: "sine", start: 0, duration: 0.18 }, { freq: 233.08, type: "sine", start: 0.2, duration: 0.28 }],
};
ipcRenderer.on("openpets:play-audio", (_event, payload) => {
ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
try {
if (!payload) return;
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
@ -385,7 +385,7 @@ ipcRenderer.on("openpets:play-audio", (_event, payload) => {
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
});
ipcRenderer.on("openpets:stop-audio", () => {
ipcRenderer.on("familiaros:stop-audio", () => {
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
activeAudioNodes = [];
@ -397,7 +397,7 @@ ipcRenderer.on("openpets:stop-audio", () => {
let currentTtsAudio = null;
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
@ -411,7 +411,7 @@ ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
@ -427,7 +427,7 @@ ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-stop", () => {
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();
@ -459,7 +459,7 @@ const installMouseInterop = () => {
document.addEventListener("mousemove", (event) => {
updateInteractiveHit(event);
if (dragging) {
ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY });
ipcRenderer.send("familiaros:familiar-drag-move", { screenX: event.screenX, screenY: event.screenY });
} else if (dragCandidate) {
const dx = event.screenX - dragCandidate.screenX;
const dy = event.screenY - dragCandidate.screenY;
@ -468,7 +468,7 @@ const installMouseInterop = () => {
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
dragCandidate = null;
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY });
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
}
}
if (scaling) {
@ -490,11 +490,11 @@ const installMouseInterop = () => {
scaleStartY = event.screenY;
scaleStartValue = getCurrentSpriteScale();
setInteractiveHit(true);
ipcRenderer.send("openpets:pet-scale-start");
ipcRenderer.send("familiaros:familiar-scale-start");
return;
}
if (event.button !== 0 || !target?.closest(".pet-hitbox, .pet-shell")) return;
if (event.button !== 0 || !target?.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
dragStartPoint = null;
@ -508,7 +508,7 @@ const installMouseInterop = () => {
}
dragStartPoint = null;
dragCandidate = null;
ipcRenderer.send("openpets:pet-drag-end");
ipcRenderer.send("familiaros:familiar-drag-end");
} else if (dragCandidate) {
dragCandidate = null;
}
@ -516,7 +516,7 @@ const installMouseInterop = () => {
scaling = false;
const sprite = document.querySelector(".sprite, .installed-sprite");
const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue;
ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale });
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
}
});
@ -532,7 +532,7 @@ const installMouseInterop = () => {
scaleNudgeCommitTimer = null;
}
const finalScale = getCurrentSpriteScale();
ipcRenderer.send("openpets:pet-scale-end", { scale: finalScale });
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
};
window.addEventListener("keydown", (event) => {
@ -544,13 +544,13 @@ const installMouseInterop = () => {
const current = getCurrentSpriteScale();
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
const next = applyScalePreview(current + delta);
ipcRenderer.send("openpets:pet-scale-preview", { scale: next });
ipcRenderer.send("familiaros:familiar-scale-preview", { scale: next });
if (scaleNudgeCommitTimer) clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = setTimeout(commitScaleNudge, 250);
});
setInteractiveHit(false, "ready");
ipcRenderer.send("openpets:pet-ready");
ipcRenderer.send("familiaros:familiar-ready");
};
if (document.readyState === "loading") {

View file

@ -1,23 +1,23 @@
{
"name": "@open-pets/desktop",
"name": "@familiaros/desktop",
"version": "3.1.0",
"private": true,
"description": "OpenPets tray-first desktop companion app.",
"description": "FamiliarOS tray-first desktop companion app.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/alvinunreal/openpets.git",
"url": "git+https://github.com/alvinunreal/familiaros.git",
"directory": "apps/desktop"
},
"author": "OpenPets",
"author": "FamiliarOS",
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "pnpm build && electron .",
"dev:electron": "pnpm build:main && electron .",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env OPENPETS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "cross-env OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev",
"dev:debug": "OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env FAMILIAROS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "cross-env FAMILIAROS_DISABLE_PLUGIN_CATALOG=1 FAMILIAROS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev",
"dev:debug": "FAMILIAROS_LOG_LEVEL=debug FAMILIAROS_LOG_CONSOLE=1 pnpm dev",
"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",
@ -31,7 +31,7 @@
"build": "pnpm build:main && pnpm build:renderer"
},
"devDependencies": {
"@open-pets/plugin-sdk": "workspace:*",
"@familiaros/plugin-sdk": "workspace:*",
"@types/node": "^25.6.2",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@ -50,12 +50,12 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@open-pets/agent-events": "workspace:*",
"@open-pets/claude": "workspace:*",
"@open-pets/cli": "workspace:*",
"@open-pets/cursor": "workspace:*",
"@open-pets/mcp": "workspace:*",
"@open-pets/opencode": "workspace:*",
"@familiaros/agent-events": "workspace:*",
"@familiaros/claude": "workspace:*",
"@familiaros/cli": "workspace:*",
"@familiaros/cursor": "workspace:*",
"@familiaros/mcp": "workspace:*",
"@familiaros/opencode": "workspace:*",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sharp": "^0.34.5",

View file

@ -1,7 +1,7 @@
const { contextBridge, ipcRenderer } = require("electron");
const tokenArg = process.argv.find((arg) => arg.startsWith("--openpets-panel-token="));
const channel = tokenArg ? `openpets:plugin-panel:${tokenArg.slice("--openpets-panel-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-panel-token="));
const channel = tokenArg ? `familiaros:plugin-panel:${tokenArg.slice("--familiaros-panel-token=".length)}` : "";
const handlers = new Set();
ipcRenderer.on(`${channel}:message`, (_event, msg) => {

View file

@ -4,18 +4,18 @@ const { contextBridge, ipcRenderer } = require("electron");
// conformance check extracts call()/callSync()/subscription() route literals
// from this preload and compares them to the canonical route table.
const tokenArg = process.argv.find((arg) => arg.startsWith("--openpets-plugin-token="));
const channel = tokenArg ? `openpets:plugin-sdk:${tokenArg.slice("--openpets-plugin-token=".length)}` : "";
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-plugin-token="));
const channel = tokenArg ? `familiaros:plugin-sdk:${tokenArg.slice("--familiaros-plugin-token=".length)}` : "";
let callbackId = 0;
const callbacks = new Map();
async function call(path, args) {
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
return ipcRenderer.invoke(channel, path, normalizeForIpc(args));
}
function callSync(path, args) {
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
const result = ipcRenderer.sendSync(channel, path, normalizeForIpc(args));
if (result && typeof result === "object" && typeof result.__openPetsError === "string") throw new Error(result.__openPetsError);
return result;
@ -97,7 +97,7 @@ function wrapPickedFile(file) {
}
function wrapEventPayload(event, payload) {
if (event !== "pet:drop" || !payload || !Array.isArray(payload.files)) return payload;
if (event !== "familiar:drop" || !payload || !Array.isArray(payload.files)) return payload;
return {
...payload,
files: payload.files.map((file) => ({
@ -111,35 +111,35 @@ function wrapEventPayload(event, payload) {
function makePetHandle(petId) {
return {
id: String(petId),
speak: (spec) => call("pet.speak", [petId, spec]).then(makeBubbleHandle),
react: (reaction, options) => call("pet.react", [petId, reaction, options]),
setAnimation: (state) => call("pet.setAnimation", [petId, state]),
setScale: (scale) => call("pet.setScale", [petId, scale]),
setStatusReaction: (reaction) => call("pet.setStatusReaction", [petId, reaction]),
moveBy: (options) => call("pet.moveBy", [petId, options]),
wander: (options) => call("pet.wander", [petId, options]),
moveToHome: () => call("pet.moveToHome", [petId]),
moveTo: (point, opts) => call("pet.moveTo", [petId, point, opts]),
followCursor: (opts) => call("pet.followCursor", [petId, opts]),
physics: (opts) => call("pet.physics", [petId, opts]),
onTick: (fn) => subscription("pet.onTick", "pet.offTick", [petId], fn),
getState: () => call("pet.getState", [petId]),
show: () => call("pet.show", [petId]),
hide: () => call("pet.hide", [petId]),
close: () => call("pet.close", [petId]),
speak: (spec) => call("familiar.speak", [petId, spec]).then(makeBubbleHandle),
react: (reaction, options) => call("familiar.react", [petId, reaction, options]),
setAnimation: (state) => call("familiar.setAnimation", [petId, state]),
setScale: (scale) => call("familiar.setScale", [petId, scale]),
setStatusReaction: (reaction) => call("familiar.setStatusReaction", [petId, reaction]),
moveBy: (options) => call("familiar.moveBy", [petId, options]),
wander: (options) => call("familiar.wander", [petId, options]),
moveToHome: () => call("familiar.moveToHome", [petId]),
moveTo: (point, opts) => call("familiar.moveTo", [petId, point, opts]),
followCursor: (opts) => call("familiar.followCursor", [petId, opts]),
physics: (opts) => call("familiar.physics", [petId, opts]),
onTick: (fn) => subscription("familiar.onTick", "familiar.offTick", [petId], fn),
getState: () => call("familiar.getState", [petId]),
show: () => call("familiar.show", [petId]),
hide: () => call("familiar.hide", [petId]),
close: () => call("familiar.close", [petId]),
};
}
const defaultPet = makePetHandle("default");
const sdk = {
pet: defaultPet,
pets: {
familiar: defaultPet,
familiars: {
default: defaultPet,
list: () => call("pets.list", []),
list: () => call("familiars.list", []),
get: (petId) => makePetHandle(petId),
spawn: (spec) => call("pets.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("pets.onChange", "pets.offChange", [], fn),
spawn: (spec) => call("familiars.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
onChange: (fn) => subscription("familiars.onChange", "familiars.offChange", [], fn),
},
ui: {
bubble: (spec) => call("ui.bubble", [spec]).then(makeBubbleHandle),

View file

@ -1,17 +1,17 @@
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"),
storeKnowledgeFile: (file) => ipcRenderer.invoke("openpets:prompt-window-store-file", file),
log: (level, message) => ipcRenderer.send("openpets:prompt-window-log", level, message),
getState: () => ipcRenderer.invoke("familiaros:prompt-window-state"),
submitPrompt: (prompt) => ipcRenderer.invoke("familiaros:prompt-window-submit", prompt),
resetConversation: () => ipcRenderer.invoke("familiaros:prompt-window-reset-conversation"),
createConversation: () => ipcRenderer.invoke("familiaros:prompt-window-create-conversation"),
switchConversation: (conversationId) => ipcRenderer.invoke("familiaros:prompt-window-switch-conversation", conversationId),
deleteConversation: (conversationId) => ipcRenderer.invoke("familiaros:prompt-window-delete-conversation", conversationId),
openSettings: () => ipcRenderer.invoke("familiaros:prompt-window-open-settings"),
resizeWindow: (bounds) => ipcRenderer.invoke("familiaros:prompt-window-resize", bounds),
close: () => ipcRenderer.invoke("familiaros:prompt-window-close"),
storeKnowledgeFile: (file) => ipcRenderer.invoke("familiaros:prompt-window-store-file", file),
log: (level, message) => ipcRenderer.send("familiaros:prompt-window-log", level, message),
};
contextBridge.exposeInMainWorld("openPetsPromptWindow", api);
@ -20,7 +20,7 @@ contextBridge.exposeInMainWorld("openPetsPromptWindow", api);
let currentTtsAudio = null;
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
@ -30,7 +30,7 @@ ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
@ -46,7 +46,7 @@ ipcRenderer.on("openpets:tts-audio", (_event, payload) => {
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("openpets:tts-stop", () => {
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();

View file

@ -11,16 +11,16 @@ import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const filters = [
"@open-pets/agent-events",
"@open-pets/client",
"@open-pets/claude",
"@open-pets/cli",
"@open-pets/cursor",
"@open-pets/mcp",
"@open-pets/opencode",
"@open-pets/pet-format",
"@open-pets/pi",
"@open-pets/plugin-sdk",
"@familiaros/agent-events",
"@familiaros/client",
"@familiaros/claude",
"@familiaros/cli",
"@familiaros/cursor",
"@familiaros/mcp",
"@familiaros/opencode",
"@familiaros/familiar-format",
"@familiaros/pi",
"@familiaros/plugin-sdk",
];
const args = [];

View file

@ -2,7 +2,7 @@
## Responsibility
Build and release automation scripts for the OpenPets desktop application. Handles packaging cleanup and local release orchestration (macOS-focused).
Build and release automation scripts for the FamiliarOS desktop application. Handles packaging cleanup and local release orchestration (macOS-focused).
## Design
@ -37,7 +37,7 @@ Check preload syntax → Compile tests to .test-dist → Run behavior tests →
- **File System**: `apps/desktop/dist-electron/` (build output), `apps/desktop/dist/` (compiled JS)
- **Git**: Working tree status, remote sync verification, tag existence checks
- **GitHub**: `gh release create`, `gh release upload` to `alvinunreal/openpets`
- **GitHub**: `gh release create`, `gh release upload` to `alvinunreal/familiaros`
- **Build Tools**: `pnpm`, `electron-builder`, `node --check`
- **Node APIs**: `crypto` (SHA256), `fs`, `path`, `child_process.spawnSync`

View file

@ -9,7 +9,7 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url));
const desktopDir = resolve(scriptsDir, "..");
const repoRoot = resolve(desktopDir, "../..");
const outputDir = join(desktopDir, "dist-electron");
const repository = "alvinunreal/openpets";
const repository = "alvinunreal/familiaros";
const allowedArgs = new Set([
"--dry-run",
@ -55,7 +55,7 @@ function main() {
preflight();
if (!skipChecks) {
run("pnpm", ["build"], { cwd: repoRoot });
run("pnpm", ["--filter", "@open-pets/desktop", "check"], { cwd: repoRoot });
run("pnpm", ["--filter", "@familiaros/desktop", "check"], { cwd: repoRoot });
}
run("node", ["scripts/clean-package-output.cjs"], { cwd: desktopDir });
@ -85,7 +85,7 @@ function main() {
}
const target = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim();
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--title", `OpenPets ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--title", `FamiliarOS ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot });
run("gh", ["release", "upload", tag, "--repo", repository, ...uploadArtifacts], { cwd: repoRoot });
console.log(`\nPublished release created: https://github.com/${repository}/releases/tag/${tag}`);
console.log("Published releases are visible to the app update checker.");
@ -208,18 +208,18 @@ function run(command, args, options) {
function defaultReleaseNotes() {
return [
`OpenPets ${tag} ships the first official plugin release with polished desktop plugin management.`,
`FamiliarOS ${tag} ships the first official plugin release with polished desktop plugin management.`,
"",
"## New: OpenPets Plugins",
"## New: FamiliarOS Plugins",
"",
"OpenPets now includes a first-party plugin platform for bundled desktop companion behaviors.",
"FamiliarOS now includes a first-party plugin platform for bundled desktop companion behaviors.",
"",
"## Included plugins",
"",
"- Ambient Companion — gentle idle check-ins and ambient pet moments, enabled by default.",
"- Ambient Companion — gentle idle check-ins and ambient familiar moments, enabled by default.",
"- Break Buddy — friendly stretch, hydrate, and rest reminders, enabled by default.",
"- Pet Pal — playful pet interactions and quick companion actions, enabled by default.",
"- Focus Buddy — passive focus-session controls available from the pet menu, enabled by default.",
"- Familiar Pal — playful familiar interactions and quick companion actions, enabled by default.",
"- Focus Buddy — passive focus-session controls available from the familiar menu, enabled by default.",
"- GitHub Notifications — developer/advanced public repository release and failed-workflow notifications, disabled by default. No GitHub login, token, or private repository access is used.",
"",
"## Plugin management",
@ -227,7 +227,7 @@ function defaultReleaseNotes() {
"- New polished Plugins window with install, enable, configure, update, reload, and uninstall actions.",
"- Friendly plugin configuration UI; no JSON editing required.",
"- Plugin permissions, network hosts, and official plugin icons are explicit.",
"- JavaScript plugins run in a sandboxed renderer with a narrow OpenPets SDK.",
"- JavaScript plugins run in a sandboxed renderer with a narrow FamiliarOS SDK.",
"",
"## Developer notes",
"",

View file

@ -11,16 +11,16 @@ import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const preloadChecks = ["control-center-preload.cjs", "pet-preload.cjs", "plugin-sdk-preload.cjs", "panel-preload.cjs"];
const preloadChecks = ["control-center-preload.cjs", "familiar-preload.cjs", "plugin-sdk-preload.cjs", "panel-preload.cjs"];
const behaviorTests = [
".test-dist/tests/lease-manager.test.js",
".test-dist/tests/default-pet-external-show.test.js",
".test-dist/tests/default-familiar-external-show.test.js",
".test-dist/tests/onboarding-state.test.js",
".test-dist/tests/update-version.test.js",
".test-dist/tests/reaction-animation-mapping.test.js",
".test-dist/tests/zip-safety.test.js",
".test-dist/tests/tts-engine.test.js",
".test-dist/tests/codex-pets.test.js",
".test-dist/tests/codex-familiars.test.js",
".test-dist/tests/claude-memory.test.js",
".test-dist/tests/prompt-memory-extraction.test.js",
".test-dist/tests/knowledge-store.test.js",
@ -49,7 +49,7 @@ function run(command, args = [], options = {}) {
const child = spawn(command, args, {
stdio: "inherit",
cwd: rootDir,
env: { ...process.env, OPENPETS_DESKTOP_ROOT: rootDir },
env: { ...process.env, FAMILIAROS_DESKTOP_ROOT: rootDir },
...options,
});
child.on("close", (code) => {

View file

@ -3,8 +3,8 @@ import { BrowserWindow } from "electron";
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { debug, info } from "./logger.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
import { transientDisplayMs, type FamiliarOSReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./familiar-window.js";
const agentPetWindows = new Map<string, BrowserWindow>();
const transientDisplays = new Map<string, PetTransientDisplay>();
@ -18,11 +18,11 @@ const busyStatusBadgeMs = 120_000;
export function showAgentPet(petId: string): boolean {
if (dismissedAgentPets.has(petId)) {
info("pet.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
info("familiar.agent", "show skipped", { petId, reason: "dismissed", activeWindows: agentPetWindows.size });
return false;
}
const window = getOrCreateAgentPetWindow(petId);
info("pet.agent", "show requested", { petId, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), activeWindows: agentPetWindows.size });
info("familiar.agent", "show requested", { petId, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), activeWindows: agentPetWindows.size });
if (window.isMinimized()) window.restore();
window.showInactive();
return true;
@ -31,10 +31,10 @@ export function showAgentPet(petId: string): boolean {
export function closeAgentPetIfOpen(petId: string): void {
const window = agentPetWindows.get(petId);
if (!window || window.isDestroyed()) {
debug("pet.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
debug("familiar.agent", "close skipped", { petId, reason: "no-window", activeWindows: agentPetWindows.size });
return;
}
info("pet.agent", "close requested", { petId, windowId: window.id, activeWindows: agentPetWindows.size });
info("familiar.agent", "close requested", { petId, windowId: window.id, activeWindows: agentPetWindows.size });
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
window.setIgnoreMouseEvents(false);
@ -42,32 +42,32 @@ export function closeAgentPetIfOpen(petId: string): void {
}
export function dismissAgentPetForActiveLease(petId: string): void {
info("pet.agent", "dismiss requested", { petId });
info("familiar.agent", "dismiss requested", { petId });
dismissedAgentPets.add(petId);
closeAgentPetIfOpen(petId);
}
export function clearAgentPetDismissal(petId: string): void {
debug("pet.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
debug("familiar.agent", "dismissal cleared", { petId, wasDismissed: dismissedAgentPets.has(petId) });
dismissedAgentPets.delete(petId);
}
export function clearAgentPetLeaseState(petId: string): void {
info("pet.agent", "lease state cleared", { petId, hadWindow: agentPetWindows.has(petId), wasDismissed: dismissedAgentPets.has(petId) });
info("familiar.agent", "lease state cleared", { petId, hadWindow: agentPetWindows.has(petId), wasDismissed: dismissedAgentPets.has(petId) });
dismissedAgentPets.delete(petId);
closeAgentPetIfOpen(petId);
clearAgentDisplay(petId);
}
export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "reaction apply", { petId, reaction });
export function applyAgentPetReaction(petId: string, reaction: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
debug("familiar.agent", "reaction apply", { petId, reaction });
setAgentDisplay(petId, { reaction });
const shown = showAgentPet(petId);
return shown ? { shown } : { shown, reason: "dismissed" };
}
export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
debug("pet.agent", "say apply", { petId, reaction, messageLength: message.length });
export function applyAgentPetSay(petId: string, message: string, reaction?: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
debug("familiar.agent", "say apply", { petId, reaction, messageLength: message.length });
if (!reaction) clearStatusBadge(petId);
setAgentDisplay(petId, { message, reaction });
const shown = showAgentPet(petId);
@ -75,7 +75,7 @@ export function applyAgentPetSay(petId: string, message: string, reaction?: Open
}
export function closeAllAgentPets(): void {
info("pet.agent", "close all requested", { activeWindows: agentPetWindows.size });
info("familiar.agent", "close all requested", { activeWindows: agentPetWindows.size });
for (const petId of [...agentPetWindows.keys()]) {
closeAgentPetIfOpen(petId);
}
@ -83,7 +83,7 @@ export function closeAllAgentPets(): void {
}
export function refreshAgentPetContent(): void {
debug("pet.agent", "refresh all content", { activeWindows: agentPetWindows.size, petIds: [...agentPetWindows.keys()] });
debug("familiar.agent", "refresh all content", { activeWindows: agentPetWindows.size, petIds: [...agentPetWindows.keys()] });
const scale = getPreferredPetScale();
for (const [petId, window] of agentPetWindows.entries()) {
if (!window.isDestroyed()) {
@ -96,9 +96,9 @@ export function refreshAgentPetContent(): void {
function handleBubbleDismissed(petId: string, dismissToken: string): void {
const currentGeneration = displayGenerations.get(petId) ?? 0;
debug("pet.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
debug("familiar.agent", "bubble dismissed callback", { petId, windowId: agentPetWindows.get(petId)?.id, dismissToken, currentGeneration });
if (dismissToken !== String(currentGeneration)) {
debug("pet.agent", "bubble dismissed stale token", { petId, dismissToken, currentGeneration });
debug("familiar.agent", "bubble dismissed stale token", { petId, dismissToken, currentGeneration });
return;
}
clearAgentDisplay(petId);
@ -111,21 +111,21 @@ function handleBubbleDismissed(petId: string, dismissToken: string): void {
function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
const existing = agentPetWindows.get(petId);
if (existing && !existing.isDestroyed()) {
debug("pet.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
debug("familiar.agent", "reuse existing window", { petId, windowId: existing.id, activeWindows: agentPetWindows.size });
return existing;
}
const state = getAppStateSnapshot();
const scale = state.preferences.petScale as PetScaleValue;
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet) throw new Error(`Installed pet is unavailable: ${petId}`);
const familiar = state.familiars.installed.find((candidate) => candidate.id === petId);
if (!familiar) throw new Error(`Installed familiar is unavailable: ${petId}`);
const offset = agentPetWindows.size + 1;
const initial = getDefaultPetInitialPosition(defaultPetWindowSize);
const display = transientDisplays.get(petId) ?? null;
const badge = statusBadges.get(petId) ?? null;
const window = createAgentPetWindow({
petId,
displayName: pet.displayName,
displayName: familiar.displayName,
scale,
position: { x: initial.x - offset * 36, y: initial.y - offset * 24 },
display,
@ -137,17 +137,17 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
const windowId = window.id;
window.on("closed", () => {
info("pet.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
info("familiar.agent", "closed", { petId, windowId, activeWindowsBeforeDelete: agentPetWindows.size });
agentPetWindows.delete(petId);
clearAgentDisplay(petId);
});
agentPetWindows.set(petId, window);
info("pet.agent", "created", { petId, windowId: window.id, offset, activeWindows: agentPetWindows.size, position: { x: initial.x - offset * 36, y: initial.y - offset * 24 } });
info("familiar.agent", "created", { petId, windowId: window.id, offset, activeWindows: agentPetWindows.size, position: { x: initial.x - offset * 36, y: initial.y - offset * 24 } });
return window;
}
function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
debug("pet.agent", "display set", { petId, reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
debug("familiar.agent", "display set", { petId, reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
const nextGeneration = (displayGenerations.get(petId) ?? 0) + 1;
displayGenerations.set(petId, nextGeneration);
const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, { ...display, dismissToken: String(nextGeneration) });
@ -191,7 +191,7 @@ function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
}
function clearAgentDisplay(petId: string): void {
debug("pet.agent", "display cleared", { petId, hadDisplay: transientDisplays.has(petId), hadBadge: statusBadges.has(petId) });
debug("familiar.agent", "display cleared", { petId, hadDisplay: transientDisplays.has(petId), hadBadge: statusBadges.has(petId) });
const timer = transientTimers.get(petId);
if (timer) clearTimeout(timer);
const animationTimer = transientAnimationTimers.get(petId);
@ -216,14 +216,14 @@ function clearAllAgentDisplayTimers(): void {
statusBadges.clear();
}
function setStatusBadge(petId: string, reaction: OpenPetsReaction): void {
function setStatusBadge(petId: string, reaction: FamiliarOSReaction): void {
if (reaction === "idle") {
clearStatusBadge(petId);
return;
}
statusBadges.set(petId, reaction);
debug("pet.agent", "status badge set", { petId, reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
debug("familiar.agent", "status badge set", { petId, reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
const existingTimer = statusBadgeTimers.get(petId);
if (existingTimer) clearTimeout(existingTimer);
const timer = setTimeout(() => {
@ -238,14 +238,14 @@ function setStatusBadge(petId: string, reaction: OpenPetsReaction): void {
}
function clearStatusBadge(petId: string): void {
if (statusBadges.has(petId)) debug("pet.agent", "status badge cleared", { petId, reaction: statusBadges.get(petId) });
if (statusBadges.has(petId)) debug("familiar.agent", "status badge cleared", { petId, reaction: statusBadges.get(petId) });
statusBadges.delete(petId);
const timer = statusBadgeTimers.get(petId);
if (timer) clearTimeout(timer);
statusBadgeTimers.delete(petId);
}
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}
@ -254,11 +254,11 @@ function getCurrentDismissToken(petId: string, display: PetTransientDisplay | nu
}
function handleAgentScaleChanged(scale: number): void {
debug("pet.agent", "scale changed", { scale });
debug("familiar.agent", "scale changed", { scale });
import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}).catch((error) => {
console.error("Failed to update pet scale preference.", error);
console.error("Failed to update familiar scale preference.", error);
});
}

View file

@ -4,13 +4,13 @@ import { dirname, isAbsolute, join } from "node:path";
import { createRequire } from "node:module";
import { app } from "electron";
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, buildOpenPetsMcpServerCommand, classifyClaudeMcpStatus, createOpenPetsHookSettingsPreview, doctorClaudeHooks, formatCommandForDisplay, installClaudeHooks, mapAsarPathToUnpacked, uninstallClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type OpenPetsCommandMode, type ParsedClaudeMcpEntry } from "@open-pets/claude";
import { buildCursorRulesPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult } from "@open-pets/cursor";
import { buildOpenPetsOnlyPreview, type RedactedPreview } from "@open-pets/cursor";
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, buildFamiliarOSMcpServerCommand, classifyClaudeMcpStatus, createFamiliarOSHookSettingsPreview, doctorClaudeHooks, formatCommandForDisplay, installClaudeHooks, mapAsarPathToUnpacked, uninstallClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
import { buildCursorRulesPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult } from "@familiaros/cursor";
import { buildFamiliarOSOnlyPreview, type RedactedPreview } from "@familiaros/cursor";
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type OpenPetsStateV1 } from "./app-state.js";
import { doctorClaudeOpenPetsMemory, installClaudeOpenPetsMemory, uninstallClaudeOpenPetsMemory, type ClaudeOpenPetsMemoryStatus } from "./claude-memory.js";
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type FamiliarOSStateV1 } from "./app-state.js";
import { doctorClaudeFamiliarOSMemory, installClaudeFamiliarOSMemory, uninstallClaudeFamiliarOSMemory, type ClaudeFamiliarOSMemoryStatus } from "./claude-memory.js";
export type AgentSetupAction = "configure" | "replace" | "remove" | "install-memory" | "doctor-hooks" | "install-hooks" | "uninstall-hooks" | "opencode-install" | "opencode-remove" | "cursor-install" | "cursor-replace" | "cursor-remove";
export type JournalAction = "configure" | "update" | "replace" | "remove";
@ -36,13 +36,13 @@ export interface ClaudeCodeStatus {
export interface AgentSetupSnapshot {
readonly selectedPetId?: string;
readonly commandMode: OpenPetsCommandMode;
readonly commandMode: FamiliarOSCommandMode;
readonly localDevAvailable: boolean;
readonly petOptions: readonly AgentSetupPetOption[];
readonly preview: ClaudeMcpPreview;
readonly status: ClaudeCodeStatus;
readonly hookStatus: ClaudeHookDoctorResult;
readonly memoryStatus: ClaudeOpenPetsMemoryStatus;
readonly memoryStatus: ClaudeFamiliarOSMemoryStatus;
readonly opencodeStatus: OpenCodeSetupStatus;
readonly opencodePreview: OpenCodeSetupPreview;
readonly cursorStatus: CursorSetupStatus;
@ -136,7 +136,7 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandMode
const status = preview.error ? createBundledResourceErrorStatus(preview.error) : await detectClaudeCodeStatus(petId, commandMode);
const rawHookStatus = preview.error ? createHookErrorStatus(preview.error) : safeDoctorClaudeHooks(commandMode, petId);
const hookStatus = { ...rawHookStatus, settingsPath: formatUserPath(rawHookStatus.settingsPath) ?? rawHookStatus.settingsPath, backupPath: formatUserPath(rawHookStatus.backupPath) };
const rawMemoryStatus = doctorClaudeOpenPetsMemory(app.getPath("home"));
const rawMemoryStatus = doctorClaudeFamiliarOSMemory(app.getPath("home"));
const memoryStatus = { ...rawMemoryStatus, claudeMdPath: formatUserPath(rawMemoryStatus.claudeMdPath) ?? rawMemoryStatus.claudeMdPath, openPetsMemoryPath: formatUserPath(rawMemoryStatus.openPetsMemoryPath) ?? rawMemoryStatus.openPetsMemoryPath };
const opencode = await getOpenCodeSetup(commandMode, petId);
const cursor = await getCursorSetup(commandMode, petId);
@ -165,7 +165,7 @@ export function updateAgentSetupCommandPaths(patch: unknown): AgentSetupCommandP
for (const key of Object.keys(patch)) {
if (key !== "claude" && key !== "node" && key !== "opencode") throw new Error("Invalid command path setting.");
}
const updates: Writable<Partial<OpenPetsStateV1["preferences"]>> = {};
const updates: Writable<Partial<FamiliarOSStateV1["preferences"]>> = {};
if ("claude" in patch) updates.claudeCommandPath = normalizeOptionalCommandPath(patch.claude, "Claude");
if ("node" in patch) updates.nodeCommandPath = normalizeOptionalCommandPath(patch.node, "Node.js");
if ("opencode" in patch) updates.opencodeCommandPath = normalizeOptionalCommandPath(patch.opencode, "OpenCode");
@ -201,30 +201,30 @@ export function sanitizeAgentSetupOutput(value: string): string {
.slice(0, 500);
}
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
try {
return { preview: withPreferredClaudeCommand(buildClaudeMcpPreview(selectedPetId, commandMode, getPreferredNodeCommand())) };
} catch (error) {
const message = error instanceof Error ? error.message : "Packaged OpenPets command resources are unavailable.";
const message = error instanceof Error ? error.message : "Packaged FamiliarOS command resources are unavailable.";
return { preview: createErrorPreview(commandMode, message), error: message };
}
}
function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
function safeDoctorClaudeHooks(commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
try {
return doctorClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
} catch (error) {
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable.");
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged FamiliarOS hook resources are unavailable.");
}
}
function createErrorPreview(commandMode: OpenPetsCommandMode, message: string): ClaudeMcpPreview {
function createErrorPreview(commandMode: FamiliarOSCommandMode, message: string): ClaudeMcpPreview {
const claude = getPreferredClaudeCommand();
return {
commandMode,
add: { command: claude, args: [] },
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "openpets"] },
mcpJson: { mcpServers: { openpets: { type: "stdio", command: "node", args: [] } } },
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "familiaros"] },
mcpJson: { mcpServers: { familiaros: { type: "stdio", command: "node", args: [] } } },
displayCommand: message,
};
}
@ -248,7 +248,7 @@ function createHookErrorStatus(message: string): ClaudeHookDoctorResult {
return { status: "error", settingsPath: "~/.claude/settings.json", exists: false, valid: false, message, preview: {}, asyncSupported: false };
}
async function runAction(action: AgentSetupAction, selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
async function runAction(action: AgentSetupAction, selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<AgentSetupActionResult> {
if (action === "opencode-install") return installOpenCodeGlobal(selectedPetId, commandMode);
if (action === "opencode-remove") return removeOpenCodeGlobal();
if (action === "cursor-install") return installCursorGlobal(selectedPetId, commandMode);
@ -264,10 +264,10 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef
try {
result = uninstallClaudeHooks(undefined, commandMode);
} catch (error) {
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook uninstall failed.", changed: false };
return { ok: false, action, message: error instanceof Error ? error.message : "FamiliarOS hook uninstall failed.", changed: false };
}
const message = result.changed ? `Uninstalled OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
writeActionJournal({ action: "remove", selectedPetId, command: ["open-pets-claude", "uninstall-hooks"], previousStatus: result.status, success: result.status !== "error", message });
const message = result.changed ? `Uninstalled FamiliarOS Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
writeActionJournal({ action: "remove", selectedPetId, command: ["familiaros-claude", "uninstall-hooks"], previousStatus: result.status, success: result.status !== "error", message });
return { ok: result.status !== "error", action, message, changed: result.changed };
}
if (action === "install-memory") {
@ -279,7 +279,7 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef
}
if (commandMode === "bundled") {
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return { ok: false, action, message: `Node.js is required for packaged OpenPets commands. Open Claude configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
if (!node.ok) return { ok: false, action, message: `Node.js is required for packaged FamiliarOS commands. Open Claude configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
}
const previewResult = safeBuildClaudeMcpPreview(selectedPetId, commandMode);
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
@ -289,9 +289,9 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef
try {
result = installClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
} catch (error) {
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook install failed.", changed: false };
return { ok: false, action, message: error instanceof Error ? error.message : "FamiliarOS hook install failed.", changed: false };
}
const message = result.changed ? `Installed OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
const message = result.changed ? `Installed FamiliarOS Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("install-hooks", selectedPetId), previousStatus: result.status, success: result.status !== "error", message });
return { ok: result.status !== "error", action, message, changed: result.changed };
}
@ -308,11 +308,11 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef
if (action === "configure") {
if (detection.openPetsEntry.present && detection.openPetsEntry.verified && detection.openPetsEntry.matchesExpected) {
const memoryResult = safeInstallClaudeMemory();
const message = `OpenPets MCP is already configured for Claude Code.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`;
const message = `FamiliarOS MCP is already configured for Claude Code.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`;
return { ok: true, action, message, changed: memoryResult.ok && memoryResult.message.startsWith("Added") };
}
if (detection.openPetsEntry.present) {
return { ok: false, action, message: "Claude already has an openpets MCP entry. OpenPets will keep it as installed; use Replace only if you want to recreate it with the recommended command.", changed: false };
return { ok: false, action, message: "Claude already has an familiaros MCP entry. FamiliarOS will keep it as installed; use Replace only if you want to recreate it with the recommended command.", changed: false };
}
return runAdd(preview, selectedPetId, previousStatus, action);
}
@ -328,14 +328,14 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef
return {
ok: false,
action,
message: `${added.message} The previous openpets entry was removed; use this command to restore the intended entry: ${preview.displayCommand}`,
message: `${added.message} The previous familiaros entry was removed; use this command to restore the intended entry: ${preview.displayCommand}`,
changed: true,
};
}
return { ok: true, action, message: `Replaced Claude Code OpenPets MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`, changed: true };
return { ok: true, action, message: `Replaced Claude Code FamiliarOS MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`, changed: true };
}
async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
async function getOpenCodeSetup(commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const petId = selectedPetId || undefined;
const cliVersion = getCliPackageVersion();
@ -360,14 +360,14 @@ async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, selectedPetId:
configPath: prepared.ok ? (formatUserPath(prepared.configPath) ?? prepared.configPath) : "",
cleanupConfigPaths: prepared.ok ? prepared.cleanupConfigPaths.map((path) => formatUserPath(path) ?? path) : [],
mcpCommand: prepared.ok ? prepared.command : [],
plugin: prepared.ok ? prepared.plugin : (petId ? [`@open-pets/opencode@${pluginVersion}`, { pet: petId }] : `@open-pets/opencode@${pluginVersion}`),
plugin: prepared.ok ? prepared.plugin : (petId ? [`@familiaros/opencode@${pluginVersion}`, { familiar: petId }] : `@familiaros/opencode@${pluginVersion}`),
instructionPath: prepared.ok ? (formatUserPath(prepared.instructionPath) ?? prepared.instructionPath) : "",
configPreview: prepared.ok ? prepared.configPreview : {},
},
};
}
async function getCursorSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
async function getCursorSetup(commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
const homeDir = app.getPath("home");
const configPath = getCursorGlobalMcpPath(homeDir);
const petId = selectedPetId || undefined;
@ -393,8 +393,8 @@ async function getCursorSetup(commandMode: OpenPetsCommandMode, selectedPetId: s
preview: {
global: true,
configPath: formatUserPath(configPath) ?? configPath,
mcpEntry: buildOpenPetsOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
rulesPath: ".cursor/rules/openpets.mdc",
mcpEntry: buildFamiliarOSOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
rulesPath: ".cursor/rules/familiaros.mdc",
rulesContent: buildCursorRulesPreview(),
commandMode: "published",
},
@ -480,29 +480,29 @@ function quoteCommandForDisplay(command: string): string {
return /\s/.test(command) ? JSON.stringify(command) : command;
}
function safePrepareOpenCode(configDir: string, selectedPetId: string | undefined, cliVersion: string, pluginVersion: string, commandMode: OpenPetsCommandMode, cliEntryPath: string | undefined): { readonly ok: true; readonly command: readonly string[]; readonly configPath: string; readonly cleanupConfigPaths: readonly string[]; readonly instructionPath: string; readonly plugin: readonly unknown[] | string; readonly configPreview: Record<string, unknown> } | { readonly ok: false; readonly message: string } {
function safePrepareOpenCode(configDir: string, selectedPetId: string | undefined, cliVersion: string, pluginVersion: string, commandMode: FamiliarOSCommandMode, cliEntryPath: string | undefined): { readonly ok: true; readonly command: readonly string[]; readonly configPath: string; readonly cleanupConfigPaths: readonly string[]; readonly instructionPath: string; readonly plugin: readonly unknown[] | string; readonly configPreview: Record<string, unknown> } | { readonly ok: false; readonly message: string } {
try {
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion, pluginVersion, commandMode, cliEntryPath });
const parsed = parseOpenCodeConfig(prepared.configWrite.content);
if (!parsed.ok) return { ok: false, message: parsed.message };
const config = parsed.value as { mcp?: { openpets?: { command?: readonly string[] } }; plugin?: readonly unknown[] };
const config = parsed.value as { mcp?: { familiaros?: { command?: readonly string[] } }; plugin?: readonly unknown[] };
const plugin = Array.isArray(config.plugin) ? config.plugin[config.plugin.length - 1] : undefined;
return { ok: true, command: config.mcp?.openpets?.command ?? [], configPath: prepared.configPath, cleanupConfigPaths: prepared.cleanupConfigWrites.map((write) => write.targetPath), instructionPath: prepared.instructionPath, plugin: plugin === undefined ? [] : (plugin as readonly unknown[] | string), configPreview: parsed.value };
return { ok: true, command: config.mcp?.familiaros?.command ?? [], configPath: prepared.configPath, cleanupConfigPaths: prepared.cleanupConfigWrites.map((write) => write.targetPath), instructionPath: prepared.instructionPath, plugin: plugin === undefined ? [] : (plugin as readonly unknown[] | string), configPreview: parsed.value };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "OpenCode setup preview failed." };
}
}
async function installOpenCodeGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
async function installOpenCodeGlobal(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<AgentSetupActionResult> {
if (commandMode === "bundled") {
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return { ok: false, action: "opencode-install", message: `Node.js is required for packaged OpenPets commands. Open OpenCode configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
if (!node.ok) return { ok: false, action: "opencode-install", message: `Node.js is required for packaged FamiliarOS commands. Open OpenCode configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
}
try {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion: getCliPackageVersion(), pluginVersion: getOpenCodePackageVersion(), commandMode, cliEntryPath: commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode) });
writePreparedOpenCodeGlobalSetup(prepared);
return { ok: true, action: "opencode-install", message: `Installed global OpenCode OpenPets setup. Config: ${formatUserPath(prepared.configPath) ?? prepared.configPath}. Instructions: ${formatUserPath(prepared.instructionPath) ?? prepared.instructionPath}.`, changed: true };
return { ok: true, action: "opencode-install", message: `Installed global OpenCode FamiliarOS setup. Config: ${formatUserPath(prepared.configPath) ?? prepared.configPath}. Instructions: ${formatUserPath(prepared.instructionPath) ?? prepared.instructionPath}.`, changed: true };
} catch (error) {
return { ok: false, action: "opencode-install", message: error instanceof Error ? error.message : "OpenCode setup failed.", changed: false };
}
@ -513,13 +513,13 @@ async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
const prepared = prepareOpenCodeGlobalRemove(configDir);
writePreparedOpenCodeGlobalRemove(prepared);
return { ok: true, action: "opencode-remove", message: prepared.configWrites.length > 0 ? "Removed global OpenCode OpenPets setup." : "Global OpenCode OpenPets setup was already absent.", changed: prepared.configWrites.length > 0 };
return { ok: true, action: "opencode-remove", message: prepared.configWrites.length > 0 ? "Removed global OpenCode FamiliarOS setup." : "Global OpenCode FamiliarOS setup was already absent.", changed: prepared.configWrites.length > 0 };
} catch (error) {
return { ok: false, action: "opencode-remove", message: error instanceof Error ? error.message : "OpenCode removal failed.", changed: false };
}
}
async function installCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
async function installCursorGlobal(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<AgentSetupActionResult> {
void commandMode;
try {
const homeDir = app.getPath("home");
@ -532,7 +532,7 @@ async function installCursorGlobal(selectedPetId: string | undefined, commandMod
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-install", message: `Installed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
return { ok: true, action: "cursor-install", message: `Installed Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-install", message: "Failed to plan Cursor MCP install.", changed: false };
} catch (error) {
@ -540,7 +540,7 @@ async function installCursorGlobal(selectedPetId: string | undefined, commandMod
}
}
async function replaceCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
async function replaceCursorGlobal(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<AgentSetupActionResult> {
void commandMode;
try {
const homeDir = app.getPath("home");
@ -553,7 +553,7 @@ async function replaceCursorGlobal(selectedPetId: string | undefined, commandMod
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
return { ok: true, action: "cursor-replace", message: `Replaced Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
return { ok: true, action: "cursor-replace", message: `Replaced Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-replace", message: "Failed to plan Cursor MCP replace.", changed: false };
} catch (error) {
@ -571,7 +571,7 @@ async function removeCursorGlobal(): Promise<AgentSetupActionResult> {
}
if ("targetPath" in plan) {
executeCursorMcpWrite(plan);
return { ok: true, action: "cursor-remove", message: `Removed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}. Cursor may need to be restarted or reloaded.`, changed: true };
return { ok: true, action: "cursor-remove", message: `Removed Cursor FamiliarOS MCP config at ${formatUserPath(configPath) ?? configPath}. Cursor may need to be restarted or reloaded.`, changed: true };
}
return { ok: false, action: "cursor-remove", message: "Failed to plan Cursor MCP remove.", changed: false };
} catch (error) {
@ -579,17 +579,17 @@ async function removeCursorGlobal(): Promise<AgentSetupActionResult> {
}
}
function getDesktopCliEntryPath(commandMode: OpenPetsCommandMode): string {
const path = require.resolve("@open-pets/cli");
function getDesktopCliEntryPath(commandMode: FamiliarOSCommandMode): string {
const path = require.resolve("@familiaros/cli");
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
}
function getCliPackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/cli");
return getWorkspacePackageVersion("@familiaros/cli");
}
function getOpenCodePackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/opencode");
return getWorkspacePackageVersion("@familiaros/opencode");
}
function getWorkspacePackageVersion(packageName: string): string {
@ -604,17 +604,17 @@ function getWorkspacePackageVersion(packageName: string): string {
}
function getMcpPackageVersion(): string {
return getWorkspacePackageVersion("@open-pets/mcp");
return getWorkspacePackageVersion("@familiaros/mcp");
}
export interface OpenPetsMcpServerPreview {
readonly commandMode: OpenPetsCommandMode;
export interface FamiliarOSMcpServerPreview {
readonly commandMode: FamiliarOSCommandMode;
readonly command: string;
readonly args: readonly string[];
readonly displayCommand: string;
readonly mcpJson: {
readonly mcpServers: {
readonly openpets: {
readonly familiaros: {
readonly type: "stdio";
readonly command: string;
readonly args: readonly string[];
@ -623,15 +623,15 @@ export interface OpenPetsMcpServerPreview {
};
}
export interface OpenPetsMcpServerHealth {
export interface FamiliarOSMcpServerHealth {
readonly ok: boolean;
readonly output: string;
readonly error?: string;
}
export function buildOpenPetsMcpServerPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): OpenPetsMcpServerPreview {
export function buildFamiliarOSMcpServerPreview(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): FamiliarOSMcpServerPreview {
const nodeCommand = getPreferredNodeCommand();
const { command, args } = buildOpenPetsMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const { command, args } = buildFamiliarOSMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const displayCommand = formatCommandForDisplay({ command, args });
return {
commandMode,
@ -640,15 +640,15 @@ export function buildOpenPetsMcpServerPreview(selectedPetId: string | undefined,
displayCommand,
mcpJson: {
mcpServers: {
openpets: { type: "stdio", command, args },
familiaros: { type: "stdio", command, args },
},
},
};
}
export async function testOpenPetsMcpServer(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<OpenPetsMcpServerHealth> {
export async function testFamiliarOSMcpServer(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<FamiliarOSMcpServerHealth> {
const nodeCommand = getPreferredNodeCommand();
const { command, args } = buildOpenPetsMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const { command, args } = buildFamiliarOSMcpServerCommand(selectedPetId, commandMode, nodeCommand);
const result = await runCommand({ command, args: [...args, "--version"] });
if (result.ok) {
return { ok: true, output: result.stdout.trim() || "MCP server responded." };
@ -658,19 +658,19 @@ export async function testOpenPetsMcpServer(selectedPetId: string | undefined, c
}
function summarizeMemoryMessages(...messages: readonly string[]): string {
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:OpenPets )?instructions[^.]*\./g) ?? []);
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:FamiliarOS )?instructions[^.]*\./g) ?? []);
return memoryMessages.length > 0 ? ` ${memoryMessages.join(" ")}` : "";
}
function createHookJournalCommand(command: "doctor-hooks" | "install-hooks", selectedPetId: string | undefined): readonly string[] {
return selectedPetId ? ["open-pets-claude", command, "--pet", selectedPetId] : ["open-pets-claude", command];
return selectedPetId ? ["familiaros-claude", command, "--familiar", selectedPetId] : ["familiaros-claude", command];
}
async function runAdd(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
const result = await runClaudeCommand(preview.add);
const memoryResult = result.ok ? safeInstallClaudeMemory() : { ok: false as const, message: "" };
const message = result.ok
? `Configured Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
? `Configured Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP add failed: ${summarizeCommandResult(result)}`;
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: result.ok, message });
return { ok: result.ok, action, message, changed: result.ok };
@ -680,7 +680,7 @@ async function runRemove(preview: ClaudeMcpPreview, selectedPetId: string | unde
const result = await runClaudeCommand(preview.remove);
const memoryResult = result.ok ? safeUninstallClaudeMemory() : { ok: false as const, message: "" };
const message = result.ok
? `Removed Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
? `Removed Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
: `Claude MCP remove failed: ${summarizeCommandResult(result)}`;
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.remove.command, ...preview.remove.args], previousStatus, success: result.ok, message });
return { ok: result.ok, action, message, changed: result.ok };
@ -688,8 +688,8 @@ async function runRemove(preview: ClaudeMcpPreview, selectedPetId: string | unde
function safeInstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
try {
const result = installClaudeOpenPetsMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Added Claude OpenPets instructions." : "Claude OpenPets instructions already present." };
const result = installClaudeFamiliarOSMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Added Claude FamiliarOS instructions." : "Claude FamiliarOS instructions already present." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
@ -697,17 +697,17 @@ function safeInstallClaudeMemory(): { readonly ok: true; readonly message: strin
function safeUninstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
try {
const result = uninstallClaudeOpenPetsMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Removed Claude OpenPets instructions." : "Claude OpenPets instructions were already absent." };
const result = uninstallClaudeFamiliarOSMemory(app.getPath("home"));
return { ok: true, message: result.changed ? "Removed Claude FamiliarOS instructions." : "Claude FamiliarOS instructions were already absent." };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
}
}
async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<ClaudeCodeStatus> {
async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<ClaudeCodeStatus> {
if (commandMode === "bundled") {
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
if (!node.ok) return createStatus("error", "Node required", `Node.js is required for packaged OpenPets commands. Open Claude configuration, expand Advanced detection, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
if (!node.ok) return createStatus("error", "Node required", `Node.js is required for packaged FamiliarOS commands. Open Claude configuration, expand Advanced detection, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
}
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
@ -728,10 +728,10 @@ async function detectClaudeCodeStatus(selectedPetId: string | undefined, command
if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode, getPreferredNodeCommand());
}
if (!entry.present) return createStatus("needs_setup", "Needs setup", "Claude Code is detected, but OpenPets MCP is not configured.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified && entry.matchesExpected) return createStatus("configured", "Configured", "Claude Code has the expected OpenPets MCP entry.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified) return createStatus("configured", "Installed — custom", "Claude Code has an openpets MCP entry with a custom command. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
return createStatus("configured", "Installed — unverified", "Claude Code lists an openpets MCP entry, but command details were not available. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (!entry.present) return createStatus("needs_setup", "Needs setup", "Claude Code is detected, but FamiliarOS MCP is not configured.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified && entry.matchesExpected) return createStatus("configured", "Configured", "Claude Code has the expected FamiliarOS MCP entry.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
if (entry.verified) return createStatus("configured", "Installed — custom", "Claude Code has an familiaros MCP entry with a custom command. FamiliarOS will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
return createStatus("configured", "Installed — unverified", "Claude Code lists an familiaros MCP entry, but command details were not available. FamiliarOS will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
}
async function runClaudeCommandWithTimeoutRetry(spec: ClaudeCommandSpec): Promise<CommandResult> {
@ -759,24 +759,24 @@ function createStatus(state: ClaudeCodeStatus["state"], label: string, details:
function validateSelectedPetId(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw new Error("Invalid selected pet id.");
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === value);
if (!pet || pet.broken) throw new Error("Selected pet is not installed or is broken.");
return pet.id;
if (typeof value !== "string") throw new Error("Invalid selected familiar id.");
const familiar = getAppStateSnapshot().familiars.installed.find((candidate) => candidate.id === value);
if (!familiar || familiar.broken) throw new Error("Selected familiar is not installed or is broken.");
return familiar.id;
}
function validateCommandMode(value: unknown): OpenPetsCommandMode {
function validateCommandMode(value: unknown): FamiliarOSCommandMode {
if (app.isPackaged) return "bundled";
return value === "local" ? "local" : "published";
}
function getPetOptions(): readonly AgentSetupPetOption[] {
const state = getAppStateSnapshot();
return state.pets.installed.filter(isUsablePet).map((pet) => ({ id: pet.id, displayName: pet.displayName, default: pet.id === state.preferences.defaultPetId }));
return state.familiars.installed.filter(isUsablePet).map((familiar) => ({ id: familiar.id, displayName: familiar.displayName, default: familiar.id === state.preferences.defaultPetId }));
}
function isUsablePet(pet: InstalledPetState): boolean {
return pet.installed && !pet.broken && !pet.builtIn;
function isUsablePet(familiar: InstalledPetState): boolean {
return familiar.installed && !familiar.broken && !familiar.builtIn;
}
async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
@ -918,7 +918,7 @@ function writeActionJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & {
writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
} catch (error) {
console.error("Failed to write OpenPets agent setup action journal.", error);
console.error("Failed to write FamiliarOS agent setup action journal.", error);
}
}
@ -949,5 +949,5 @@ function journalActionFor(action: AgentSetupAction): JournalAction {
export const agentSetupInternalsForChecks = {
sanitizeAgentSetupOutput,
createOpenPetsHookSettingsPreview,
createFamiliarOSHookSettingsPreview,
};

View file

@ -4,11 +4,11 @@ import { dirname, isAbsolute, join } from "node:path";
import { app } from "electron";
import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
import { builtInPet } from "./built-in-pet.js";
import { builtInPet } from "./built-in-familiar.js";
import type { Point } from "./display.js";
import { isSupportedLocale, type LocalePreference } from "./i18n/catalog.js";
import { allowedReactions, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { allowedReactions, type FamiliarOSReaction } from "./local-ipc-protocol.js";
import { assertSafePetId, getInstalledPetDir } from "./familiar-paths.js";
import { publishPluginAgentActivity } from "./plugin-events-source.js";
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
@ -32,7 +32,7 @@ export interface InstalledPetState {
readonly brokenReason?: string;
}
export interface OpenPetsStateV1 {
export interface FamiliarOSStateV1 {
readonly version: 1;
readonly preferences: {
readonly defaultPetId: string;
@ -57,48 +57,77 @@ export interface OpenPetsStateV1 {
readonly ttsModel?: string;
readonly ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom";
readonly ttsEndpoint?: string;
readonly familiarName?: string;
};
readonly pets: {
readonly familiars: {
readonly installed: readonly InstalledPetState[];
};
readonly defaultPet: {
readonly position?: Point;
};
readonly analytics: OpenPetsAnalyticsState;
readonly analytics: FamiliarOSAnalyticsState;
}
export interface OpenPetsAnalyticsState {
export interface FamiliarOSAnalyticsState {
readonly messagesSent: number;
readonly reactionsSent: number;
readonly reactionCounts: Record<OpenPetsReaction, number>;
readonly reactionCounts: Record<FamiliarOSReaction, number>;
readonly perPetActivityCounts: Record<string, number>;
readonly lastActivityAt?: number;
}
export type OpenPetsActivityRecord =
| { readonly kind: "say"; readonly reaction?: OpenPetsReaction; readonly petId?: string }
| { readonly kind: "react"; readonly reaction: OpenPetsReaction; readonly petId?: string };
export type FamiliarOSActivityRecord =
| { readonly kind: "say"; readonly reaction?: FamiliarOSReaction; readonly petId?: string }
| { readonly kind: "react"; readonly reaction: FamiliarOSReaction; readonly petId?: string };
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 stateFileName = "familiaros-state.json";
const directInstallLockName = ".install-familiar.lock";
const directInstallLockStaleMs = 10 * 60 * 1000;
let statePath: string | null = null;
let currentState: OpenPetsStateV1 | null = null;
let currentState: FamiliarOSStateV1 | null = null;
let startupInstallLockPath: string | null = null;
const legacyFileMigrations: Record<string, string> = {
"openpets-state.json": "familiaros-state.json",
"openpets-openapi-chat.json": "familiaros-openapi-chat.json",
"openpets-openai-chat.json": "familiaros-openai-chat.json",
"openpets-chat-history.json": "familiaros-chat-history.json",
"openpets-chat-conversations.json": "familiaros-chat-conversations.json",
"openpets-memory.json": "familiaros-memory.json",
"openpets-plugin-platform.json": "familiaros-plugin-platform.json",
"openpets-plugin-state.json": "familiaros-plugin-state.json",
"openpets-tts-credential.json": "familiaros-tts-credential.json",
};
function migrateLegacyFilenames(userDataPath: string): void {
for (const [oldName, newName] of Object.entries(legacyFileMigrations)) {
const oldPath = join(userDataPath, oldName);
const newPath = join(userDataPath, newName);
if (existsSync(oldPath) && !existsSync(newPath)) {
try {
renameSync(oldPath, newPath);
console.log(`Migrated legacy state file: ${oldName} -> ${newName}`);
} catch (error) {
console.error(`Failed to migrate ${oldName}:`, error);
}
}
}
}
export function initializeAppState(): void {
const userDataPath = app.getPath("userData");
migrateLegacyFilenames(userDataPath);
startupInstallLockPath = acquireStartupInstallLock(userDataPath);
statePath = join(userDataPath, stateFileName);
const nextState = normalizeState(readStateFile(statePath));
writeStateToDisk(nextState);
currentState = nextState;
console.log(`OpenPets state initialized at ${statePath}.`);
console.log(`FamiliarOS state initialized at ${statePath}.`);
}
export function releaseStartupInstallLock(): void {
@ -107,11 +136,11 @@ export function releaseStartupInstallLock(): void {
if (lockPath) rmSync(lockPath, { recursive: true, force: true });
}
export function getAppStateSnapshot(): OpenPetsStateV1 {
export function getAppStateSnapshot(): FamiliarOSStateV1 {
return cloneState(getInitializedState());
}
export function updatePreferences(patch: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1 {
export function updatePreferences(patch: Partial<FamiliarOSStateV1["preferences"]>): FamiliarOSStateV1 {
const state = getInitializedState();
const preferences = normalizePreferences({ ...state.preferences, ...patch });
@ -128,23 +157,23 @@ export function isOnboardingCompleted(): boolean {
return getInitializedState().preferences.onboardingCompleted;
}
export function completeOnboarding(): OpenPetsStateV1 {
export function completeOnboarding(): FamiliarOSStateV1 {
const state = getInitializedState();
const nextState = normalizeState(markOnboardingCompleted(state));
commitState(nextState);
return getAppStateSnapshot();
}
export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
export function setDefaultPet(defaultPetId: string): FamiliarOSStateV1 {
const state = getInitializedState();
const targetPet = state.pets.installed.find((pet) => pet.id === defaultPetId);
const targetPet = state.familiars.installed.find((familiar) => familiar.id === defaultPetId);
if (!targetPet) {
throw new Error(`Cannot set unknown pet as default: ${defaultPetId}`);
throw new Error(`Cannot set unknown familiar as default: ${defaultPetId}`);
}
if (targetPet.broken) {
throw new Error(`Cannot set broken pet as default: ${defaultPetId}`);
throw new Error(`Cannot set broken familiar as default: ${defaultPetId}`);
}
const nextState = normalizeState({
@ -159,7 +188,7 @@ export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
export function setDefaultPetPosition(position: Point): FamiliarOSStateV1 {
const state = getInitializedState();
const nextState = normalizeState({
@ -174,7 +203,7 @@ export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 {
export function resetDefaultPetPosition(position: Point): FamiliarOSStateV1 {
return setDefaultPetPosition(position);
}
@ -182,7 +211,7 @@ export function getDefaultPetPosition(): Point | undefined {
return getInitializedState().defaultPet.position;
}
export function recordOpenPetsActivity(activity: OpenPetsActivityRecord, now: number = Date.now()): OpenPetsStateV1 {
export function recordFamiliarOSActivity(activity: FamiliarOSActivityRecord, now: number = Date.now()): FamiliarOSStateV1 {
publishPluginAgentActivity({ kind: activity.kind, reaction: activity.reaction });
const state = getInitializedState();
const analytics = state.analytics;
@ -207,20 +236,20 @@ export function recordOpenPetsActivity(activity: OpenPetsActivityRecord, now: nu
return getAppStateSnapshot();
}
export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): OpenPetsStateV1 {
export function installPetState(familiar: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): FamiliarOSStateV1 {
const state = getInitializedState();
if (state.pets.installed.some((installedPet) => installedPet.id === pet.id)) {
throw new Error(`Pet is already installed: ${pet.id}`);
if (state.familiars.installed.some((installedPet) => installedPet.id === familiar.id)) {
throw new Error(`Familiar is already installed: ${familiar.id}`);
}
const nextState = normalizeState({
...state,
pets: {
familiars: {
installed: [
...state.pets.installed,
...state.familiars.installed,
{
...pet,
...familiar,
builtIn: false,
protected: false,
installed: true,
@ -233,16 +262,16 @@ export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protec
return getAppStateSnapshot();
}
export function removePetState(petId: string): OpenPetsStateV1 {
export function removePetState(petId: string): FamiliarOSStateV1 {
if (petId === builtInPet.id) {
throw new Error("Built-in pet cannot be removed.");
throw new Error("Built-in familiar cannot be removed.");
}
const state = getInitializedState();
const existing = state.pets.installed.find((pet) => pet.id === petId);
const existing = state.familiars.installed.find((familiar) => familiar.id === petId);
if (!existing) {
throw new Error(`Pet is not installed: ${petId}`);
throw new Error(`Familiar is not installed: ${petId}`);
}
const nextDefaultPetId = state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId;
@ -253,8 +282,8 @@ export function removePetState(petId: string): OpenPetsStateV1 {
...state.preferences,
defaultPetId: nextDefaultPetId,
},
pets: {
installed: state.pets.installed.filter((pet) => pet.id !== petId),
familiars: {
installed: state.familiars.installed.filter((familiar) => familiar.id !== petId),
},
});
@ -262,7 +291,7 @@ export function removePetState(petId: string): OpenPetsStateV1 {
return getAppStateSnapshot();
}
export function markPetBroken(petId: string, brokenReason: string): OpenPetsStateV1 {
export function markPetBroken(petId: string, brokenReason: string): FamiliarOSStateV1 {
const state = getInitializedState();
if (petId === builtInPet.id) {
@ -275,8 +304,8 @@ export function markPetBroken(petId: string, brokenReason: string): OpenPetsStat
...state.preferences,
defaultPetId: state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId,
},
pets: {
installed: state.pets.installed.map((pet) => pet.id === petId ? { ...pet, broken: true, brokenReason } : pet),
familiars: {
installed: state.familiars.installed.map((familiar) => familiar.id === petId ? { ...familiar, broken: true, brokenReason } : familiar),
},
});
@ -286,15 +315,15 @@ export function markPetBroken(petId: string, brokenReason: string): OpenPetsStat
export function getStateFilePath(): string {
if (!statePath) {
throw new Error("OpenPets app state has not been initialized.");
throw new Error("FamiliarOS app state has not been initialized.");
}
return statePath;
}
function getInitializedState(): OpenPetsStateV1 {
function getInitializedState(): FamiliarOSStateV1 {
if (!currentState) {
throw new Error("OpenPets app state has not been initialized.");
throw new Error("FamiliarOS app state has not been initialized.");
}
return currentState;
@ -308,12 +337,12 @@ function readStateFile(path: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
console.error(`Failed to read OpenPets state from ${path}; using defaults.`, error);
console.error(`Failed to read FamiliarOS state from ${path}; using defaults.`, error);
return undefined;
}
}
function normalizeState(value: unknown): OpenPetsStateV1 {
function normalizeState(value: unknown): FamiliarOSStateV1 {
const record = isRecord(value) ? value : {};
const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {};
const preferencesRecord = isRecord(record.preferences) ? record.preferences : {};
@ -321,7 +350,7 @@ function normalizeState(value: unknown): OpenPetsStateV1 {
const position = normalizeMaybePosition(defaultPetRecord.position);
const installedPets = normalizeInstalledPets(record);
const defaultPetId = typeof preferencesRecord.defaultPetId === "string"
&& installedPets.some((pet) => pet.id === preferencesRecord.defaultPetId && !pet.broken)
&& installedPets.some((familiar) => familiar.id === preferencesRecord.defaultPetId && !familiar.broken)
? preferencesRecord.defaultPetId
: builtInPet.id;
@ -332,7 +361,7 @@ function normalizeState(value: unknown): OpenPetsStateV1 {
...preferencesRecord,
defaultPetId,
}),
pets: {
familiars: {
installed: installedPets,
},
defaultPet: position ? { position } : {},
@ -340,7 +369,7 @@ function normalizeState(value: unknown): OpenPetsStateV1 {
};
}
function normalizeAnalytics(value: unknown): OpenPetsAnalyticsState {
function normalizeAnalytics(value: unknown): FamiliarOSAnalyticsState {
const record = isRecord(value) ? value : {};
return {
messagesSent: normalizeCount(record.messagesSent),
@ -351,9 +380,9 @@ function normalizeAnalytics(value: unknown): OpenPetsAnalyticsState {
};
}
function normalizeReactionCounts(value: unknown): Record<OpenPetsReaction, number> {
function normalizeReactionCounts(value: unknown): Record<FamiliarOSReaction, number> {
const record = isRecord(value) ? value : {};
const counts = {} as Record<OpenPetsReaction, number>;
const counts = {} as Record<FamiliarOSReaction, number>;
for (const reaction of allowedReactions) {
counts[reaction] = normalizeCount(record[reaction]);
}
@ -385,7 +414,7 @@ function normalizeTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1["preferences"] {
function normalizePreferences(value: Partial<FamiliarOSStateV1["preferences"]>): FamiliarOSStateV1["preferences"] {
const defaultState = createDefaultState();
const legacyValue = value as Partial<{
readonly openAiChatModel?: unknown;
@ -413,15 +442,23 @@ function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): O
opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath),
vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools),
openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true,
ttsProvider: ["system", "openai", "elevenlabs", "piper", "openai-compatible"].includes(value.ttsProvider as string) ? (value.ttsProvider as OpenPetsStateV1["preferences"]["ttsProvider"]) : "system",
ttsProvider: ["system", "openai", "elevenlabs", "piper", "openai-compatible"].includes(value.ttsProvider as string) ? (value.ttsProvider as FamiliarOSStateV1["preferences"]["ttsProvider"]) : "system",
ttsVoice: typeof value.ttsVoice === "string" ? value.ttsVoice : undefined,
ttsSpeed: typeof value.ttsSpeed === "number" && value.ttsSpeed >= 0.5 && value.ttsSpeed <= 2 ? value.ttsSpeed : 1,
ttsModel: typeof value.ttsModel === "string" ? value.ttsModel : undefined,
ttsEndpointPreset: ["openrouter", "litellm", "wavespeedai", "custom"].includes(value.ttsEndpointPreset as string) ? (value.ttsEndpointPreset as OpenPetsStateV1["preferences"]["ttsEndpointPreset"]) : "openrouter",
ttsEndpointPreset: ["openrouter", "litellm", "wavespeedai", "custom"].includes(value.ttsEndpointPreset as string) ? (value.ttsEndpointPreset as FamiliarOSStateV1["preferences"]["ttsEndpointPreset"]) : "openrouter",
ttsEndpoint: typeof value.ttsEndpoint === "string" ? value.ttsEndpoint : undefined,
familiarName: normalizeFamiliarName(value.familiarName),
};
}
export function normalizeFamiliarName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim().slice(0, 64);
if (!trimmed || /[\r\n\0\x01-\x1f\x7f]/.test(trimmed)) return undefined;
return trimmed;
}
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));
@ -498,13 +535,13 @@ function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark"
}
function normalizeInstalledPets(value: Record<string, unknown>): InstalledPetState[] {
const installed = isRecord(value.pets) && Array.isArray(value.pets.installed)
? value.pets.installed
const installed = isRecord(value.familiars) && Array.isArray(value.familiars.installed)
? value.familiars.installed
: [];
const normalized = installed
.map((pet) => normalizeInstalledPet(pet))
.filter((pet): pet is InstalledPetState => Boolean(pet && pet.id !== builtInPet.id));
.map((familiar) => normalizeInstalledPet(familiar))
.filter((familiar): familiar is InstalledPetState => Boolean(familiar && familiar.id !== builtInPet.id));
return [builtInPet, ...normalized];
}
@ -535,7 +572,7 @@ function normalizeInstalledPet(value: unknown): InstalledPetState | null {
};
}
function createDefaultState(): OpenPetsStateV1 {
function createDefaultState(): FamiliarOSStateV1 {
return {
version: 1,
preferences: {
@ -561,8 +598,9 @@ function createDefaultState(): OpenPetsStateV1 {
ttsModel: undefined,
ttsEndpointPreset: "openrouter",
ttsEndpoint: undefined,
familiarName: undefined,
},
pets: {
familiars: {
installed: [builtInPet],
},
defaultPet: {},
@ -576,12 +614,12 @@ function createDefaultState(): OpenPetsStateV1 {
};
}
function commitState(nextState: OpenPetsStateV1): void {
function commitState(nextState: FamiliarOSStateV1): void {
writeStateToDisk(nextState);
currentState = nextState;
}
function writeStateToDisk(state: OpenPetsStateV1): void {
function writeStateToDisk(state: FamiliarOSStateV1): void {
const path = getStateFilePath();
mkdirSync(dirname(path), { recursive: true });
@ -593,7 +631,7 @@ function writeStateToDisk(state: OpenPetsStateV1): void {
function validateInstalledPetFiles(petId: string): string | undefined {
try {
const dir = getInstalledPetDir(petId);
const petJsonPath = join(dir, "pet.json");
const petJsonPath = join(dir, "familiar.json");
const spritesheetPath = join(dir, "spritesheet.webp");
JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown;
const spritesheet = statSync(spritesheetPath);
@ -602,7 +640,7 @@ function validateInstalledPetFiles(petId: string): string | undefined {
if (spritesheet.size > 100 * 1024 * 1024) return "spritesheet.webp is too large.";
return undefined;
} catch (error) {
return error instanceof Error ? error.message : "Installed pet files are invalid.";
return error instanceof Error ? error.message : "Installed familiar files are invalid.";
}
}
@ -648,8 +686,8 @@ function normalizePosition(value: Partial<Point>): Point | undefined {
};
}
function cloneState(state: OpenPetsStateV1): OpenPetsStateV1 {
return structuredClone(state) as OpenPetsStateV1;
function cloneState(state: FamiliarOSStateV1): FamiliarOSStateV1 {
return structuredClone(state) as FamiliarOSStateV1;
}
function acquireStartupInstallLock(userDataPath: string): string {
@ -658,7 +696,7 @@ function acquireStartupInstallLock(userDataPath: string): string {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
mkdirSync(lockPath, { mode: 0o700 });
writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), command: "openpets-startup" })}\n`, "utf8");
writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), command: "familiaros-startup" })}\n`, "utf8");
return lockPath;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
@ -667,10 +705,10 @@ function acquireStartupInstallLock(userDataPath: string): string {
rmSync(lockPath, { recursive: true, force: true });
continue;
}
throw new Error("OpenPets cannot start while a direct pet install is in progress. Wait for install-pet to finish, then reopen OpenPets.");
throw new Error("FamiliarOS cannot start while a direct familiar install is in progress. Wait for install-familiar to finish, then reopen FamiliarOS.");
}
}
throw new Error("Could not acquire OpenPets startup lock.");
throw new Error("Could not acquire FamiliarOS startup lock.");
}
function isStaleInstallLock(lockPath: string): boolean {

View file

@ -17,7 +17,7 @@ export function createAppIcon(): NativeImage {
return cachedAppIcon;
}
console.error(`OpenPets app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`FamiliarOS app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
cachedAppIcon = createFallbackTrayIcon();
return cachedAppIcon;
}
@ -30,7 +30,7 @@ export function createTrayIcon(): NativeImage {
return assetImage.resize({ width: 22, height: 22 });
}
console.error(`OpenPets tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
console.error(`FamiliarOS tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
return createFallbackTrayIcon();
}
@ -63,7 +63,7 @@ function createFallbackTrayIcon(): NativeImage {
});
if (image.isEmpty()) {
console.error("OpenPets tray icon creation produced an empty image.");
console.error("FamiliarOS tray icon creation produced an empty image.");
}
if (process.platform === "darwin") {

View file

@ -1,6 +1,6 @@
export const builtInPet = {
id: "builtin",
displayName: "Built-in Pet",
displayName: "Built-in Familiar",
builtIn: true,
protected: true,
installed: true,

View file

@ -1,7 +1,7 @@
export interface CatalogV2 {
readonly version: 2;
readonly generatedAt: string;
readonly pets: readonly CatalogPetV2[];
readonly familiars: readonly CatalogPetV2[];
}
export interface CatalogPetV2 {
@ -41,7 +41,7 @@ export interface CatalogV3Page {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly pets: readonly CatalogPetV3[];
readonly familiars: readonly CatalogPetV3[];
}
export interface CatalogV3SearchIndex {
@ -56,7 +56,7 @@ export interface CatalogV3SearchPage {
readonly version: 3;
readonly page: number;
readonly pageSize: number;
readonly pets: readonly CatalogV3SearchPet[];
readonly familiars: readonly CatalogV3SearchPet[];
}
export interface CatalogV3SearchPet {
@ -86,16 +86,16 @@ export function validateCatalogV2(value: unknown): CatalogV2 {
if (!isRecord(value)) throw new Error("Catalog must be an object.");
if (value.version !== 2) throw new Error("Catalog version must be 2.");
if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog generatedAt must be a valid date string.");
if (!Array.isArray(value.pets)) throw new Error("Catalog pets must be an array.");
if (value.pets.length > 1000) throw new Error("Catalog has too many pets.");
if (!Array.isArray(value.familiars)) throw new Error("Catalog familiars must be an array.");
if (value.familiars.length > 1000) throw new Error("Catalog has too many familiars.");
const ids = new Set<string>();
const pets = value.pets.map((pet) => validateCatalogPet(pet, ids));
const familiars = value.familiars.map((familiar) => validateCatalogPet(familiar, ids));
return {
version: 2,
generatedAt: value.generatedAt,
pets,
familiars,
};
}
@ -133,16 +133,16 @@ export function validateCatalogV3Page(value: unknown, expectedPage: number): Cat
if (value.version !== 3) throw new Error("Catalog v3 page version must be 3.");
if (value.page !== expectedPage) throw new Error("Catalog v3 page index mismatch.");
const pageSize = validateInteger(value.pageSize, "Catalog v3 pageSize", 1, 500);
if (!Array.isArray(value.pets)) throw new Error("Catalog v3 pets must be an array.");
if (value.pets.length > pageSize) throw new Error("Catalog v3 page has too many pets.");
if (!Array.isArray(value.familiars)) throw new Error("Catalog v3 familiars must be an array.");
if (value.familiars.length > pageSize) throw new Error("Catalog v3 page has too many familiars.");
const ids = new Set<string>();
const pets = value.pets.map((pet) => validateCatalogV3Pet(pet, ids));
const familiars = value.familiars.map((familiar) => validateCatalogV3Pet(familiar, ids));
return {
version: 3,
page: expectedPage,
pageSize,
pets,
familiars,
};
}
@ -169,21 +169,21 @@ export function validateCatalogV3SearchPage(value: unknown, expectedPage: number
if (value.version !== 3) throw new Error("Catalog v3 search page version must be 3.");
if (value.page !== expectedPage) throw new Error("Catalog v3 search page index mismatch.");
const pageSize = validateInteger(value.pageSize, "Catalog v3 search pageSize", 1, 1000);
if (!Array.isArray(value.pets)) throw new Error("Catalog v3 search pets must be an array.");
if (value.pets.length > pageSize) throw new Error("Catalog v3 search page has too many pets.");
if (!Array.isArray(value.familiars)) throw new Error("Catalog v3 search familiars must be an array.");
if (value.familiars.length > pageSize) throw new Error("Catalog v3 search page has too many familiars.");
return {
version: 3,
page: expectedPage,
pageSize,
pets: value.pets.map((pet) => validateCatalogV3SearchPet(pet, catalogPageCount)),
familiars: value.familiars.map((familiar) => validateCatalogV3SearchPet(familiar, catalogPageCount)),
};
}
function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
if (!isRecord(value)) throw new Error("Catalog pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog familiar must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog pet id: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog familiar id: ${id}`);
ids.add(id);
return {
@ -196,9 +196,9 @@ function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
}
function validateCatalogV3Pet(value: unknown, ids: Set<string>): CatalogPetV3 {
if (!isRecord(value)) throw new Error("Catalog v3 pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 familiar must be an object.");
const id = validateId(value.id);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 pet id on page: ${id}`);
if (ids.has(id)) throw new Error(`Duplicate catalog v3 familiar id on page: ${id}`);
ids.add(id);
const category = validateCategory(value.category);
const entry: CatalogPetV3 = {
@ -224,7 +224,7 @@ function validateCatalogV3Category(value: unknown): CatalogV3Category {
}
function validateCatalogV3SearchPet(value: unknown, catalogPageCount: number): CatalogV3SearchPet {
if (!isRecord(value)) throw new Error("Catalog v3 search pet must be an object.");
if (!isRecord(value)) throw new Error("Catalog v3 search familiar must be an object.");
const catalogPage = validateInteger(value.catalogPage, "Catalog v3 search catalogPage", 0, Math.max(0, catalogPageCount - 1));
return withCatalogMeta({
id: validateId(value.id),
@ -244,7 +244,7 @@ function withCatalogMeta<T extends object>(entry: T, value: Record<string, unkno
}
function validateBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new Error(`Catalog pet ${field} must be a boolean.`);
if (typeof value !== "boolean") throw new Error(`Catalog familiar ${field} must be a boolean.`);
return value;
}
@ -263,15 +263,15 @@ function validateInteger(value: unknown, field: string, min: number, max: number
}
function validateId(value: unknown): string {
if (typeof value !== "string") throw new Error("Catalog pet id must be a string.");
if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved.");
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog pet id: ${value}`);
if (typeof value !== "string") throw new Error("Catalog familiar id must be a string.");
if (value === "builtin") throw new Error("Catalog familiar id 'builtin' is reserved.");
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog familiar id: ${value}`);
return value;
}
function validateString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`Catalog pet ${field} must be a string.`);
if (value.length > maxLength) throw new Error(`Catalog pet ${field} is too long.`);
if (typeof value !== "string") throw new Error(`Catalog familiar ${field} must be a string.`);
if (value.length > maxLength) throw new Error(`Catalog familiar ${field} is too long.`);
return value;
}
@ -284,10 +284,10 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "catalog"
if (url.port) throw new Error(`${field} URL cannot include a custom port.`);
if (field === "preview") {
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed.");
if (url.hostname !== "familiaros.dev" || !url.pathname.startsWith("/familiars/")) throw new Error("Preview URL host/path is not allowed.");
} else if (field === "catalog") {
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/catalog.v3/")) throw new Error("Catalog URL host/path is not allowed.");
} else if (url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/pets/")) {
if (url.hostname !== "familiaros.dev" || !url.pathname.startsWith("/familiars/catalog.v3/")) throw new Error("Catalog URL host/path is not allowed.");
} else if (url.hostname !== "zip.familiaros.dev" || !url.pathname.startsWith("/familiars/")) {
throw new Error("Zip URL host/path is not allowed.");
}

View file

@ -5,8 +5,8 @@ import { app } from "electron";
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json";
export const catalogV3Url = "https://openpets.dev/pets/catalog.v3.json";
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const maxCatalogV3PageBytes = 256_000;
@ -14,7 +14,7 @@ const fetchTimeoutMs = 5_000;
export interface CatalogUiState {
readonly source: "remote" | "fixture" | "error";
readonly pets: readonly CatalogPetV2[];
readonly familiars: readonly CatalogPetV2[];
readonly generatedAt?: string;
readonly error?: string;
readonly version?: 2 | 3;
@ -29,7 +29,7 @@ export interface CatalogUiState {
export interface CatalogSearchUiState {
readonly source: "remote" | "error";
readonly pets: readonly CatalogV3SearchPet[];
readonly familiars: readonly CatalogV3SearchPet[];
readonly total?: number;
readonly error?: string;
}
@ -47,7 +47,7 @@ export async function getCatalogUiState(): Promise<CatalogUiState> {
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
return {
source: "remote",
pets: filterSurfaceablePets(firstPage.pets),
familiars: filterSurfaceablePets(firstPage.familiars),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: surfaceableTotal(remoteV3.index),
@ -66,14 +66,14 @@ export async function getCatalogUiState(): Promise<CatalogUiState> {
export async function getCatalogPageUiState(page: number): Promise<CatalogUiState> {
if (!Number.isInteger(page) || page < 0) throw new Error("Catalog page must be a non-negative integer.");
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
if (page >= surfaceablePageCount(remoteV3.index)) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index);
if (!pageResult.ok) return { source: "error", pets: [], error: pageResult.error };
if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error };
return {
source: "remote",
pets: filterSurfaceablePets(pageResult.pets),
familiars: filterSurfaceablePets(pageResult.familiars),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: surfaceableTotal(remoteV3.index),
@ -88,13 +88,13 @@ export async function getCatalogPageUiState(page: number): Promise<CatalogUiStat
export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
try {
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
return { source: "remote", pets: surfacedPets, total: surfacedPets.length };
return { source: "remote", familiars: surfacedPets, total: surfacedPets.length };
} catch (error) {
return { source: "error", pets: [], error: error instanceof Error ? error.message : "unknown error" };
return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" };
}
}
@ -104,26 +104,26 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
let blockedHiddenV3Pet = false;
try {
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
const searchPet = searchPets.find((pet) => pet.id === petId);
const searchPet = searchPets.find((familiar) => familiar.id === petId);
if (searchPet && !isSurfaceablePet(searchPet)) {
blockedHiddenV3Pet = true;
throw new Error(`Pet is not available in the curated catalog: ${petId}`);
throw new Error(`Familiar is not available in the curated catalog: ${petId}`);
}
if (searchPet) {
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
const pet = page.find((candidate) => candidate.id === petId);
if (pet && isSurfaceablePet(pet)) return pet;
const familiar = page.find((candidate) => candidate.id === petId);
if (familiar && isSurfaceablePet(familiar)) return familiar;
}
} catch (error) {
if (blockedHiddenV3Pet) throw error;
// Fall through to v2/fixture so visible v2-compatible pets remain installable during partial v3 outages.
// Fall through to v2/fixture so visible v2-compatible familiars remain installable during partial v3 outages.
}
}
const catalog = await getV2CatalogOrFixture();
const pet = filterSurfaceablePets(catalog.pets).find((candidate) => candidate.id === petId);
if (!pet) throw new Error(`Pet is not available in the validated catalog: ${petId}`);
return pet;
const familiar = filterSurfaceablePets(catalog.familiars).find((candidate) => candidate.id === petId);
if (!familiar) throw new Error(`Familiar is not available in the validated catalog: ${petId}`);
return familiar;
}
async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<CatalogUiState> {
@ -133,10 +133,10 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (remote.ok) {
return {
source: "remote",
pets: filterSurfaceablePets(remote.catalog.pets),
familiars: filterSurfaceablePets(remote.catalog.familiars),
generatedAt: remote.catalog.generatedAt,
version: 2,
total: filterSurfaceablePets(remote.catalog.pets).length,
total: filterSurfaceablePets(remote.catalog.familiars).length,
supportsCategories: false,
};
}
@ -146,33 +146,33 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (fixture.ok) {
return {
source: "fixture",
pets: filterSurfaceablePets(fixture.catalog.pets),
familiars: filterSurfaceablePets(fixture.catalog.familiars),
generatedAt: fixture.catalog.generatedAt,
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
version: 2,
total: filterSurfaceablePets(fixture.catalog.pets).length,
total: filterSurfaceablePets(fixture.catalog.familiars).length,
supportsCategories: false,
};
}
return {
source: "error",
pets: [],
familiars: [],
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
};
}
function filterSurfaceablePets<T extends { readonly original?: boolean; readonly featured?: boolean }>(pets: readonly T[]): readonly T[] {
return pets.filter(isSurfaceablePet);
function filterSurfaceablePets<T extends { readonly original?: boolean; readonly featured?: boolean }>(familiars: readonly T[]): readonly T[] {
return familiars.filter(isSurfaceablePet);
}
function isSurfaceablePet(pet: { readonly original?: boolean; readonly featured?: boolean }): boolean {
return pet.original === true || pet.featured === true;
function isSurfaceablePet(familiar: { readonly original?: boolean; readonly featured?: boolean }): boolean {
return familiar.original === true || familiar.featured === true;
}
function getSurfaceableSearchPets(pets: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(pets).map((pet, surfaceIndex) => ({
...pet,
function getSurfaceableSearchPets(familiars: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(familiars).map((familiar, surfaceIndex) => ({
...familiar,
catalogPage: Math.floor(surfaceIndex / index.pageSize),
}));
}
@ -194,23 +194,23 @@ async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; reado
}
}
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, pets: await getRemoteCatalogV3Page(page, index) };
return { ok: true, familiars: await getRemoteCatalogV3Page(page, index) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
const pageSearchPets = searchPets.slice(page * index.pageSize, (page + 1) * index.pageSize);
const ids = new Set(pageSearchPets.map((pet) => pet.id));
const catalogPageNumbers = [...new Set(pageSearchPets.map((pet) => pet.catalogPage))];
const ids = new Set(pageSearchPets.map((familiar) => familiar.id));
const catalogPageNumbers = [...new Set(pageSearchPets.map((familiar) => familiar.catalogPage))];
const catalogPages = await Promise.all(catalogPageNumbers.map((catalogPage) => getRemoteCatalogV3Page(catalogPage, index)));
const petsById = new Map(catalogPages.flat().filter((pet) => ids.has(pet.id) && isSurfaceablePet(pet)).map((pet) => [pet.id, pet]));
return { ok: true, pets: pageSearchPets.map((pet) => petsById.get(pet.id)).filter((pet): pet is CatalogPetV2 => Boolean(pet)) };
const petsById = new Map(catalogPages.flat().filter((familiar) => ids.has(familiar.id) && isSurfaceablePet(familiar)).map((familiar) => [familiar.id, familiar]));
return { ok: true, familiars: pageSearchPets.map((familiar) => petsById.get(familiar.id)).filter((familiar): familiar is CatalogPetV2 => Boolean(familiar)) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
@ -227,38 +227,38 @@ async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Prom
const pageUrl = index.pages[page];
if (!pageUrl) throw new Error("Catalog page is out of range.");
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
const pets = payload.pets.map(toCatalogPetV2Compat);
assertUniquePetIds(pets);
v3PageCache.set(page, pets);
return pets;
const familiars = payload.familiars.map(toCatalogPetV2Compat);
assertUniquePetIds(familiars);
v3PageCache.set(page, familiars);
return familiars;
}
async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
v3SearchPromise ||= Promise.resolve().then(async () => {
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
const pages = await Promise.all(searchIndex.pages.map(async (pageUrl, page) => validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length)));
const pets = pages.flatMap((page) => page.pets);
if (pets.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return pets;
const familiars = pages.flatMap((page) => page.familiars);
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return familiars;
});
return await v3SearchPromise;
}
function toCatalogPetV2Compat(pet: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 {
function toCatalogPetV2Compat(familiar: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 {
const entry: CatalogPetV2 = {
id: pet.id,
displayName: pet.displayName,
description: pet.description,
preview: pet.thumbnail,
spritesheet: pet.spritesheet,
zip: pet.zip,
category: pet.category,
id: familiar.id,
displayName: familiar.displayName,
description: familiar.description,
preview: familiar.thumbnail,
spritesheet: familiar.spritesheet,
zip: familiar.zip,
category: familiar.category,
};
return {
...entry,
...(pet.subcategory ? { subcategory: pet.subcategory } : {}),
...(pet.original === undefined ? {} : { original: pet.original }),
...(pet.featured === undefined ? {} : { featured: pet.featured }),
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
...(familiar.original === undefined ? {} : { original: familiar.original }),
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
};
}
@ -349,10 +349,10 @@ function validateCatalogEndpoint(value: string, expected: string): void {
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
}
function assertUniquePetIds(pets: readonly CatalogPetV2[]): void {
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
const ids = new Set<string>();
for (const pet of pets) {
if (ids.has(pet.id)) throw new Error(`Duplicate catalog v3 pet id: ${pet.id}`);
ids.add(pet.id);
for (const familiar of familiars) {
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
ids.add(familiar.id);
}
}

View file

@ -4,9 +4,9 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
// Desktop-specific Cursor integration checks
// These verify that the desktop app correctly uses the @open-pets/cursor package
// These verify that the desktop app correctly uses the @familiaros/cursor package
const root = realpathSync(mkdtempSync(join(tmpdir(), "openpets-cursor-desktop-")));
const root = realpathSync(mkdtempSync(join(tmpdir(), "familiaros-cursor-desktop-")));
try {
// Test that desktop would use the correct global config path
@ -29,10 +29,10 @@ try {
const configPath = join(cursorDir, "mcp.json");
const installedConfig = {
mcpServers: {
openpets: {
familiaros: {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
},
},
};
@ -40,12 +40,12 @@ try {
const content = readFileSync(configPath, "utf8");
const parsed = JSON.parse(content);
assert.deepEqual(parsed.mcpServers.openpets, installedConfig.mcpServers.openpets);
assert.deepEqual(parsed.mcpServers.familiaros, installedConfig.mcpServers.familiaros);
// Test that desktop would preserve unrelated servers during operations
const multiServerConfig = {
mcpServers: {
openpets: installedConfig.mcpServers.openpets,
familiaros: installedConfig.mcpServers.familiaros,
other: { type: "stdio", command: "test", args: [] },
},
topLevelField: "preserve",
@ -61,7 +61,7 @@ try {
const publishedEntry = {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "test"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "test"],
};
assert.equal(publishedEntry.command, "npx");
assert.ok(publishedEntry.args[1].includes("@"), "Published mode must use pinned version");
@ -69,7 +69,7 @@ try {
const localEntry = {
type: "stdio",
command: "node",
args: ["/absolute/path/to/mcp.js", "--pet", "test"],
args: ["/absolute/path/to/mcp.js", "--familiar", "test"],
};
assert.equal(localEntry.command, "node");
assert.ok(localEntry.args[0].startsWith("/"), "Local mode must use absolute path");
@ -113,24 +113,24 @@ try {
// Test that desktop would create valid MCP preview
const preview = {
mcpServers: {
openpets: {
familiaros: {
type: "stdio",
command: "npx",
args: ["-y", "@open-pets/mcp@2.0.6", "--pet", "fixer"],
args: ["-y", "@familiaros/mcp@2.0.6", "--familiar", "fixer"],
},
},
};
assert.equal(preview.mcpServers.openpets.type, "stdio");
assert.equal(preview.mcpServers.openpets.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.openpets.args));
assert.equal(preview.mcpServers.familiaros.type, "stdio");
assert.equal(preview.mcpServers.familiaros.command, "npx");
assert.ok(Array.isArray(preview.mcpServers.familiaros.args));
// Test desktop Phase 2 rules posture: preview/copy only, no project writes
const rulesPreview = {
rulesPath: ".cursor/rules/openpets.mdc",
rulesContent: "<!-- OPENPETS:CURSOR_RULES:START -->\nUse OpenPets sparingly.\n<!-- OPENPETS:CURSOR_RULES:END -->\n",
rulesPath: ".cursor/rules/familiaros.mdc",
rulesContent: "<!-- FAMILIAROS:CURSOR_RULES:START -->\nUse FamiliarOS sparingly.\n<!-- FAMILIAROS:CURSOR_RULES:END -->\n",
};
assert.equal(rulesPreview.rulesPath, ".cursor/rules/openpets.mdc");
assert.match(rulesPreview.rulesContent, /OPENPETS:CURSOR_RULES:START/);
assert.equal(rulesPreview.rulesPath, ".cursor/rules/familiaros.mdc");
assert.match(rulesPreview.rulesContent, /FAMILIAROS:CURSOR_RULES:START/);
assert.doesNotMatch(rulesPreview.rulesContent, /alwaysApply:\s*true/);
console.error("Cursor desktop validation passed.");

View file

@ -3,14 +3,14 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mapAsarPathToUnpacked } from "@open-pets/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
import { mapAsarPathToUnpacked } from "@familiaros/claude";
import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
const root = mkdtempSync(join(tmpdir(), "openpets-desktop-opencode-"));
const root = mkdtempSync(join(tmpdir(), "familiaros-desktop-opencode-"));
try {
const globalDir = join(root, "opencode-global");
const bundledCli = mapAsarPathToUnpacked(join(root, "OpenPets.app", "Contents", "Resources", "app.asar", "node_modules", "@open-pets", "cli", "dist", "index.js"));
const bundledCli = mapAsarPathToUnpacked(join(root, "FamiliarOS.app", "Contents", "Resources", "app.asar", "node_modules", "@familiaros", "cli", "dist", "index.js"));
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed");
@ -24,20 +24,20 @@ try {
});
assert.equal(install.configPath, join(globalDir, "opencode.jsonc"));
assert.equal(install.instructionPath, join(globalDir, "openpets.md"));
assert.equal(install.instructionPath, join(globalDir, "familiaros.md"));
assert.match(install.configWrite.content, /app\.asar\.unpacked/);
assert.doesNotMatch(install.configWrite.content, /app\.asar(?!\.unpacked)/);
assert.match(install.configWrite.content, /@open-pets\/opencode@4\.5\.6/);
assert.match(install.configWrite.content, /@familiaros\/opencode@4\.5\.6/);
const preview = parseOpenCodeConfig(install.configWrite.content);
assert.equal(preview.ok, true, "desktop OpenCode preview must parse as JSONC without JSON.parse.");
const previewConfig = preview.value as { readonly mcp?: { readonly openpets?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] };
assert.deepEqual(previewConfig.mcp?.openpets?.command, ["node", bundledCli, "mcp", "--pet", "fixer"]);
assert.deepEqual(previewConfig.plugin, [["@open-pets/opencode@4.5.6", { pet: "fixer" }]]);
const previewConfig = preview.value as { readonly mcp?: { readonly familiaros?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] };
assert.deepEqual(previewConfig.mcp?.familiaros?.command, ["node", bundledCli, "mcp", "--familiar", "fixer"]);
assert.deepEqual(previewConfig.plugin, [["@familiaros/opencode@4.5.6", { familiar: "fixer" }]]);
writePreparedOpenCodeGlobalSetup(install);
assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "installed");
assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/);
assert.match(readFileSync(join(globalDir, "familiaros.md"), "utf8"), /FAMILIAROS:START/);
const remove = prepareOpenCodeGlobalRemove(globalDir);
assert.equal(remove.configWrites.length, 1);

View file

@ -16,26 +16,26 @@ const workspaceConfig = readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf
const builderConfigPath = join(appDir, "electron-builder.yml");
const builderConfig = readFileSync(builderConfigPath, "utf8");
assert.equal(packageJson.description, "OpenPets tray-first desktop companion app.");
assert.equal(packageJson.author, "OpenPets");
assert.match(packageJson.scripts?.["dev:debug"] ?? "", /OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev/, "desktop debug dev script must enable verbose log mirroring.");
assert.equal(packageJson.description, "FamiliarOS tray-first desktop companion app.");
assert.equal(packageJson.author, "FamiliarOS");
assert.match(packageJson.scripts?.["dev:debug"] ?? "", /FAMILIAROS_LOG_LEVEL=debug FAMILIAROS_LOG_CONSOLE=1 pnpm dev/, "desktop debug dev script must enable verbose log mirroring.");
assert.match(packageJson.scripts?.package ?? "", /node scripts\/clean-package-output\.cjs && electron-builder/);
assert.match(packageJson.scripts?.["package:dir"] ?? "", /node scripts\/clean-package-output\.cjs && electron-builder --dir/);
assert.equal(rootPackageJson.scripts?.["package:desktop:dir"], "pnpm build && pnpm --filter @open-pets/desktop package:dir");
assert.equal(packageJson.dependencies?.["@open-pets/claude"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/cli"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/cursor"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/mcp"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/opencode"], "workspace:*");
assert.equal(packageJson.dependencies?.["@open-pets/agent-events"], "workspace:*");
assert.equal(rootPackageJson.scripts?.["package:desktop:dir"], "pnpm build && pnpm --filter @familiaros/desktop package:dir");
assert.equal(packageJson.dependencies?.["@familiaros/claude"], "workspace:*");
assert.equal(packageJson.dependencies?.["@familiaros/cli"], "workspace:*");
assert.equal(packageJson.dependencies?.["@familiaros/cursor"], "workspace:*");
assert.equal(packageJson.dependencies?.["@familiaros/mcp"], "workspace:*");
assert.equal(packageJson.dependencies?.["@familiaros/opencode"], "workspace:*");
assert.equal(packageJson.dependencies?.["@familiaros/agent-events"], "workspace:*");
assert.equal(packageJson.dependencies?.["@img/sharp-win32-x64"], undefined, "sharp platform binaries must stay optional transitive deps, not direct host-breaking dependencies.");
assert.match(workspaceConfig, /supportedArchitectures:[\s\S]*?os:[\s\S]*?- darwin[\s\S]*?- win32[\s\S]*?- linux/, "pnpm must install optional sharp binaries for desktop release OS targets.");
assert.match(workspaceConfig, /supportedArchitectures:[\s\S]*?cpu:[\s\S]*?- x64[\s\S]*?- arm64/, "pnpm must install optional sharp binaries for desktop release CPU targets.");
assert.match(workspaceConfig, /supportedArchitectures:[\s\S]*?libc:[\s\S]*?- glibc/, "pnpm must install optional sharp binaries for Linux glibc release targets.");
assert.match(packageJson.devDependencies?.["electron-builder"] ?? "", /^\^26\.(?:9|[1-9]\d)\./, "desktop AppImage packaging must use electron-builder 26.9+ for conditional Linux sandbox handling.");
assert.match(builderConfig, /appId:\s*dev\.openpets\.app/);
assert.match(builderConfig, /productName:\s*OpenPets/);
assert.match(builderConfig, /executableName:\s*openpets/, "desktop packages must use a safe executable name for the stricter AppImage toolset.");
assert.match(builderConfig, /appId:\s*dev\.familiaros\.app/);
assert.match(builderConfig, /productName:\s*FamiliarOS/);
assert.match(builderConfig, /executableName:\s*familiaros/, "desktop packages must use a safe executable name for the stricter AppImage toolset.");
assert.match(builderConfig, /output:\s*dist-electron/);
assert.match(builderConfig, /linux:[\s\S]*?target:[\s\S]*?- AppImage[\s\S]*?- deb[\s\S]*?- rpm[\s\S]*?- tar\.gz/, "desktop Linux packaging must include AppImage, deb, rpm, and tar.gz targets.");
assert.match(builderConfig, /publish:\s*null/);
@ -46,7 +46,7 @@ assert.match(builderConfig, /asarUnpack:/);
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, /familiar-preload\.cjs/);
assert.match(builderConfig, /prompt-window-preload\.cjs/);
assert.match(builderConfig, /plugin-sdk-preload\.cjs/);
assert.match(builderConfig, /plugin-command-form-preload\.cjs/);
@ -55,23 +55,23 @@ assert.match(builderConfig, /extraResources:[\s\S]*from:\s*\.\.\/\.\.\/plugins\/
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, "familiar-preload.cjs")), "familiar-preload.cjs must exist for familiar 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.");
assert.ok(existsSync(join(appDir, "assets", "app-icon.icns")), "app icon must exist for packaging.");
assert.ok(existsSync(join(appDir, "assets", "app-icon.ico")), "Windows app icon must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-spritesheet.webp"), "default pet spritesheet must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-thumbnail.png"), "default pet thumbnail must exist for Pet Manager preview.");
assertNonEmptyFile(join(appDir, "assets", "default-familiar-spritesheet.webp"), "default familiar spritesheet must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-familiar-thumbnail.png"), "default familiar thumbnail must exist for Familiar Manager preview.");
for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "pi.svg", "vscode.svg", "windsurf.svg", "zed.svg"]) {
assertSafeBundledSvg(join(appDir, "assets", "integrations", icon), `integration icon must be safe and packaged: ${icon}`);
}
assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png.");
const petWindowSource = readFileSync(join(appDir, "src", "pet-window.ts"), "utf8");
const petWindowSource = readFileSync(join(appDir, "src", "familiar-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 petPreloadSource = readFileSync(join(appDir, "familiar-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");
@ -86,103 +86,103 @@ const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8");
const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
const localIpcPathsSource = readFileSync(join(appDir, "src", "local-ipc-paths.ts"), "utf8");
const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"), "utf8");
const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-pet-controller.ts"), "utf8");
const agentPetControllerSourceForLogging = readFileSync(join(appDir, "src", "agent-pet-controller.ts"), "utf8");
const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8");
const agentPetControllerSourceForLogging = readFileSync(join(appDir, "src", "agent-familiar-controller.ts"), "utf8");
const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8");
assert.match(loggerSource, /openpets\.log/, "desktop logger must write a user-sendable openpets.log file.");
assert.match(loggerSource, /openpets\.previous\.log/, "desktop logger must retain a previous log file for bug reports.");
assert.match(loggerSource, /OPENPETS_LOG_LEVEL/, "desktop logger must support verbose dev logging via environment.");
assert.match(loggerSource, /normalizeLogLevel\(process\.env\.OPENPETS_LOG_LEVEL\) \?\? "debug"/, "desktop production logger must default to debug for user-sendable diagnostics.");
assert.match(loggerSource, /familiaros\.log/, "desktop logger must write a user-sendable familiaros.log file.");
assert.match(loggerSource, /familiaros\.previous\.log/, "desktop logger must retain a previous log file for bug reports.");
assert.match(loggerSource, /FAMILIAROS_LOG_LEVEL/, "desktop logger must support verbose dev logging via environment.");
assert.match(loggerSource, /normalizeLogLevel\(process\.env\.FAMILIAROS_LOG_LEVEL\) \?\? "debug"/, "desktop production logger must default to debug for user-sendable diagnostics.");
assert.match(loggerSource, /redacted-token/, "desktop logger must redact token-looking values.");
assert.match(mainSource, /initializeLogger\(\)/, "desktop startup must initialize logging before subsystem startup.");
assert.match(mainSource, /process\.platform === "linux"[\s\S]*?appendSwitch\("ozone-platform", "x11"\)/, "Linux desktop pets must prefer X11/Xwayland because GNOME Wayland blocks always-on-top and programmatic window dragging.");
assert.match(mainSource, /process\.platform === "linux"[\s\S]*?appendSwitch\("ozone-platform", "x11"\)/, "Linux desktop familiars must prefer X11/Xwayland because GNOME Wayland blocks always-on-top and programmatic window dragging.");
assert.match(mainSource, /hasSwitch\("ozone-platform"\)/, "Linux X11 preference must let users override Electron's Ozone backend explicitly.");
assert.match(traySource, /t\("tray\.openLogsFolder"\)/, "desktop tray must expose user-sendable logs for bug reports.");
assert.match(enCatalogSource, /Open Logs Folder/, "English catalog must keep the user-sendable logs label.");
assert.match(localIpcSourceForLogging, /request received/, "desktop IPC must log request methods for diagnostics.");
assert.match(localIpcPathsSource, /OPENPETS_IPC_BIND[\s\S]*?OPENPETS_IPC_ENDPOINT[\s\S]*?validateBindHost[\s\S]*?validateAdvertisedHost/, "WSL NAT IPC must separate bind and advertised endpoints with validation.");
assert.match(localIpcPathsSource, /OPENPETS_IPC_ENDPOINT only controls the advertised discovery endpoint[\s\S]*?OPENPETS_IPC_BIND to opt into TCP IPC listening/, "OPENPETS_IPC_ENDPOINT-only mode must not start TCP listening; OPENPETS_IPC_BIND is the explicit TCP opt-in.");
assert.match(localIpcPathsSource, /OPENPETS_IPC_ENDPOINT must use the same port as OPENPETS_IPC_BIND unless OPENPETS_IPC_BIND uses port 0/, "WSL NAT advertised endpoint must not silently override mismatched ports.");
assert.match(localIpcPathsSource, /FAMILIAROS_IPC_BIND[\s\S]*?FAMILIAROS_IPC_ENDPOINT[\s\S]*?validateBindHost[\s\S]*?validateAdvertisedHost/, "WSL NAT IPC must separate bind and advertised endpoints with validation.");
assert.match(localIpcPathsSource, /FAMILIAROS_IPC_ENDPOINT only controls the advertised discovery endpoint[\s\S]*?FAMILIAROS_IPC_BIND to opt into TCP IPC listening/, "FAMILIAROS_IPC_ENDPOINT-only mode must not start TCP listening; FAMILIAROS_IPC_BIND is the explicit TCP opt-in.");
assert.match(localIpcPathsSource, /FAMILIAROS_IPC_ENDPOINT must use the same port as FAMILIAROS_IPC_BIND unless FAMILIAROS_IPC_BIND uses port 0/, "WSL NAT advertised endpoint must not silently override mismatched ports.");
assert.match(leaseManagerSource, /acquired/, "lease manager must log lease acquisition details.");
assert.match(defaultPetControllerSource, /show requested/, "default pet controller must log show lifecycle events.");
assert.match(agentPetControllerSourceForLogging, /show requested/, "agent pet controller must log show lifecycle events.");
assert.ok(petWindowSource.includes("defaultPetSprite.frameWidth * defaultPetSprite.columns") && petWindowSource.includes("defaultPetSprite.frameHeight * defaultPetSprite.rows"), "pet renderer must derive universal spritesheet dimensions from frame size and row/column counts.");
assert.match(defaultPetControllerSource, /show requested/, "default familiar controller must log show lifecycle events.");
assert.match(agentPetControllerSourceForLogging, /show requested/, "agent familiar controller must log show lifecycle events.");
assert.ok(petWindowSource.includes("defaultPetSprite.frameWidth * defaultPetSprite.columns") && petWindowSource.includes("defaultPetSprite.frameHeight * defaultPetSprite.rows"), "familiar renderer must derive universal spritesheet dimensions from frame size and row/column counts.");
for (const reaction of ["idle", "thinking", "working", "editing", "running", "testing", "waiting", "waving", "success", "error", "celebrating"]) {
assert.match(reactionMessagesSource, new RegExp(`${reaction}:\\s*\\[`), `reaction messages must define a pool for: ${reaction}`);
}
assert.match(reactionMessagesSource, /satisfies Record<OpenPetsReaction, readonly string\[\]>/, "reaction-only bubble message pools must be exhaustive over OpenPetsReaction.");
assert.match(reactionMessagesSource, /satisfies Record<FamiliarOSReaction, readonly string\[\]>/, "reaction-only bubble message pools must be exhaustive over FamiliarOSReaction.");
assert.match(petWindowSource, /pickReactionMessage\(display\.reaction\b/, "reaction-only bubbles must render randomized messages instead of raw lowercase reaction ids.");
assert.match(petWindowSource, /function preparePetTransientDisplay/, "reaction-only bubbles must prepare a stable random message before rerenders.");
assert.match(petWindowSource, /function mergePetTransientDisplay/, "reaction-only events must not replace an active explicit message bubble.");
assert.match(petWindowSource, /return \{ \.\.\.current, reaction: next\.reaction, dismissToken: next\.dismissToken \?\? current\.dismissToken \}/, "reaction-only updates merged into an active message must carry the latest dismiss token.");
assert.match(petWindowSource, /function getTransientDisplayDurationMs[\s\S]*?12_000[\s\S]*?message\.length \* 70/, "speech bubbles must stay visible longer for longer messages without becoming permanent.");
assert.match(defaultPetControllerSource, /getTransientDisplayDurationMs\(transientDisplay\)/, "default pet speech bubble timeout must be length-aware.");
assert.match(agentPetControllerSourceForLogging, /getTransientDisplayDurationMs\(preparedDisplay\)/, "agent pet speech bubble timeout must be length-aware.");
assert.match(defaultPetControllerSource, /getTransientDisplayDurationMs\(transientDisplay\)/, "default familiar speech bubble timeout must be length-aware.");
assert.match(agentPetControllerSourceForLogging, /getTransientDisplayDurationMs\(preparedDisplay\)/, "agent familiar speech bubble timeout must be length-aware.");
assert.match(petWindowSource, /function getTransientReactionAnimationMs/, "finite reaction animations must expose their own shorter lifetime.");
assert.match(petWindowSource, /function clearTransientReaction/, "finite reaction animations must be clearable while the bubble remains visible.");
assert.match(petWindowSource, /webContents\.send\("openpets:pet-reaction-state"/, "finite reaction animations must clear sprite state without reloading the bubble.");
assert.match(petPreloadSource, /openpets:pet-reaction-state/, "pet preload must accept in-place reaction state updates.");
assert.match(petWindowSource, /const webContents = window\.webContents[\s\S]*?const removeListeners = \(\): void => \{[\s\S]*?if \(!webContents\.isDestroyed\(\)\)/, "pet window cleanup must capture webContents and avoid touching destroyed BrowserWindow objects.");
assert.match(petWindowSource, /window\.on\("close", removeListeners\);\s*window\.once\("closed", removeListeners\);/, "pet window cleanup must run before and after close so agent lease release and Cmd/Ctrl+W are idempotent.");
assert.match(displaySource, /width:\s*220/, "pet windows must stay tightly bounded around pet and bubble.");
assert.match(displaySource, /height:\s*320/, "pet windows must be tall enough for adaptive long message bubbles at large pet scale without becoming a huge click shield.");
assert.match(petWindowSource, /function getBubbleClassName/, "pet bubbles must classify explicit messages by length.");
assert.match(petWindowSource, /is-long-message/, "pet bubbles must have a long-message layout.");
assert.match(petWindowSource, /is-very-long-message/, "pet bubbles must have a very-long-message layout for 140-character say messages.");
assert.match(petWindowSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent pet window background must not capture clicks or drags.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "pet windows must install real mouse passthrough and controlled drag behavior.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent pet window background must use OS-level mouse passthrough.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible pet and bubble hit targets must re-enable mouse handling.");
assert.match(petWindowSource, /openpets:pet-ready/, "pet windows must resync passthrough after each renderer reload.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag[\s\S]*?const rearmPassthrough[\s\S]*?process\.platform !== "win32"[\s\S]*?rearmWindowsMouseForwarding\(reason\)/, "Windows pet reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking.");
assert.match(petWindowSource, /scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+75ms`, 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+175ms`, 175\);/, "Windows pet reloads must retry mouse forwarding rearm after load settles.");
assert.match(petWindowSource, /openpets:pet-probe-hit-test/, "Windows pet reloads must probe current cursor hit target when mousemove forwarding is stale.");
assert.match(petWindowSource, /export function recoverPetMouseInterop/, "pet windows must expose a controlled mouse interop recovery hook for OS display and resume events.");
assert.match(petWindowSource, /petMouseInteropRecovery\.set\(window, scheduleMouseInteropRecovery\)/, "pet windows must register their mouse interop recovery callback.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag[\s\S]*?scheduleWindowsForwardingWatch[\s\S]*?rearmWindowsMouseForwarding\(reason, false\)[\s\S]*?scheduleWindowsForwardingWatch\(reason\)/, "Windows pet passthrough must keep rearming while idle so hover and drag recover after pet reloads without noisy logs.");
assert.match(petPreloadSource, /openpets:pet-probe-hit-test[\s\S]*?elementFromPoint\(clientX, clientY\)[\s\S]*?reportInteractiveHit/, "pet preload must answer main-process cursor hit-test probes.");
assert.match(petWindowSource, /did-finish-load", rearmAfterLoad/, "pet windows must re-arm mouse passthrough after every content load.");
assert.match(petWindowSource, /did-fail-load", handleLoadFailure/, "pet windows must restore passthrough after failed content loads.");
assert.match(petWindowSource, /window\.setIgnoreMouseEvents\(false\);[\s\S]*?await window\.loadFile/, "pet reloads must reset OS mouse passthrough before navigation.");
assert.match(petWindowSource, /function allocateWindowLoadSequence/, "pet content reloads must allocate request sequence before async rendering.");
assert.match(petWindowSource, /tryUpdateLoadedPetContent\(window, render, "default", sequence\)/, "default pet transient updates must avoid BrowserWindow reloads when the pet document is already loaded.");
assert.match(petPreloadSource, /openpets:pet-content-state[\s\S]*?document\.body\.innerHTML/, "pet preload must accept sanitized in-place content updates for transient bubbles and badges.");
assert.match(petWindowSource, /windowLoadChains\.set\(window, next\)/, "pet content reloads must serialize loadFile calls per BrowserWindow.");
assert.match(petWindowSource, /next\.catch\(\(\) => \{\}\)\.finally/, "pet content reload chain cleanup must not create unhandled rejections.");
assert.match(petWindowSource, /destroyed-after-write/, "pet content reloads must re-check destroyed windows after writing HTML.");
assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "drop-shadow/, "Windows pet windows must avoid CSS drop-shadow on transparent layered windows.");
assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "blur\(10px\)"/, "Windows pet windows must avoid backdrop-filter on transparent layered windows.");
assert.match(petWindowSource, /\.pet-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "pet dragging must avoid Electron draggable regions so right-click context menus work.");
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.");
assert.match(petWindowSource, /function installPetContextMenu/, "pet windows must install a native right-click context menu.");
assert.match(petWindowSource, /webContents\.on\("context-menu"/, "pet context menu must be handled in the Electron main process.");
assert.match(petWindowSource, /Menu\.buildFromTemplate/, "pet context menu must use a small native Electron menu.");
assert.doesNotMatch(petPreloadSource, /setIgnoreMouseEvents/, "pet preload must not call Electron window APIs directly.");
const agentPetControllerSource = readFileSync(join(appDir, "src", "agent-pet-controller.ts"), "utf8");
assert.match(petWindowSource, /webContents\.send\("familiaros:familiar-reaction-state"/, "finite reaction animations must clear sprite state without reloading the bubble.");
assert.match(petPreloadSource, /familiaros:familiar-reaction-state/, "familiar preload must accept in-place reaction state updates.");
assert.match(petWindowSource, /const webContents = window\.webContents[\s\S]*?const removeListeners = \(\): void => \{[\s\S]*?if \(!webContents\.isDestroyed\(\)\)/, "familiar window cleanup must capture webContents and avoid touching destroyed BrowserWindow objects.");
assert.match(petWindowSource, /window\.on\("close", removeListeners\);\s*window\.once\("closed", removeListeners\);/, "familiar window cleanup must run before and after close so agent lease release and Cmd/Ctrl+W are idempotent.");
assert.match(displaySource, /width:\s*220/, "familiar windows must stay tightly bounded around familiar and bubble.");
assert.match(displaySource, /height:\s*320/, "familiar windows must be tall enough for adaptive long message bubbles at large familiar scale without becoming a huge click shield.");
assert.match(petWindowSource, /function getBubbleClassName/, "familiar bubbles must classify explicit messages by length.");
assert.match(petWindowSource, /is-long-message/, "familiar bubbles must have a long-message layout.");
assert.match(petWindowSource, /is-very-long-message/, "familiar bubbles must have a very-long-message layout for 140-character say messages.");
assert.match(petWindowSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent familiar window background must not capture clicks or drags.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "familiar windows must install real mouse passthrough and controlled drag behavior.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent familiar window background must use OS-level mouse passthrough.");
assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible familiar and bubble hit targets must re-enable mouse handling.");
assert.match(petWindowSource, /familiaros:familiar-ready/, "familiar windows must resync passthrough after each renderer reload.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag[\s\S]*?const rearmPassthrough[\s\S]*?process\.platform !== "win32"[\s\S]*?rearmWindowsMouseForwarding\(reason\)/, "Windows familiar reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking.");
assert.match(petWindowSource, /scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+75ms`, 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+175ms`, 175\);/, "Windows familiar reloads must retry mouse forwarding rearm after load settles.");
assert.match(petWindowSource, /familiaros:familiar-probe-hit-test/, "Windows familiar reloads must probe current cursor hit target when mousemove forwarding is stale.");
assert.match(petWindowSource, /export function recoverPetMouseInterop/, "familiar windows must expose a controlled mouse interop recovery hook for OS display and resume events.");
assert.match(petWindowSource, /petMouseInteropRecovery\.set\(window, scheduleMouseInteropRecovery\)/, "familiar windows must register their mouse interop recovery callback.");
assert.match(petWindowSource, /function installMousePassthroughAndDrag[\s\S]*?scheduleWindowsForwardingWatch[\s\S]*?rearmWindowsMouseForwarding\(reason, false\)[\s\S]*?scheduleWindowsForwardingWatch\(reason\)/, "Windows familiar passthrough must keep rearming while idle so hover and drag recover after familiar reloads without noisy logs.");
assert.match(petPreloadSource, /familiaros:familiar-probe-hit-test[\s\S]*?elementFromPoint\(clientX, clientY\)[\s\S]*?reportInteractiveHit/, "familiar preload must answer main-process cursor hit-test probes.");
assert.match(petWindowSource, /did-finish-load", rearmAfterLoad/, "familiar windows must re-arm mouse passthrough after every content load.");
assert.match(petWindowSource, /did-fail-load", handleLoadFailure/, "familiar windows must restore passthrough after failed content loads.");
assert.match(petWindowSource, /window\.setIgnoreMouseEvents\(false\);[\s\S]*?await window\.loadFile/, "familiar reloads must reset OS mouse passthrough before navigation.");
assert.match(petWindowSource, /function allocateWindowLoadSequence/, "familiar content reloads must allocate request sequence before async rendering.");
assert.match(petWindowSource, /tryUpdateLoadedPetContent\(window, render, "default", sequence\)/, "default familiar transient updates must avoid BrowserWindow reloads when the familiar document is already loaded.");
assert.match(petPreloadSource, /familiaros:familiar-content-state[\s\S]*?document\.body\.innerHTML/, "familiar preload must accept sanitized in-place content updates for transient bubbles and badges.");
assert.match(petWindowSource, /windowLoadChains\.set\(window, next\)/, "familiar content reloads must serialize loadFile calls per BrowserWindow.");
assert.match(petWindowSource, /next\.catch\(\(\) => \{\}\)\.finally/, "familiar content reload chain cleanup must not create unhandled rejections.");
assert.match(petWindowSource, /destroyed-after-write/, "familiar content reloads must re-check destroyed windows after writing HTML.");
assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "drop-shadow/, "Windows familiar windows must avoid CSS drop-shadow on transparent layered windows.");
assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "blur\(10px\)"/, "Windows familiar windows must avoid backdrop-filter on transparent layered windows.");
assert.match(petWindowSource, /\.familiar-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "familiar dragging must avoid Electron draggable regions so right-click context menus work.");
assert.match(petPreloadSource, /familiaros:familiar-hit-test/, "familiar preload must report visible familiar and bubble hit testing for passthrough.");
assert.match(petPreloadSource, /familiaros:familiar-ready/, "familiar preload must report readiness after installing mouse handlers.");
assert.match(petPreloadSource, /familiaros:familiar-drag-start/, "familiar preload must start controlled familiar dragging from the sprite.");
assert.match(petPreloadSource, /familiaros:familiar-open-prompt/, "familiar preload must request the floating prompt window on double-click.");
assert.match(defaultPetControllerSource, /powerMonitor\.on\("resume", recoverDefaultPetWindowAfterResume\)/, "default familiar must recover mouse interop after Windows sleep or resume.");
assert.match(defaultPetControllerSource, /recoverDefaultPetMouseInterop\("display-change"\)/, "default familiar must recover mouse interop after monitor topology changes.");
assert.match(windowsSource, /recoverDefaultPetMouseInterop\("default-familiar-changed"\)/, "changing default familiar must recover mouse interop for dragging without app restart.");
assert.match(petWindowSource, /function installPetContextMenu/, "familiar windows must install a native right-click context menu.");
assert.match(petWindowSource, /webContents\.on\("context-menu"/, "familiar context menu must be handled in the Electron main process.");
assert.match(petWindowSource, /Menu\.buildFromTemplate/, "familiar context menu must use a small native Electron menu.");
assert.doesNotMatch(petPreloadSource, /setIgnoreMouseEvents/, "familiar preload must not call Electron window APIs directly.");
const agentPetControllerSource = readFileSync(join(appDir, "src", "agent-familiar-controller.ts"), "utf8");
const localIpcSource = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
assert.match(agentPetControllerSource, /dismissedAgentPets = new Set<string>/, "agent pets must remember manual close while leases remain active.");
assert.match(agentPetControllerSource, /dismissAgentPetForActiveLease/, "agent pet context-menu close must dismiss the pet for the active lease.");
assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dismissed agent pets must not reopen on later same-lease reactions.");
assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent pet lease cleanup must clear dismissal, timers, and hidden transient state.");
assert.match(localIpcSource, /handleLastExplicitLease/, "agent pet dismissal must clear when the explicit lease group ends.");
assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent pet state.");
assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit pet events as not shown.");
assert.match(updateCheckerSource, /alvinunreal\/openpets/, "GitHub release notice must check the public OpenPets repository.");
assert.match(agentPetControllerSource, /dismissedAgentPets = new Set<string>/, "agent familiars must remember manual close while leases remain active.");
assert.match(agentPetControllerSource, /dismissAgentPetForActiveLease/, "agent familiar context-menu close must dismiss the familiar for the active lease.");
assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dismissed agent familiars must not reopen on later same-lease reactions.");
assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent familiar lease cleanup must clear dismissal, timers, and hidden transient state.");
assert.match(localIpcSource, /handleLastExplicitLease/, "agent familiar dismissal must clear when the explicit lease group ends.");
assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent familiar state.");
assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit familiar events as not shown.");
assert.match(updateCheckerSource, /alvinunreal\/familiaros/, "GitHub release notice must check the public FamiliarOS repository.");
assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API.");
assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally.");
assert.match(traySource, /t\("tray\.updateAvailable"/, "tray menu must surface available updates.");
assert.match(enCatalogSource, /Update available:/, "English catalog must keep the update-available label.");
assert.match(windowsSource, /openpets:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(windowsSource, /openpets:get-reaction-animation-settings/, "settings window must be able to load reaction animation metadata.");
assert.match(windowsSource, /familiaros:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(windowsSource, /familiaros:get-reaction-animation-settings/, "settings window must be able to load reaction animation metadata.");
assert.match(windowsSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides.");
assert.match(windowsSource, /openpets-pet-preview/, "settings reaction preview must use a scoped internal pet preview protocol.");
assert.match(windowsSource, /openpets:open-update-release-page/, "settings window must be able to open the release page.");
assert.match(windowsSource, /familiaros-familiar-preview/, "settings reaction preview must use a scoped internal familiar preview protocol.");
assert.match(windowsSource, /familiaros: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(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Center preload must expose OpenAPI chat credential management.");
@ -191,22 +191,22 @@ assert.match(promptWindowSource, /sendOpenApiChatPrompt/, "Prompt window must su
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, /getPetsState/, "Control Center must include the familiars page data bridge.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include the integrations page.");
assert.match(petWindowSource, /--bubble-max-width:/, "pet window must define a scalable bubble max-width variable.");
assert.match(petWindowSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the pet window while allowing larger pets.");
assert.match(petWindowSource, /--bubble-max-width:/, "familiar window must define a scalable bubble max-width variable.");
assert.match(petWindowSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the familiar window while allowing larger familiars.");
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.");
assert.match(petWindowSource, /\.sprite, \.installed-sprite, \.bubble/, "reduced-motion CSS must include built-in and installed sprites.");
assert.match(petWindowSource, /function createAgentPetWindow[\s\S]*?installMotionStatePublisher\(window\)/, "agent pet windows must publish motion state so dragged non-default pets run.");
assert.match(petWindowSource, /loadExplicitPetContent[\s\S]*?state\.preferences\.petScale/, "explicit agent pet windows must use the saved pet scale preference.");
assert.match(petWindowSource, /interface AgentPetWindowOptions[\s\S]*?readonly scale: PetScaleValue/, "new agent pet windows must receive the current pet scale explicitly for their first render.");
assert.match(petWindowSource, /loadExplicitPetContent\(window, options\.petId, options\.display, options\.badge, dismissToken, options\.scale\)/, "agent pet first render must not fall back to the medium default scale.");
assert.match(agentPetControllerSourceForLogging, /function getPreferredPetScale\(\): PetScaleValue/, "agent pet reloads must share one explicit saved scale helper.");
assert.match(agentPetControllerSourceForLogging, /loadExplicitPetContent\(window, petId, display, badge, getCurrentDismissToken\(petId, display, badge\), scale\)/, "agent pet refreshes must pass the saved pet scale explicitly.");
assert.match(agentPetControllerSourceForLogging, /loadExplicitPetContent\(window, petId, preparedDisplay, statusBadges\.get\(petId\) \?\? null, preparedDisplay\.dismissToken, getPreferredPetScale\(\)\)/, "agent pet transient updates must pass the saved pet scale explicitly.");
assert.match(petWindowSource, /function createAgentPetWindow[\s\S]*?installMotionStatePublisher\(window\)/, "agent familiar windows must publish motion state so dragged non-default familiars run.");
assert.match(petWindowSource, /loadExplicitPetContent[\s\S]*?state\.preferences\.petScale/, "explicit agent familiar windows must use the saved familiar scale preference.");
assert.match(petWindowSource, /interface AgentPetWindowOptions[\s\S]*?readonly scale: PetScaleValue/, "new agent familiar windows must receive the current familiar scale explicitly for their first render.");
assert.match(petWindowSource, /loadExplicitPetContent\(window, options\.petId, options\.display, options\.badge, dismissToken, options\.scale\)/, "agent familiar first render must not fall back to the medium default scale.");
assert.match(agentPetControllerSourceForLogging, /function getPreferredPetScale\(\): PetScaleValue/, "agent familiar reloads must share one explicit saved scale helper.");
assert.match(agentPetControllerSourceForLogging, /loadExplicitPetContent\(window, petId, display, badge, getCurrentDismissToken\(petId, display, badge\), scale\)/, "agent familiar refreshes must pass the saved familiar scale explicitly.");
assert.match(agentPetControllerSourceForLogging, /loadExplicitPetContent\(window, petId, preparedDisplay, statusBadges\.get\(petId\) \?\? null, preparedDisplay\.dismissToken, getPreferredPetScale\(\)\)/, "agent familiar transient updates must pass the saved familiar scale explicitly.");
assert.match(mappingDoc, /\| 3 \| `waving` \| `waving`, Claude `Notification`\. \|/, "mapping docs must describe waving animation row and notification mapping.");
assert.match(mappingDoc, /reaction-animation-mapping\.ts/, "mapping docs must reference the shared reaction animation mapping source of truth.");
assert.match(mappingDoc, /overrid/i, "mapping docs must mention that reaction animation defaults can be overridden in Settings.");
@ -228,15 +228,15 @@ assert.match(enCatalogSource, /OpenCode/, "Control Center integrations must incl
assert.match(enCatalogSource, /Cursor/, "Control Center integrations must include Cursor.");
assert.match(enCatalogSource, /Pi/, "Control Center integrations must include Pi.");
assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "pet scale preference changes must refresh default and agent pet windows.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "familiar scale preference changes must refresh default and agent familiar windows.");
assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist.");
assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run.");
assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@open-pets/claude must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "client", "dist", "index.js")), "@open-pets/client must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "mcp", "dist", "index.js")), "@open-pets/mcp must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "cli", "dist", "index.js")), "@open-pets/cli must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "opencode", "dist", "plugin.js")), "@open-pets/opencode plugin must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "agent-events", "dist", "index.js")), "@open-pets/agent-events must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@familiaros/claude must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "client", "dist", "index.js")), "@familiaros/client must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "mcp", "dist", "index.js")), "@familiaros/mcp must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "cli", "dist", "index.js")), "@familiaros/cli must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "opencode", "dist", "plugin.js")), "@familiaros/opencode plugin must be built before packaging.");
assert.ok(existsSync(join(repoRoot, "packages", "agent-events", "dist", "index.js")), "@familiaros/agent-events must be built before packaging.");
if (process.argv.includes("--output")) {
checkPackageOutput();
@ -258,19 +258,19 @@ function checkPackageOutput(): void {
assertBundledOfficialPlugins(appResourceDir);
const appContents = join(appResourceDir, "app.asar.unpacked");
assert.ok(existsSync(appContents), "packaged app.asar.unpacked resources are missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "index.js")), "packaged @open-pets/claude runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js")), "packaged @open-pets/claude CLI runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "package.json")), "packaged @open-pets/claude package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "dist", "index.js")), "packaged @open-pets/client runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "package.json")), "packaged @open-pets/client package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js")), "packaged @open-pets/mcp runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "package.json")), "packaged @open-pets/mcp package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js")), "packaged @open-pets/cli runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js")), "packaged @open-pets/opencode plugin runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "package.json")), "packaged @open-pets/opencode package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "cursor", "dist", "index.js")), "packaged @open-pets/cursor runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "cursor", "package.json")), "packaged @open-pets/cursor package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "agent-events", "dist", "index.js")), "packaged @open-pets/agent-events runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "claude", "dist", "index.js")), "packaged @familiaros/claude runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "claude", "dist", "cli.js")), "packaged @familiaros/claude CLI runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "claude", "package.json")), "packaged @familiaros/claude package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "client", "dist", "index.js")), "packaged @familiaros/client runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "client", "package.json")), "packaged @familiaros/client package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "mcp", "dist", "index.js")), "packaged @familiaros/mcp runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "mcp", "package.json")), "packaged @familiaros/mcp package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "cli", "dist", "index.js")), "packaged @familiaros/cli runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "opencode", "dist", "plugin.js")), "packaged @familiaros/opencode plugin runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "opencode", "package.json")), "packaged @familiaros/opencode package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "cursor", "dist", "index.js")), "packaged @familiaros/cursor runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "cursor", "package.json")), "packaged @familiaros/cursor package metadata is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@familiaros", "agent-events", "dist", "index.js")), "packaged @familiaros/agent-events runtime is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@modelcontextprotocol", "sdk")), "packaged MCP SDK runtime dependency is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "zod", "index.cjs")), "packaged zod runtime dependency is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "yauzl", "index.js")), "packaged yauzl runtime dependency is missing.");
@ -281,10 +281,10 @@ function checkPackageOutput(): void {
assert.ok(existsSync(join(appContents, "node_modules", "@img", "sharp-win32-x64", "lib", "sharp-win32-x64.node")), "packaged Windows x64 sharp native binary is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@img", "sharp-linux-x64", "lib", "sharp-linux-x64.node")), "packaged Linux x64 sharp native binary is missing.");
assert.ok(existsSync(join(appContents, "node_modules", "@img", "sharp-darwin-x64", "lib", "sharp-darwin-x64.node")), "packaged macOS x64 sharp native binary is missing.");
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@familiaros", "mcp", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@familiaros", "cli", "dist", "index.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@familiaros", "opencode", "dist", "plugin.js"));
assertRegularNonSymlink(join(appContents, "node_modules", "@familiaros", "claude", "dist", "cli.js"));
assertCommandSmoke(appContents);
}
@ -349,7 +349,7 @@ function isInside(parent: string, child: string): boolean {
}
function checkCleanupHelper(): void {
const sentinel = join(appDir, "dist-electron", ".openpets-clean-sentinel");
const sentinel = join(appDir, "dist-electron", ".familiaros-clean-sentinel");
mkdirSync(dirname(sentinel), { recursive: true });
writeFileSync(sentinel, "stale", "utf8");
const result = spawnSync(process.execPath, [join(appDir, "scripts", "clean-package-output.cjs")], { cwd: appDir, encoding: "utf8" });
@ -370,9 +370,9 @@ function assertNonEmptyFile(path: string, message: string): void {
}
function assertBundledOfficialPlugins(resourceDir: string): void {
for (const id of ["openpets.reminders"]) {
for (const id of ["familiaros.reminders"]) {
const dir = join(resourceDir, "plugins", "official", id);
const manifestPath = join(dir, "openpets.plugin.json");
const manifestPath = join(dir, "familiaros.plugin.json");
assertNonEmptyFile(manifestPath, `packaged bundled plugin manifest is missing: ${id}`);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { entry?: string };
if (manifest.entry) assertNonEmptyFile(join(dir, manifest.entry), `packaged bundled plugin entry is missing: ${id}`);
@ -389,20 +389,20 @@ function assertSafeBundledSvg(path: string, message: string): void {
}
function assertCommandSmoke(appContents: string): void {
const mcpEntry = join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js");
const mcpEntry = join(appContents, "node_modules", "@familiaros", "mcp", "dist", "index.js");
const mcp = spawnSync(process.execPath, [mcpEntry, "--version"], { encoding: "utf8" });
assert.equal(mcp.status, 0, `packaged MCP command smoke failed: ${mcp.stderr || mcp.stdout}`);
const hookEntry = join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js");
const hook = spawnSync(process.execPath, [hookEntry, "hook", "--openpets-managed"], {
const hookEntry = join(appContents, "node_modules", "@familiaros", "claude", "dist", "cli.js");
const hook = spawnSync(process.execPath, [hookEntry, "hook", "--familiaros-managed"], {
input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }),
encoding: "utf8",
env: { ...process.env, OPENPETS_DISCOVERY_FILE: join(appContents, "missing-ipc.json") },
env: { ...process.env, FAMILIAROS_DISCOVERY_FILE: join(appContents, "missing-ipc.json") },
});
assert.equal(hook.status, 0, `packaged Claude hook command smoke failed: ${hook.stderr || hook.stdout}`);
assert.equal(hook.stdout, "");
const opencodePlugin = join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js");
const opencodePlugin = join(appContents, "node_modules", "@familiaros", "opencode", "dist", "plugin.js");
const plugin = spawnSync(process.execPath, ["--input-type=module", "--eval", `const mod = await import(${JSON.stringify(`file://${opencodePlugin}`)}); if (!mod.default?.server || !mod.default?.id) process.exit(2);`], { encoding: "utf8" });
assert.equal(plugin.status, 0, `packaged OpenCode plugin smoke failed: ${plugin.stderr || plugin.stdout}`);
}

View file

@ -1,13 +1,13 @@
/**
* Drift guard between the runtime plugin SDK and the published
* `@open-pets/plugin-sdk` type contract.
* `@familiaros/plugin-sdk` type contract.
*
* These are compile-time assertions: if the runtime's plugin-facing surface
* (namespaces) or the JavaScript permission set ever diverges from what the
* published package promises authors, `tsc` fails here. Keep the package and
* the bridge in lockstep.
*/
import type { OpenPetsContext, OpenPetsPermission } from "@open-pets/plugin-sdk";
import type { FamiliarOSContext, FamiliarOSPermission } from "@familiaros/plugin-sdk";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -20,12 +20,12 @@ import { pluginSdkAsyncRoutes, pluginSdkSyncRoutes, type PluginSdkRoute } from "
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// Every namespace the published SDK exposes (ctx.pet, ctx.schedule, …) must
// Every namespace the published SDK exposes (ctx.familiar, ctx.schedule, …) must
// exist on the runtime API, and the runtime must expose nothing extra.
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof OpenPetsContext>>;
type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof FamiliarOSContext>>;
// The JavaScript plugin permission union must match the published contract.
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, OpenPetsPermission>>;
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, FamiliarOSPermission>>;
type _HostRoutesMatch = Expect<Equal<keyof typeof sdkCallHandlers, PluginSdkRoute>>;
// Reference the aliases so unused-type tooling never strips the guard.

View file

@ -2,37 +2,37 @@ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readF
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
export interface ClaudeOpenPetsMemoryResult {
export interface ClaudeFamiliarOSMemoryResult {
readonly changed: boolean;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export interface ClaudeOpenPetsMemoryStatus {
export interface ClaudeFamiliarOSMemoryStatus {
readonly status: "installed" | "not_installed" | "error";
readonly message: string;
readonly claudeMdPath: string;
readonly openPetsMemoryPath: string;
}
export const openPetsClaudeImportLine = "@~/.claude/openpets.md";
export const openPetsClaudeImportLine = "@~/.claude/familiaros.md";
const openPetsImportStart = "<!-- OPENPETS:IMPORT:START -->";
const openPetsImportEnd = "<!-- OPENPETS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- OPENPETS:START -->";
const openPetsMemoryEnd = "<!-- OPENPETS:END -->";
const openPetsImportStart = "<!-- FAMILIAROS:IMPORT:START -->";
const openPetsImportEnd = "<!-- FAMILIAROS:IMPORT:END -->";
const openPetsMemoryStart = "<!-- FAMILIAROS:START -->";
const openPetsMemoryEnd = "<!-- FAMILIAROS:END -->";
const maxClaudeMemoryBytes = 1024 * 1024;
export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
export function installClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
mkdirSync(paths.claudeDir, { recursive: true, mode: 0o700 });
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
const nextOpenPetsMemory = upsertOpenPetsMemoryBlock(currentOpenPetsMemory, createOpenPetsMemoryBlock());
const openPetsChanged = currentOpenPetsMemory !== nextOpenPetsMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
const currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
const nextFamiliarOSMemory = upsertFamiliarOSMemoryBlock(currentFamiliarOSMemory, createFamiliarOSMemoryBlock());
const openPetsChanged = currentFamiliarOSMemory !== nextFamiliarOSMemory;
if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
const currentClaudeMd = readTextFile(paths.claudeMdPath);
const nextClaudeMd = ensureManagedImport(currentClaudeMd);
@ -42,7 +42,7 @@ export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemo
return { changed: openPetsChanged || claudeMdChanged, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult {
export function uninstallClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryResult {
const paths = getClaudeMemoryPaths(homeDir);
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
@ -55,18 +55,18 @@ export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMe
changed = true;
}
const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath);
if (currentOpenPetsMemory) {
const nextOpenPetsMemory = removeOpenPetsMemoryBlock(currentOpenPetsMemory);
if (nextOpenPetsMemory.trim().length === 0) {
const currentFamiliarOSMemory = readTextFile(paths.openPetsMemoryPath);
if (currentFamiliarOSMemory) {
const nextFamiliarOSMemory = removeFamiliarOSMemoryBlock(currentFamiliarOSMemory);
if (nextFamiliarOSMemory.trim().length === 0) {
if (hasUserOwnedImport) {
writePrivateTextFile(paths.openPetsMemoryPath, "");
} else {
rmSync(paths.openPetsMemoryPath, { force: true });
}
changed = true;
} else if (nextOpenPetsMemory !== currentOpenPetsMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory);
} else if (nextFamiliarOSMemory !== currentFamiliarOSMemory) {
writePrivateTextFile(paths.openPetsMemoryPath, nextFamiliarOSMemory);
changed = true;
}
}
@ -74,26 +74,26 @@ export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMe
return { changed, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
export function doctorClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryStatus {
export function doctorClaudeFamiliarOSMemory(homeDir: string): ClaudeFamiliarOSMemoryStatus {
const paths = getClaudeMemoryPaths(homeDir);
try {
assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath);
const claudeMd = readTextFile(paths.claudeMdPath);
const openPetsMemory = readTextFile(paths.openPetsMemoryPath);
const hasImport = hasManagedImport(claudeMd) || hasImportLineOutsideManagedBlock(claudeMd);
const hasInstructions = createOpenPetsBlockPattern().test(openPetsMemory) || /openpets_say|OpenPets MCP/i.test(openPetsMemory);
const hasInstructions = createFamiliarOSBlockPattern().test(openPetsMemory) || /familiaros_say|FamiliarOS MCP/i.test(openPetsMemory);
if (hasImport && hasInstructions) {
return { status: "installed", message: "Claude will load OpenPets instructions from ~/.claude/openpets.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "installed", message: "Claude will load FamiliarOS instructions from ~/.claude/familiaros.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasImport) {
return { status: "not_installed", message: "Claude imports OpenPets instructions, but the OpenPets memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "Claude imports FamiliarOS instructions, but the FamiliarOS memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
if (hasInstructions) {
return { status: "not_installed", message: "OpenPets instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "FamiliarOS instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
return { status: "not_installed", message: "Claude OpenPets instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "not_installed", message: "Claude FamiliarOS instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "Claude OpenPets instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
return { status: "error", message: error instanceof Error ? error.message : "Claude FamiliarOS instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath };
}
}
@ -102,12 +102,12 @@ export function getClaudeMemoryPaths(homeDir: string): { readonly claudeDir: str
return {
claudeDir,
claudeMdPath: join(claudeDir, "CLAUDE.md"),
openPetsMemoryPath: join(claudeDir, "openpets.md"),
openPetsMemoryPath: join(claudeDir, "familiaros.md"),
};
}
export function createOpenPetsMemoryBlock(): string {
return `${openPetsMemoryStart}\n## OpenPets\n\nOpenPets MCP tools may be available.\n\nUse OpenPets as a short visible status channel for meaningful coding progress:\n- Use \`openpets_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`openpets_react\` for small visual or emotional feedback.\n- Use \`openpets_status\` only when checking availability or the targeted pet.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`;
export function createFamiliarOSMemoryBlock(): string {
return `${openPetsMemoryStart}\n## FamiliarOS\n\nFamiliarOS MCP tools may be available.\n\nUse FamiliarOS as a short visible status channel for meaningful coding progress:\n- Use \`familiaros_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`familiaros_react\` for small visual or emotional feedback.\n- Use \`familiaros_status\` only when checking availability or the targeted familiar.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`;
}
export function ensureImportLine(source: string, importLine: string): string {
@ -139,17 +139,17 @@ export function removeImportLine(source: string, importLine: string): string {
.replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : ""));
}
export function upsertOpenPetsMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
export function upsertFamiliarOSMemoryBlock(source: string, block: string): string {
const withoutBlocks = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, "");
return withoutBlocks ? `${withoutBlocks}\n\n${block}` : block;
}
export function removeOpenPetsMemoryBlock(source: string): string {
const withoutBlock = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
export function removeFamiliarOSMemoryBlock(source: string): string {
const withoutBlock = source.replace(createFamiliarOSBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim();
return withoutBlock ? `${withoutBlock}\n` : "";
}
function createOpenPetsBlockPattern(): RegExp {
function createFamiliarOSBlockPattern(): RegExp {
return new RegExp(`${escapeRegExp(openPetsMemoryStart)}[\\s\\S]*?${escapeRegExp(openPetsMemoryEnd)}\\n?`, "g");
}
@ -174,7 +174,7 @@ function assertSafeClaudeMemoryPaths(claudeDir: string, claudeMdPath: string, op
if (!existsSync(path)) continue;
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for FamiliarOS to update safely.");
}
}
@ -202,7 +202,7 @@ function assertSafeWriteTarget(path: string): void {
if (!existsSync(path)) return;
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely.");
if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for FamiliarOS to update safely.");
}
function escapeRegExp(value: string): string {

View file

@ -2,17 +2,17 @@
## Responsibility
Core TypeScript source for the OpenPets desktop application. Organized into: lifecycle management, state persistence, Control Center and pet windows, IPC server, agent integrations, pet installation/management, and declarative plus JavaScript plugin runtimes.
Core TypeScript source for the FamiliarOS desktop application. Organized into: lifecycle management, state persistence, Control Center and familiar windows, IPC server, agent integrations, familiar installation/management, and declarative plus JavaScript plugin runtimes.
## Design
- **Modular Controllers**: Separate controllers for default pet vs agent pets (lease-based)
- **Modular Controllers**: Separate controllers for default familiar vs agent familiars (lease-based)
- **Protocol-First IPC**: Versioned JSON protocol over TCP/Unix sockets with token auth
- **Defensive I/O**: All file operations use temp+rename for atomicity, path traversal validation, symlink checks
- **Validation at Boundaries**: Catalog, ZIP entries, pet metadata, and IPC params all strictly validated
- **Lease Pattern**: Agent pets use expiring leases (15s TTL) with heartbeats; default pet is persistent
- **Sandboxed Renderers**: Control Center loads the Vite React/Tailwind bundle through a hardened BrowserWindow and narrow preload bridge; transparent pet windows and plugin SDK host windows stay separate
- **Structured Logging**: Scoped logging (app, ipc, lease, pet.*, state, tray, ui) with log rotation and redaction
- **Validation at Boundaries**: Catalog, ZIP entries, familiar metadata, and IPC params all strictly validated
- **Lease Pattern**: Agent familiars use expiring leases (15s TTL) with heartbeats; default familiar is persistent
- **Sandboxed Renderers**: Control Center loads the Vite React/Tailwind bundle through a hardened BrowserWindow and narrow preload bridge; transparent familiar windows and plugin SDK host windows stay separate
- **Structured Logging**: Scoped logging (app, ipc, lease, familiar.*, state, tray, ui) with log rotation and redaction
- **Reaction Animation Mapping**: User-configurable mapping from reaction types to sprite animation states
- **Plugin Runtimes**: Plugins use validated manifests, approved permissions, persisted config, safe path checks, declarative timer-triggered actions, or sandboxed JavaScript entry modules through the SDK bridge.
@ -33,25 +33,25 @@ main.ts
**IPC Request Flow**:
```
local-ipc.ts → parseIpcRequest() → handleRequest()
├── hello/status/pets.list/pets.install
├── hello/status/familiars.list/familiars.install
└── lease.acquire/heartbeat/release
└── lease-manager.ts
├── resolveTarget() (default vs explicit pet)
├── onFirstExplicitLease → agent-pet-controller.showAgentPet()
└── onLastExplicitLease → agent-pet-controller.closeAgentPetIfOpen()
├── resolveTarget() (default vs explicit familiar)
├── onFirstExplicitLease → agent-familiar-controller.showAgentPet()
└── onLastExplicitLease → agent-familiar-controller.closeAgentPetIfOpen()
└── Logging via logger.ts (ipc, lease scopes)
```
**Pet Display Flow**:
**Familiar Display Flow**:
```
pet-window.ts
familiar-window.ts
├── createDefaultPetWindow() / createAgentPetWindow()
├── loadDefaultPetContent() / loadExplicitPetContent()
│ ├── HTML generation with CSS sprite animation
│ ├── reaction-animation-mapping.ts (resolveReactionSpriteState)
│ ├── reaction-messages.ts (pickReactionMessage for bubbles)
│ └── Speech bubbles with status badges
└── pet-preload.cjs (renderer IPC for drag/click-through)
└── familiar-preload.cjs (renderer IPC for drag/click-through)
```
**Agent Setup Flow**:
@ -62,20 +62,20 @@ windows.ts (IPC handlers)
├── runAgentSetupAction()
│ ├── configure/replace/remove (MCP commands)
│ ├── install-memory (claude-memory.ts)
│ └── install-hooks/uninstall-hooks/doctor-hooks (@open-pets/claude)
├── OpenCode global config management (@open-pets/opencode)
└── Cursor global MCP config management (@open-pets/cursor)
│ └── install-hooks/uninstall-hooks/doctor-hooks (@familiaros/claude)
├── OpenCode global config management (@familiaros/opencode)
└── Cursor global MCP config management (@familiaros/cursor)
```
**Pet Installation Flow**:
**Familiar Installation Flow**:
```
pet-installation.ts
familiar-installation.ts
├── installPet()
│ ├── getCatalogPet() → catalog.ts
│ ├── downloadPetZip() → validate ZIP magic
│ ├── extractPetZip() → yauzl with entry validation
│ └── installPetState() → app-state.ts
└── importCodexPet() → codex-pets.ts
└── importCodexPet() → codex-familiars.ts
```
**Control Center Flow**:
@ -83,77 +83,77 @@ pet-installation.ts
tray.ts → openControlCenterWindow(route) → windows.ts
├── hardened BrowserWindow loads Vite renderer or packaged dist/renderer/index.html
├── control-center-preload.cjs exposes page-specific APIs
├── Dashboard snapshot: default pet, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Pets/Integrations/Plugins/Settings
├── Dashboard snapshot: default familiar, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Familiars/Integrations/Plugins/Settings
```
**Plugin Flow**:
```
main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, ElectronPluginJsHost).start()
├── plugin-state.ts reads/writes userData/openpets-plugin-state.json
├── plugin-state.ts reads/writes userData/familiaros-plugin-state.json
├── plugin-runtime.ts reloads enabled manifests
│ ├── declarative runtime schedules timer triggers
│ ├── plugin-js-host.ts starts hidden sandboxed BrowserWindow hosts for JavaScript plugins
│ └── plugin-sdk-bridge.ts → plugin-pet-api.ts/default-pet-controller plus schedules, storage, commands, status, logs, and restricted network
│ └── plugin-sdk-bridge.ts → plugin-familiar-api.ts/default-familiar-controller plus schedules, storage, commands, status, logs, and restricted network
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads
└── lifecycle.ts → stopPluginService() on quit
Control Center plugins route:
tray.ts → openControlCenterWindow("plugins") → windows.ts → renderer React app
└── openpets:plugins-* IPC handlers call PluginService methods
└── familiaros:plugins-* IPC handlers call PluginService methods
Catalog install/update:
plugin-catalog.ts → plugin-catalog-validation.ts
└── plugin-package.ts downloads HTTPS ZIP, validates SHA-256, extracts root manifest only, and installs to userData/plugins/{id}
Local development load:
plugin-local-loader.ts validates selected folder manifest and snapshots only openpets.plugin.json to userData/plugins-dev/{id}
plugin-local-loader.ts validates selected folder manifest and snapshots only familiaros.plugin.json to userData/plugins-dev/{id}
```
## Integration Points
- **Within src/**:
- `main.ts` → all modules (orchestrator), including `ElectronPluginJsHost` for JavaScript plugins
- `local-ipc.ts``lease-manager.ts``agent-pet-controller.ts`
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-pets.ts`, `update-checker.ts` for Control Center route snapshots/actions
- `local-ipc.ts``lease-manager.ts``agent-familiar-controller.ts`
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-familiars.ts`, `update-checker.ts` for Control Center route snapshots/actions
- `windows.ts``plugin-service.ts` for Control Center plugin UI IPC, plugin commands, and Dashboard plugin health
- `pet-window.ts` ↔ `default-pet-controller.ts`, `agent-pet-controller.ts`
- `pet-installation.ts` ↔ `app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `familiar-window.ts` ↔ `default-familiar-controller.ts`, `agent-familiar-controller.ts`
- `familiar-installation.ts` ↔ `app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `plugin-service.ts``plugin-state.ts`, `plugin-runtime.ts`, `plugin-catalog.ts`, `plugin-package.ts`, `plugin-local-loader.ts`, `plugin-js-host.ts`, `plugin-sdk-bridge.ts`
- **To packages/**:
- `@open-pets/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
- `@open-pets/opencode`: `prepareOpenCodeGlobalSetup`, `doctorOpenCodeGlobalSetup`
- `@open-pets/cursor`: `planCursorMcpInstall`, `executeCursorMcpWrite`, `buildCursorRulesPreview`, etc.
- `@open-pets/cli`: Version lookup for bundled mode
- `@familiaros/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
- `@familiaros/opencode`: `prepareOpenCodeGlobalSetup`, `doctorOpenCodeGlobalSetup`
- `@familiaros/cursor`: `planCursorMcpInstall`, `executeCursorMcpWrite`, `buildCursorRulesPreview`, etc.
- `@familiaros/cli`: Version lookup for bundled mode
- **To System**:
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, plugin storage JSON, `~/.codex/pets/`, `~/.claude/`, `~/.opencode/`
- Network: `fetch()` to openpets.dev, GitHub API, plugin catalog at `https://openpets.dev/plugins/catalog.v1.json`, plugin ZIPs restricted to `https://zip.openpets.dev/plugins/`
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, plugin storage JSON, `~/.codex/familiars/`, `~/.claude/`, `~/.opencode/`
- Network: `fetch()` to familiaros.dev, GitHub API, plugin catalog at `https://familiaros.dev/plugins/catalog.v1.json`, plugin ZIPs restricted to `https://zip.familiaros.dev/plugins/`
- Processes: `spawn()` for `claude`, `opencode`, `node`
## Key Modules
**Core**:
- `main.ts`: Entry, single-instance lock, bootstrap sequence, JavaScript plugin host construction
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and pet windows on quit
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and familiar windows on quit
- `state.ts`: Simple shell pause state
- `app-state.ts`: Persistent JSON state with V1 schema, atomic writes, reaction animation overrides
- `app-state-core.ts`: Pet scale options, onboarding normalization
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet.default, pet.agent, pet.window, state, tray, ui), log rotation, redaction
- `app-state-core.ts`: Familiar scale options, onboarding normalization
- `logger.ts`: Structured logging with scopes (app, ipc, lease, familiar.default, familiar.agent, familiar.window, state, tray, ui), log rotation, redaction
**UI**:
- `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, route-targeted Control Center entries, logs folder
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/pet/settings UI IPC endpoints, and scoped internal protocols
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/familiar/settings UI IPC endpoints, and scoped internal protocols
- `assets.ts`: Tray icon loading with generated fallback
- `display.ts`: Screen geometry helpers, pet window positioning
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Pets, Integrations, Plugins, and Settings.
- `display.ts`: Screen geometry helpers, familiar window positioning
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Familiars, Integrations, Plugins, and Settings.
**Pets**:
- `pet-window.ts`: Window creation (transparent, frameless, always-on-top), HTML/CSS generation, sprite animation states, speech bubbles, status badges, transient displays
- `default-pet-controller.ts`: Default pet visibility, position persistence, transient reactions, status badges, logging
- `agent-pet-controller.ts`: Lease-triggered pet windows, dismissal tracking, transient displays, status badges, logging
- `built-in-pet.ts`: Built-in pet constant
**Familiars**:
- `familiar-window.ts`: Window creation (transparent, frameless, always-on-top), HTML/CSS generation, sprite animation states, speech bubbles, status badges, transient displays
- `default-familiar-controller.ts`: Default familiar visibility, position persistence, transient reactions, status badges, logging
- `agent-familiar-controller.ts`: Lease-triggered familiar windows, dismissal tracking, transient displays, status badges, logging
- `built-in-familiar.ts`: Built-in familiar constant
- `reaction-messages.ts`: Message pools for each reaction type
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping, user-configurable overrides, sprite state definitions
@ -164,32 +164,32 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
- `lease-manager.ts`: Lease lifecycle (acquire, heartbeat, release, cleanup), target resolution
**Installation**:
- `pet-installation.ts`: ZIP download, yauzl extraction with safety limits, pet validation
- `pet-paths.ts`: Safe path resolution for pet directories
- `codex-pets.ts`: Import from `~/.codex/pets/` with validation
- `codex-pets-core.ts`: Codex metadata validation constants
- `familiar-installation.ts`: ZIP download, yauzl extraction with safety limits, familiar validation
- `familiar-paths.ts`: Safe path resolution for familiar directories
- `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation
- `codex-familiars-core.ts`: Codex metadata validation constants
- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback
- `catalog-validation.ts`: CatalogV2/V3 schema validation
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
**Plugins**:
- `plugin-manifest.ts`: Manifest V1/V2 schema/types and validation for declarative and JavaScript runtimes, permissions (`timer`/`schedule`, `pet:speak`, `pet:reaction`, `storage`, `status`, `commands`, `network`), config schema, timer triggers, entry files, and pet actions.
- `plugin-manifest.ts`: Manifest V1/V2 schema/types and validation for declarative and JavaScript runtimes, permissions (`timer`/`schedule`, `familiar:speak`, `familiar:reaction`, `storage`, `status`, `commands`, `network`), config schema, timer triggers, entry files, and familiar actions.
- `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching.
- `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references.
- `plugin-state.ts`: Persistent plugin state store (`openpets-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timer triggers, starts/stops JavaScript plugin hosts, verifies approved permissions, exposes public command/status state, validates actions, schedules cancellable timers, and marks broken plugins on validation/action failure.
- `plugin-pet-api.ts`: Narrow adapter from plugin actions to default pet external `say`/`react` controller calls.
- `plugin-familiar-api.ts`: Narrow adapter from plugin actions to default familiar external `say`/`react` controller calls.
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, catalog install/update, local load, uninstall, permission prompts, compatibility checks, JavaScript host/SDK bridge integration, and runtime reloads.
- `plugin-catalog.ts`: Remote plugin catalog fetch with timeout, redirect rejection, response size cap, cache, and refresh support.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum OpenPets version.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum FamiliarOS version.
- `plugin-package.ts`: Catalog plugin package download/install with HTTPS host/path allowlist, SHA-256 verification, ZIP size/entry restrictions, manifest/catalog consistency checks, and safe uninstall path resolution.
- `plugin-local-loader.ts`: Developer loader that validates a selected local folder and snapshots only the manifest into `plugins-dev` with symlink/path/size protections.
- `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, SDK IPC tokening, registration handshake, config listener cleanup, and teardown.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for pet speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for familiar speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
**Agent Integration**:
- `agent-setup.ts`: Claude/OpenCode/Cursor detection, MCP configuration, hooks management, action journaling
- `claude-memory.ts`: Claude instructions file management (`~/.claude/openpets.md`)
- `claude-memory.ts`: Claude instructions file management (`~/.claude/familiaros.md`)
- `update-checker.ts`: GitHub release polling, update status
- `update-version.ts`: Version parsing and comparison
@ -203,16 +203,16 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
| Source | Destination | Data |
|--------|-------------|------|
| Catalog API | `catalog.ts` | `CatalogV2/V3` JSON with pagination |
| ZIP Download | `pet-installation.ts` | Extracted to `userData/pets/{id}/` |
| `app-state.ts` | `userData/openpets-state.json` | Atomic JSON writes with reaction animation overrides |
| CLI via IPC | `local-ipc.ts` | `pet.react`, `pet.say`, `lease.*` |
| `lease-manager.ts` | `agent-pet-controller.ts` | Show/close agent pets |
| ZIP Download | `familiar-installation.ts` | Extracted to `userData/familiars/{id}/` |
| `app-state.ts` | `userData/familiaros-state.json` | Atomic JSON writes with reaction animation overrides |
| CLI via IPC | `local-ipc.ts` | `familiar.react`, `familiar.say`, `lease.*` |
| `lease-manager.ts` | `agent-familiar-controller.ts` | Show/close agent familiars |
| `windows.ts` | Renderer | State snapshots via IPC invoke |
| `agent-setup.ts` | Claude/OpenCode/Cursor CLI | MCP add/remove, config writes |
| All modules | `logger.ts` | Structured logs to `userData/logs/openpets.log` |
| All modules | `logger.ts` | Structured logs to `userData/logs/familiaros.log` |
| Plugin catalog | `plugin-catalog.ts`/`plugin-service.ts` | Discoverable plugin metadata filtered by app version and install state |
| Plugin ZIP/local folder | `plugin-package.ts`/`plugin-local-loader.ts` | Validated manifest snapshot installed under `userData/plugins*` |
| `plugin-state.ts` | `userData/openpets-plugin-state.json` | Installed plugins, enabled flag, approved permissions, config, broken status |
| Control Center renderer | `control-center-preload.cjs`/`windows.ts` | Narrow Dashboard/Pets/Integrations/Plugins/Settings snapshots and route-targeted actions |
| `plugin-runtime.ts` | `plugin-pet-api.ts`/`plugin-js-host.ts`/`plugin-sdk-bridge.ts` | Declarative timers and JavaScript SDK actions on default pet, schedules, storage, commands, status, logs, and network |
| `plugin-state.ts` | `userData/familiaros-plugin-state.json` | Installed plugins, enabled flag, approved permissions, config, broken status |
| Control Center renderer | `control-center-preload.cjs`/`windows.ts` | Narrow Dashboard/Familiars/Integrations/Plugins/Settings snapshots and route-targeted actions |
| `plugin-runtime.ts` | `plugin-familiar-api.ts`/`plugin-js-host.ts`/`plugin-sdk-bridge.ts` | Declarative timers and JavaScript SDK actions on default familiar, schedules, storage, commands, status, logs, and network |
| Plugins renderer | `windows.ts`/`plugin-service.ts` | Snapshot, enable, config, command, reload, install/update/uninstall, local-load operations |

View file

@ -11,13 +11,13 @@ export interface CodexPetMetadata {
}
export function validateCodexPetMetadata(value: unknown, folderName: string): CodexPetMetadata {
if (!isSafeCodexPetId(folderName)) throw new Error("Codex pet folder name is invalid.");
if (!isRecord(value)) throw new Error("pet.json must be an object.");
if (value.id !== folderName || typeof value.id !== "string") throw new Error("Codex pet id must match its folder name.");
if (!isSafeCodexPetId(value.id)) throw new Error("Codex pet id is invalid.");
if (typeof value.displayName !== "string" || value.displayName.trim().length === 0 || value.displayName.length > 80) throw new Error("Codex pet displayName is invalid.");
if (typeof value.description !== "string" || value.description.trim().length === 0 || value.description.length > 500) throw new Error("Codex pet description is invalid.");
if (value.spritesheetPath !== "spritesheet.webp") throw new Error("Codex pet spritesheetPath must be spritesheet.webp.");
if (!isSafeCodexPetId(folderName)) throw new Error("Codex familiar folder name is invalid.");
if (!isRecord(value)) throw new Error("familiar.json must be an object.");
if (value.id !== folderName || typeof value.id !== "string") throw new Error("Codex familiar id must match its folder name.");
if (!isSafeCodexPetId(value.id)) throw new Error("Codex familiar id is invalid.");
if (typeof value.displayName !== "string" || value.displayName.trim().length === 0 || value.displayName.length > 80) throw new Error("Codex familiar displayName is invalid.");
if (typeof value.description !== "string" || value.description.trim().length === 0 || value.description.length > 500) throw new Error("Codex familiar description is invalid.");
if (value.spritesheetPath !== "spritesheet.webp") throw new Error("Codex familiar spritesheetPath must be spritesheet.webp.");
return {
id: value.id,
displayName: value.displayName.trim(),

View file

@ -3,18 +3,18 @@ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile }
import { homedir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
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";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js";
import { getAppStateSnapshot, installPetState, type FamiliarOSStateV1 } from "./app-state.js";
import { maxCodexPetJsonBytes, maxCodexPets, maxCodexSpritesheetBytes, maxCodexThumbnailSourceBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-familiars-core.js";
import { withPetOperation } from "./familiar-installation.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./familiar-paths.js";
const codexPetsRoot = join(homedir(), ".codex", "pets");
const codexPetsRoot = join(homedir(), ".codex", "familiars");
const codexThumbnailCache = new Map<string, string>();
let sharpFactoryPromise: Promise<any | null> | null = null;
export interface CodexPetUiState {
readonly source: "codex";
readonly pets: readonly CodexPetUiItem[];
readonly familiars: readonly CodexPetUiItem[];
readonly error?: string;
}
@ -30,32 +30,32 @@ export async function getCodexPetsUiState(): Promise<CodexPetUiState> {
try {
const root = await validateCodexRoot();
const entries = (await readdir(codexPetsRoot, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
const pets: CodexPetUiItem[] = [];
const familiars: CodexPetUiItem[] = [];
let attemptedDirectories = 0;
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
attemptedDirectories += 1;
if (attemptedDirectories > maxCodexPets) break;
const pet = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (pet) {
pets.push(pet);
const familiar = await tryReadCodexPet(root, join(root, entry.name), entry.name);
if (familiar) {
familiars.push(familiar);
}
}
pets.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", pets };
familiars.sort((left, right) => left.displayName.localeCompare(right.displayName));
return { source: "codex", familiars };
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", pets: [] };
return { source: "codex", pets: [], error: error instanceof Error ? error.message : "Codex pets unavailable." };
if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", familiars: [] };
return { source: "codex", familiars: [], error: error instanceof Error ? error.message : "Codex familiars unavailable." };
}
}
export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
export async function importCodexPet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
throw new Error(`Familiar is already installed: ${petId}`);
}
const root = await validateCodexRoot();
@ -73,13 +73,13 @@ export async function importCodexPet(petId: string): Promise<OpenPetsStateV1> {
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "familiar.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
await validateInstalledRegularFile(join(finalDir, "familiar.json"));
return installPetState({
id: metadata.id,
displayName: metadata.displayName,
@ -108,10 +108,10 @@ async function tryReadCodexPet(root: string, dir: string, folderName: string): P
displayName: metadata.displayName,
description: metadata.description,
preview,
spritesheet: `openpets-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
spritesheet: `familiaros-codex://spritesheet/${encodeURIComponent(metadata.id)}`,
};
} catch (error) {
console.error(`Skipping invalid Codex pet at ${dir}.`, error);
console.error(`Skipping invalid Codex familiar at ${dir}.`, error);
return null;
}
}
@ -127,8 +127,8 @@ export async function readCodexPetSpritesheet(petId: string): Promise<Buffer> {
async function readCodexPetMetadata(root: string, dir: string, folderName: string): Promise<CodexPetMetadata> {
await assertCodexPetDirectory(root, dir);
assertSafePetId(folderName);
const petJson = join(dir, "pet.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const petJson = join(dir, "familiar.json");
const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
const metadata = validateCodexPetMetadata(parsed, folderName);
assertSafePetId(metadata.id);
return metadata;
@ -181,23 +181,23 @@ async function loadSharpFactory(): Promise<any | null> {
async function validateCodexRoot(): Promise<string> {
const root = resolve(codexPetsRoot);
const rootStats = await lstat(root);
if (rootStats.isSymbolicLink()) throw new Error("Codex pets root cannot be a symlink.");
if (!rootStats.isDirectory()) throw new Error("Codex pets path is not a directory.");
if (rootStats.isSymbolicLink()) throw new Error("Codex familiars root cannot be a symlink.");
if (!rootStats.isDirectory()) throw new Error("Codex familiars path is not a directory.");
const realRoot = await realpath(root);
if (realRoot !== root) throw new Error("Codex pets root path is not canonical.");
if (realRoot !== root) throw new Error("Codex familiars root path is not canonical.");
return root;
}
async function assertCodexPetDirectory(root: string, target: string): Promise<void> {
const resolvedTarget = resolve(target);
if (resolvedTarget === root || !resolvedTarget.startsWith(`${root}${sep}`) || basename(resolvedTarget).startsWith(".")) {
throw new Error("Resolved path escapes Codex pets directory.");
throw new Error("Resolved path escapes Codex familiars directory.");
}
const dirStats = await lstat(resolvedTarget);
if (dirStats.isSymbolicLink()) throw new Error("Codex pet directory cannot be a symlink.");
if (!dirStats.isDirectory()) throw new Error("Codex pet path must be a directory.");
if (dirStats.isSymbolicLink()) throw new Error("Codex familiar directory cannot be a symlink.");
if (!dirStats.isDirectory()) throw new Error("Codex familiar path must be a directory.");
const realTarget = await realpath(resolvedTarget);
if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex pet directory escapes Codex pets root.");
if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex familiar directory escapes Codex familiars root.");
}
async function readRegularFile(path: string, maxBytes: number, label: string): Promise<Buffer> {
@ -218,8 +218,8 @@ async function readRegularFile(path: string, maxBytes: number, label: string): P
async function validateInstalledRegularFile(path: string): Promise<void> {
const stats = await lstat(path);
if (stats.isSymbolicLink()) throw new Error("Imported pet file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported pet file must be a regular file.");
if (stats.isSymbolicLink()) throw new Error("Imported familiar file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported familiar file must be a regular file.");
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {

View file

@ -4,8 +4,8 @@ import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, se
import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { debug, info } from "./logger.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, isPetWindowDragging, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, type PetPluginBubbles, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
import { transientDisplayMs, type FamiliarOSReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, isPetWindowDragging, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, type PetPluginBubbles, type PetStatusBadgeReaction, type PetTransientDisplay } from "./familiar-window.js";
import { PetBubbleArbiter, type ActiveBubble, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
@ -27,7 +27,7 @@ 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 reaction?: FamiliarOSReaction;
readonly fullMessage?: boolean;
readonly sticky?: boolean;
};
@ -35,7 +35,7 @@ export type InternalPetMessageOptions = {
export type PetReactionOptions = { readonly showMessage?: boolean };
// Plugin bubble slots (SDK v3): the arbiter decides what each slot shows; the
// sink merges its decisions into the default pet render.
// sink merges its decisions into the default familiar render.
let pluginTransientBubble: ActiveBubble | null = null;
let pluginPinnedBubble: ActiveBubble | null = null;
@ -43,13 +43,13 @@ const defaultPetBubbleSink: PetBubbleSink = {
present(slot, content) {
if (slot === "pinned") pluginPinnedBubble = content;
else pluginTransientBubble = content;
debug("pet.default", "plugin bubble slot", { slot, token: content?.token ?? null, pluginId: content?.pluginId });
debug("familiar.default", "plugin bubble slot", { slot, token: content?.token ?? null, pluginId: content?.pluginId });
if (content) showDefaultPetForExternalEvent();
refreshDefaultPetContent();
},
};
/** The default pet's bubble arbiter — the Electron bubbles capability targets this. */
/** The default familiar's bubble arbiter — the Electron bubbles capability targets this. */
export const defaultPetBubbleArbiter = new PetBubbleArbiter(defaultPetBubbleSink);
export function getDefaultPetPluginBubbles(): PetPluginBubbles | null {
@ -64,7 +64,7 @@ export function showDefaultPet(): void {
function showDefaultPetWindow(source: "user" | "external-event"): void {
const window = getOrCreateDefaultPetWindow();
info("pet.default", "show requested", { source, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), paused, petId: getAppStateSnapshot().preferences.defaultPetId });
info("familiar.default", "show requested", { source, windowId: window.id, visible: window.isVisible(), minimized: window.isMinimized(), paused, petId: getAppStateSnapshot().preferences.defaultPetId });
if (window.isMinimized()) {
window.restore();
@ -77,11 +77,11 @@ export function hideDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: false });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "hide skipped", { reason: "no-window" });
debug("familiar.default", "hide skipped", { reason: "no-window" });
return;
}
info("pet.default", "hide requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
info("familiar.default", "hide requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
defaultPetWindow.hide();
}
@ -92,7 +92,7 @@ export function isDefaultPetVisible(): boolean {
export function setDefaultPetPaused(nextPaused: boolean): void {
paused = nextPaused;
info("pet.default", "pause changed", { paused });
info("familiar.default", "pause changed", { paused });
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;
@ -111,25 +111,25 @@ export function getDefaultPetWindowForPlugins(): BrowserWindow | null {
export function refreshDefaultPetContent(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "refresh skipped", { reason: "no-window" });
debug("familiar.default", "refresh skipped", { reason: "no-window" });
return;
}
debug("pet.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(transientDisplay), badge: statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId });
debug("familiar.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(transientDisplay), badge: statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId });
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles());
}
export function recoverDefaultPetMouseInterop(reason: string): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "mouse interop recovery skipped", { reason, skippedReason: "no-window" });
debug("familiar.default", "mouse interop recovery skipped", { reason, skippedReason: "no-window" });
return;
}
debug("pet.default", "mouse interop recovery requested", { windowId: defaultPetWindow.id, reason, petId: getAppStateSnapshot().preferences.defaultPetId });
debug("familiar.default", "mouse interop recovery requested", { windowId: defaultPetWindow.id, reason, petId: getAppStateSnapshot().preferences.defaultPetId });
recoverPetMouseInterop(defaultPetWindow, reason);
}
export function applyExternalPetReaction(reaction: OpenPetsReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } {
export function applyExternalPetReaction(reaction: FamiliarOSReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
@ -139,7 +139,7 @@ export function applyExternalPetReaction(reaction: OpenPetsReaction, options: Pe
return { shown: isDefaultPetVisible() };
}
export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
export function applyExternalPetSay(message: string, reaction?: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
if (paused) {
return { shown: false, reason: "paused" };
}
@ -166,7 +166,7 @@ export function applyInternalPetMessage(message: string, options: InternalPetMes
return { shown: isDefaultPetVisible() };
}
export function applyExternalPetStatusReaction(reaction: OpenPetsReaction | null): void {
export function applyExternalPetStatusReaction(reaction: FamiliarOSReaction | null): void {
if (reaction === null || reaction === "idle") clearStatusBadge();
else setStatusBadge(reaction);
refreshDefaultPetContent();
@ -193,12 +193,12 @@ export function destroyDefaultPet(): void {
clearDefaultPetDisplayTimers();
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
debug("pet.default", "destroy skipped", { reason: "no-window" });
debug("familiar.default", "destroy skipped", { reason: "no-window" });
defaultPetWindow = null;
return;
}
info("pet.default", "destroy requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
info("familiar.default", "destroy requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
const window = defaultPetWindow;
defaultPetWindow = null;
@ -220,13 +220,13 @@ export function getDefaultPetWindowBounds(): Electron.Rectangle | null {
}
function handleBubbleDismissed(dismissToken: string): void {
debug("pet.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayGeneration });
debug("familiar.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayGeneration });
if (PetBubbleArbiter.isArbiterToken(dismissToken)) {
defaultPetBubbleArbiter.handleDismissed(dismissToken);
return;
}
if (dismissToken !== String(displayGeneration)) {
debug("pet.default", "bubble dismissed stale token", { dismissToken, currentGeneration: displayGeneration });
debug("familiar.default", "bubble dismissed stale token", { dismissToken, currentGeneration: displayGeneration });
return;
}
clearDefaultPetDisplayTimers();
@ -236,11 +236,11 @@ function handleBubbleDismissed(dismissToken: string): void {
}
function handleScaleChanged(scale: number): void {
debug("pet.default", "scale changed", { scale });
debug("familiar.default", "scale changed", { scale });
import("./app-state.js").then(({ updatePreferences }) => {
updatePreferences({ petScale: scale });
}).catch((error) => {
console.error("Failed to update pet scale preference.", error);
console.error("Failed to update familiar scale preference.", error);
});
}
@ -267,10 +267,10 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload),
}, getCurrentDismissToken());
const windowId = defaultPetWindow.id;
info("pet.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
info("familiar.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
defaultPetWindow.on("closed", () => {
info("pet.default", "closed", { windowId });
info("familiar.default", "closed", { windowId });
defaultPetWindow = null;
});
@ -278,7 +278,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
}
function setTransientDisplay(display: PetTransientDisplay): void {
debug("pet.default", "transient display set", { reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
debug("familiar.default", "transient display set", { reaction: display.reaction, hasMessage: Boolean(display.message), hasReactionMessage: Boolean(display.reactionMessage) });
displayGeneration++;
transientDisplay = mergePetTransientDisplay(transientDisplay, { ...display, dismissToken: String(displayGeneration) });
if (display.reaction) setStatusBadge(display.reaction);
@ -321,7 +321,7 @@ function showDefaultPetForExternalEvent(): void {
const state = getAppStateSnapshot();
const visible = isDefaultPetVisible();
if (!shouldShowDefaultPetForExternalEvent(visible, state.preferences.openDefaultPetOnLaunch, paused)) {
debug("pet.default", "external show skipped", { reason: "paused", visible, openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch });
debug("familiar.default", "external show skipped", { reason: "paused", visible, openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch });
return;
}
@ -333,7 +333,7 @@ async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unkno
const window = defaultPetWindow;
const blockedReason = getMovementBlockedReason(window);
if (blockedReason) {
debug("pet.default", "move skipped", { reason: blockedReason });
debug("familiar.default", "move skipped", { reason: blockedReason });
return { moved: false, reason: blockedReason };
}
const current = readWindowPosition(window);
@ -344,7 +344,7 @@ async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unkno
const durationMs = clampNumber(Number(rawDurationMs ?? 700), minPluginMoveDurationMs, maxPluginMoveDurationMs);
const steps = Math.max(8, Math.min(16, Math.round(durationMs / 100)));
movementInProgress = true;
debug("pet.default", "move start", { windowId: window.id, from: current, target, durationMs, steps });
debug("familiar.default", "move start", { windowId: window.id, from: current, target, durationMs, steps });
try {
for (let step = 1; step <= steps; step += 1) {
if (window.isDestroyed()) return { moved: false, reason: "destroyed" };
@ -356,7 +356,7 @@ async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unkno
}
window.setPosition(target.x, target.y, false);
setDefaultPetPosition(target);
debug("pet.default", "move finished", { windowId: window.id, target });
debug("familiar.default", "move finished", { windowId: window.id, target });
return { moved: true };
} finally {
movementInProgress = false;
@ -382,14 +382,14 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setStatusBadge(reaction: OpenPetsReaction): void {
function setStatusBadge(reaction: FamiliarOSReaction): void {
if (reaction === "idle") {
clearStatusBadge();
return;
}
statusBadge = reaction;
debug("pet.default", "status badge set", { reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
debug("familiar.default", "status badge set", { reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = setTimeout(() => {
clearStatusBadge();
@ -398,7 +398,7 @@ function setStatusBadge(reaction: OpenPetsReaction): void {
}
function clearStatusBadge(): void {
if (statusBadge) debug("pet.default", "status badge cleared", { reaction: statusBadge });
if (statusBadge) debug("familiar.default", "status badge cleared", { reaction: statusBadge });
statusBadge = null;
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = null;
@ -419,7 +419,7 @@ function getCurrentDismissToken(): string | undefined {
return transientDisplay?.dismissToken ?? (statusBadge ? String(displayGeneration) : undefined);
}
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}
@ -429,7 +429,7 @@ function reclampDefaultPetWindow(): void {
}
const safePosition = readWindowPosition(defaultPetWindow);
info("pet.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition });
info("familiar.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition });
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
setDefaultPetPosition(safePosition);
recoverDefaultPetMouseInterop("display-change");

View file

@ -27,8 +27,8 @@ export function getDefaultPetInitialPosition(size: WindowSize = defaultPetWindow
}
export function clampToVisibleWorkArea(position: Point, size: WindowSize = defaultPetWindowSize): Point {
// Clamp to the display the pet currently lives on (the one nearest its centre),
// not the primary display — otherwise a pet on an external monitor gets yanked
// Clamp to the display the familiar currently lives on (the one nearest its centre),
// not the primary display — otherwise a familiar on an external monitor gets yanked
// back to the built-in screen whenever its position is read, saved, or restored.
const centre = { x: position.x + size.width / 2, y: position.y + size.height / 2 };
const { workArea } = screen.getDisplayNearestPoint(centre);

View file

@ -7,11 +7,11 @@ import { Transform } from "node:stream";
import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type FamiliarOSStateV1 } from "./app-state.js";
import { getCatalogPet } from "./catalog.js";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-pets-core.js";
import { builtInPet } from "./built-in-pet.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-familiars-core.js";
import { builtInPet } from "./built-in-familiar.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./familiar-paths.js";
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
const maxZipDownloadBytes = 50 * 1024 * 1024;
@ -22,12 +22,12 @@ const downloadTimeoutMs = 30_000;
const operations = new Set<string>();
export async function installPet(petId: string): Promise<OpenPetsStateV1> {
export async function installPet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
assertSafePetId(petId);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) {
throw new Error(`Pet is already installed: ${petId}`);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
throw new Error(`Familiar is already installed: ${petId}`);
}
const catalogPet = await getCatalogPet(petId);
@ -43,7 +43,7 @@ export async function installPet(petId: string): Promise<OpenPetsStateV1> {
await extractPetZip(zip, tempDir);
const metadata = await validateExtractedPet(tempDir);
if (metadata.id !== petId || metadata.id !== catalogPet.id) {
throw new Error("Catalog pet package id does not match the requested pet.");
throw new Error("Catalog familiar package id does not match the requested familiar.");
}
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
@ -70,9 +70,9 @@ export async function installPet(petId: string): Promise<OpenPetsStateV1> {
});
}
export async function installPetFromZipFile(zipPath: string): Promise<OpenPetsStateV1> {
export async function installPetFromZipFile(zipPath: string): Promise<FamiliarOSStateV1> {
return withPetOperation("local-import", async () => {
const zip = await readRegularFile(zipPath, maxZipDownloadBytes, "pet zip");
const zip = await readRegularFile(zipPath, maxZipDownloadBytes, "familiar zip");
validateZipMagic(zip);
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
@ -90,18 +90,18 @@ export async function installPetFromZipFile(zipPath: string): Promise<OpenPetsSt
});
}
export async function installPetFromFolder(folderPath: string): Promise<OpenPetsStateV1> {
export async function installPetFromFolder(folderPath: string): Promise<FamiliarOSStateV1> {
return withPetOperation("local-import", async () => {
const sourceDir = resolve(folderPath);
const sourceStats = await lstat(sourceDir);
if (sourceStats.isSymbolicLink()) throw new Error("Pet folder cannot be a symlink.");
if (!sourceStats.isDirectory()) throw new Error("Pet folder must be a directory.");
if (await realpath(sourceDir) !== sourceDir) throw new Error("Pet folder path is not canonical.");
const parsed = JSON.parse((await readRegularFile(join(sourceDir, "pet.json"), maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
if (sourceStats.isSymbolicLink()) throw new Error("Familiar folder cannot be a symlink.");
if (!sourceStats.isDirectory()) throw new Error("Familiar folder must be a directory.");
if (await realpath(sourceDir) !== sourceDir) throw new Error("Familiar folder path is not canonical.");
const parsed = JSON.parse((await readRegularFile(join(sourceDir, "familiar.json"), maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(sourceDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === metadata.id)) throw new Error(`Pet is already installed: ${metadata.id}`);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === metadata.id)) throw new Error(`Familiar is already installed: ${metadata.id}`);
const spritesheet = await readRegularFile(join(sourceDir, metadata.spritesheetPath), maxCodexSpritesheetBytes, "spritesheet.webp");
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
@ -109,7 +109,7 @@ export async function installPetFromFolder(folderPath: string): Promise<OpenPets
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "familiar.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await finalizeLocalPetInstall(metadata, tempDir);
return installLocalPetState(metadata);
} catch (error) {
@ -119,10 +119,10 @@ export async function installPetFromFolder(folderPath: string): Promise<OpenPets
});
}
export async function removePet(petId: string): Promise<OpenPetsStateV1> {
export async function removePet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
if (petId === builtInPet.id) {
throw new Error("Built-in pet cannot be removed.");
throw new Error("Built-in familiar cannot be removed.");
}
assertSafePetId(petId);
const dir = getInstalledPetDir(petId);
@ -130,13 +130,13 @@ export async function removePet(petId: string): Promise<OpenPetsStateV1> {
try {
await rm(dir, { recursive: true, force: true });
} catch (error) {
throw new Error(`Pet was removed from OpenPets state, but local files could not be deleted from ${dir}. You may need to remove them manually. ${error instanceof Error ? error.message : ""}`.trim());
throw new Error(`Familiar was removed from FamiliarOS state, but local files could not be deleted from ${dir}. You may need to remove them manually. ${error instanceof Error ? error.message : ""}`.trim());
}
return state;
});
}
export async function setDefaultInstalledPet(petId: string): Promise<OpenPetsStateV1> {
export async function setDefaultInstalledPet(petId: string): Promise<FamiliarOSStateV1> {
return withPetOperation(petId, async () => {
if (petId !== builtInPet.id) {
assertSafePetId(petId);
@ -147,7 +147,7 @@ export async function setDefaultInstalledPet(petId: string): Promise<OpenPetsSta
export async function withPetOperation<T>(key: string, callback: () => Promise<T>): Promise<T> {
if (operations.has(key)) {
throw new Error("An operation for this pet is already in progress.");
throw new Error("An operation for this familiar is already in progress.");
}
operations.add(key);
@ -185,8 +185,8 @@ async function downloadPetZip(zipUrl: string): Promise<Buffer> {
function validateZipUrl(value: string): void {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("Zip URL must use https.");
if (url.hostname !== "zip.openpets.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/pets/")) throw new Error("Zip URL path is not allowed.");
if (url.hostname !== "zip.familiaros.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/familiars/")) throw new Error("Zip URL path is not allowed.");
if (url.username || url.password) throw new Error("Zip URL cannot include credentials.");
if (url.port) throw new Error("Zip URL cannot include a custom port.");
}
@ -260,7 +260,7 @@ async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
fileCount += 1;
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
if (safePath.relativeOutputPath === "pet.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("pet.json is too large.");
if (safePath.relativeOutputPath === "familiar.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("familiar.json is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large.");
extractedTotal += entry.uncompressedSize;
if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large.");
@ -268,7 +268,7 @@ async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
assertOutputPathInside(tempDir, outputPath);
seenRequired.add(safePath.relativeOutputPath);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, safePath.relativeOutputPath === "pet.json" ? maxCodexPetJsonBytes : maxIndividualFileBytes);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, safePath.relativeOutputPath === "familiar.json" ? maxCodexPetJsonBytes : maxIndividualFileBytes);
};
zipFile.readEntry();
@ -277,8 +277,8 @@ async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
zipFile.close();
}
if (!seenRequired.has("pet.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain pet.json and spritesheet.webp.");
if (!seenRequired.has("familiar.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain familiar.json and spritesheet.webp.");
}
}
@ -368,12 +368,12 @@ function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expected
}
async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata> {
const petJsonPath = join(tempDir, "pet.json");
const petJsonPath = join(tempDir, "familiar.json");
const spritesheetPath = join(tempDir, "spritesheet.webp");
assertOutputPathInside(tempDir, petJsonPath);
assertOutputPathInside(tempDir, spritesheetPath);
const parsed = JSON.parse((await readRegularFile(petJsonPath, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const parsed = JSON.parse((await readRegularFile(petJsonPath, maxCodexPetJsonBytes, "familiar.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(tempDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
@ -386,7 +386,7 @@ async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata>
}
async function finalizeLocalPetInstall(metadata: CodexPetMetadata, tempDir: string): Promise<void> {
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === metadata.id)) throw new Error(`Pet is already installed: ${metadata.id}`);
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === metadata.id)) throw new Error(`Familiar is already installed: ${metadata.id}`);
const petsRoot = getPetsRoot();
const finalDir = getInstalledPetDir(metadata.id);
assertInsideRoot(petsRoot, finalDir);
@ -394,7 +394,7 @@ async function finalizeLocalPetInstall(metadata: CodexPetMetadata, tempDir: stri
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
await validateInstalledRegularFile(join(finalDir, "familiar.json"));
} catch (error) {
await rm(finalDir, { recursive: true, force: true });
throw error;
@ -420,17 +420,17 @@ async function readRegularFile(path: string, maxBytes: number, label: string): P
async function validateInstalledRegularFile(path: string): Promise<void> {
const resolved = resolve(path);
const root = getPetsRoot();
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error("Installed pet file escapes pets root.");
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error("Installed familiar file escapes familiars root.");
const stats = await lstat(resolved);
if (stats.isSymbolicLink()) throw new Error("Imported pet file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported pet file must be a regular file.");
if (stats.isSymbolicLink()) throw new Error("Imported familiar file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported familiar file must be a regular file.");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function installLocalPetState(metadata: CodexPetMetadata): Promise<OpenPetsStateV1> {
async function installLocalPetState(metadata: CodexPetMetadata): Promise<FamiliarOSStateV1> {
try {
return installPetState({ id: metadata.id, displayName: metadata.displayName, description: metadata.description });
} catch (error) {

View file

@ -1,12 +1,12 @@
import { BrowserWindow, screen } from "electron";
import { clampToVisibleWorkArea, defaultPetWindowSize, type Point } from "./display.js";
import { isPetWindowDragging } from "./pet-window.js";
import { isPetWindowDragging } from "./familiar-window.js";
/**
* Liveness motion primitives (§13.6): animated absolute moves, continuous
* cursor following with lag, and lightweight gravity/bounce physics. Operates
* on any pet window through an accessor (windows can be recreated), one loop
* on any familiar window through an accessor (windows can be recreated), one loop
* per surface, paused while hidden or being dragged.
*/
@ -96,7 +96,7 @@ function syncLoop(petHandleId: string, accessor: WindowAccessor, state: MotionSt
let nextY = y;
if (state.follow) {
const cursor = screen.getCursorScreenPoint();
// Aim the pet's bottom-center near the cursor; lag controls smoothing.
// Aim the familiar's bottom-center near the cursor; lag controls smoothing.
const targetX = cursor.x - Math.round(defaultPetWindowSize.width / 2);
const targetY = cursor.y - Math.round(defaultPetWindowSize.height * 0.7);
const smoothing = 1 - state.follow.lag;

View file

@ -3,7 +3,7 @@ import { join, resolve, sep } from "node:path";
import { app } from "electron";
export function getPetsRoot(): string {
return join(app.getPath("userData"), "pets");
return join(app.getPath("userData"), "familiars");
}
export function getInstalledPetDir(petId: string): string {
@ -16,7 +16,7 @@ export function getInstalledPetDir(petId: string): string {
export function assertSafePetId(petId: string): void {
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(petId) || petId === "builtin") {
throw new Error(`Invalid installed pet id: ${petId}`);
throw new Error(`Invalid installed familiar id: ${petId}`);
}
}
@ -25,6 +25,6 @@ export function assertInsideRoot(root: string, target: string): void {
const resolvedTarget = resolve(target);
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${sep}`)) {
throw new Error("Resolved path escapes OpenPets pets directory.");
throw new Error("Resolved path escapes FamiliarOS familiars directory.");
}
}

View file

@ -6,10 +6,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js";
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { getInstalledPetDir } from "./pet-paths.js";
import { builtInPet } from "./built-in-familiar.js";
import { getInstalledPetDir } from "./familiar-paths.js";
import { getActiveLocale, getActiveLocaleLang, t } from "./i18n/index.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import { pickReactionMessage } from "./reaction-messages.js";
import { debug, error as logError, info, warn } from "./logger.js";
import { executeDefaultPetPluginCommand, executeDefaultPetPluginMenuSelect, getDefaultPetPluginCommands, getDefaultPetPluginMenuItems } from "./plugin-service.js";
@ -53,18 +53,18 @@ export interface AgentPetWindowOptions extends PetWindowInteractionHooks {
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly onCloseRequested: () => void;
/** Skip the right-click plugin command section (plugin-spawned pets). */
/** Skip the right-click plugin command section (plugin-spawned familiars). */
readonly plainContextMenu?: boolean;
}
/** Plugin-arbiter bubble content for one pet surface (both slots). */
/** Plugin-arbiter bubble content for one familiar surface (both slots). */
export interface PetPluginBubbles {
readonly transient: ActiveBubble | null;
readonly pinned: ActiveBubble | null;
}
export interface PetTransientDisplay {
readonly reaction?: OpenPetsReaction;
readonly reaction?: FamiliarOSReaction;
readonly message?: string;
readonly reactionMessage?: string;
readonly suppressReactionMessage?: boolean;
@ -73,7 +73,7 @@ export interface PetTransientDisplay {
readonly sticky?: boolean;
}
export type PetStatusBadgeReaction = Exclude<OpenPetsReaction, "idle">;
export type PetStatusBadgeReaction = Exclude<FamiliarOSReaction, "idle">;
interface PetContentRender {
readonly html: string;
@ -99,11 +99,11 @@ export function isPetWindowScaling(window: BrowserWindow): boolean {
}
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 });
const window = createBasePetWindow("FamiliarOS — Default Familiar", options.position);
info("familiar.window", "default window create", { windowId: window.id, position: options.position, paused: options.paused, hasDisplay: Boolean(options.display), badge: options.badge });
installMousePassthroughAndDrag(window, options);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: t("pet.menu.hidePet"), click: options.onHideRequested, defaultPet: true });
installPetContextMenu(window, { label: t("familiar.menu.hidePet"), click: options.onHideRequested, defaultPet: true });
const savePosition = debounce(() => {
if (window.isDestroyed()) {
@ -116,7 +116,7 @@ export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismiss
window.on("move", savePosition);
window.on("moved", savePosition);
window.on("close", () => {
info("pet.window", "default window close", { windowId: window.id, position: readWindowPosition(window) });
info("familiar.window", "default window close", { windowId: window.id, position: readWindowPosition(window) });
options.onPositionChanged(readWindowPosition(window));
});
@ -126,11 +126,11 @@ 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 });
const window = createBasePetWindow(`FamiliarOS${options.displayName}`, options.position);
info("familiar.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);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: t("pet.menu.closePet"), click: options.onCloseRequested });
installPetContextMenu(window, { label: t("familiar.menu.closePet"), click: options.onCloseRequested });
void loadExplicitPetContent(window, options.petId, options.display, options.badge, dismissToken, options.scale);
return window;
}
@ -143,7 +143,7 @@ export function recoverPetMouseInterop(window: BrowserWindow, reason: string): v
return;
}
debug("pet.window", "mouse interop recovery skipped", { windowId: window.id, reason, skippedReason: "unregistered-window" });
debug("familiar.window", "mouse interop recovery skipped", { windowId: window.id, reason, skippedReason: "unregistered-window" });
}
function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void; readonly defaultPet?: boolean }): void {
@ -151,7 +151,7 @@ function installPetContextMenu(window: BrowserWindow, action: { readonly label:
const handleContextMenu = (event: Electron.Event): void => {
event.preventDefault();
if (window.isDestroyed()) return;
void buildPetContextMenuTemplate(action).then((template) => Menu.buildFromTemplate(template).popup({ window })).catch((error) => { logError("pet.window", "context menu build failed", error); Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window }); });
void buildPetContextMenuTemplate(action).then((template) => Menu.buildFromTemplate(template).popup({ window })).catch((error) => { logError("familiar.window", "context menu build failed", error); Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window }); });
};
webContents.on("context-menu", handleContextMenu);
window.once("closed", () => {
@ -167,7 +167,7 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea
const plugins = new Map<string, { name: string; commands: Electron.MenuItemConstructorOptions[]; menuItems: Electron.MenuItemConstructorOptions[] }>();
const sorted = [...commands].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
for (const command of sorted) {
const item: Electron.MenuItemConstructorOptions = { label: command.commandTitle, click: () => { if (command.form) openPluginCommandForm(command).catch((error) => logError("pet.window", "plugin command form failed", error)); else executeDefaultPetPluginCommand(command.pluginId, command.commandId).catch((error) => logError("pet.window", "plugin command failed", error)); } };
const item: Electron.MenuItemConstructorOptions = { label: command.commandTitle, click: () => { if (command.form) openPluginCommandForm(command).catch((error) => logError("familiar.window", "plugin command form failed", error)); else executeDefaultPetPluginCommand(command.pluginId, command.commandId).catch((error) => logError("familiar.window", "plugin command failed", error)); } };
if (command.placement === "top" || command.featured) {
if (command.commandId === "status") {
topLevelStatus.push(item);
@ -184,7 +184,7 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea
const menuItems = await getDefaultPetPluginMenuItems();
for (const item of menuItems) {
const group = plugins.get(item.pluginId) ?? { name: item.pluginName, commands: [], menuItems: [] };
group.menuItems.push({ label: item.title, enabled: item.enabled !== false, type: item.checked === true ? "checkbox" : "normal", checked: item.checked === true ? true : undefined, click: () => { executeDefaultPetPluginMenuSelect(item.pluginId, item.itemId).catch((error) => logError("pet.window", "plugin menu select failed", error)); } });
group.menuItems.push({ label: item.title, enabled: item.enabled !== false, type: item.checked === true ? "checkbox" : "normal", checked: item.checked === true ? true : undefined, click: () => { executeDefaultPetPluginMenuSelect(item.pluginId, item.itemId).catch((error) => logError("familiar.window", "plugin menu select failed", error)); } });
plugins.set(item.pluginId, group);
}
@ -196,8 +196,8 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea
return { label: plugin.name, submenu };
};
// Place the virtual-pet stats submenu directly under its top-level status command.
const statsPluginId = "openpets.virtual-pet";
// Place the virtual-familiar stats submenu directly under its top-level status command.
const statsPluginId = "familiaros.virtual-familiar";
const statsGroup = plugins.get(statsPluginId);
const statsSubmenu = statsGroup && statsGroup.menuItems.length > 0 ? buildPluginSubmenu(statsGroup) : undefined;
if (statsGroup) plugins.delete(statsPluginId);
@ -214,7 +214,7 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea
template.push(...[...plugins.values()].map(buildPluginSubmenu));
}
if (template.length > 0) template.push({ type: "separator" });
template.push({ label: t("pet.menu.openControlCenter"), click: () => { import("./windows.js").then(({ openControlCenterWindow }) => openControlCenterWindow()).catch((error) => logError("pet.window", "open control center failed", error)); } }, { label: action.label, click: action.click });
template.push({ label: t("familiar.menu.openControlCenter"), click: () => { import("./windows.js").then(({ openControlCenterWindow }) => openControlCenterWindow()).catch((error) => logError("familiar.window", "open control center failed", error)); } }, { label: action.label, click: action.click });
return template;
}
@ -308,7 +308,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("pet.window", "mouse interop recovery", { windowId, reason });
debug("familiar.window", "mouse interop recovery", { windowId, reason });
setPassthrough(false);
if (process.platform === "win32") {
requestCursorHitTestProbe(reason);
@ -328,7 +328,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
if (process.platform === "linux") {
// Electron does not support forwarded mouse events on Linux, so ignored
// windows cannot receive the renderer events required to start dragging.
// Keep Linux pet windows interactive; this trades click-through for reliable drag.
// Keep Linux familiar windows interactive; this trades click-through for reliable drag.
window.setIgnoreMouseEvents(false);
return;
}
@ -366,18 +366,18 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
const requestCursorHitTestProbe = (reason: string, logProbe = true): void => {
if (window.isDestroyed() || webContents.isDestroyed()) return;
const probe = getCursorProbe();
if (logProbe) debug("pet.window", "cursor hit-test probe", { windowId, reason, inside: probe.inside, cursor: probe.cursor, bounds: probe.bounds });
if (logProbe) debug("familiar.window", "cursor hit-test probe", { windowId, reason, inside: probe.inside, cursor: probe.cursor, bounds: probe.bounds });
if (!probe.inside) return;
webContents.send("openpets:pet-probe-hit-test", { clientX: probe.clientX, clientY: probe.clientY, reason });
webContents.send("familiaros:familiar-probe-hit-test", { clientX: probe.clientX, clientY: probe.clientY, reason });
};
const rearmWindowsMouseForwarding = (reason: string, logRearm = true): void => {
if (window.isDestroyed()) return;
if (dragging || lastInteractive) {
if (logRearm) debug("pet.window", "windows mouse forwarding rearm skipped", { windowId, reason, dragging: Boolean(dragging), interactive: lastInteractive });
if (logRearm) debug("familiar.window", "windows mouse forwarding rearm skipped", { windowId, reason, dragging: Boolean(dragging), interactive: lastInteractive });
return;
}
if (logRearm) debug("pet.window", "windows mouse forwarding rearm", { windowId, reason });
if (logRearm) debug("familiar.window", "windows mouse forwarding rearm", { windowId, reason });
window.setIgnoreMouseEvents(false);
window.setIgnoreMouseEvents(true, { forward: true });
requestCursorHitTestProbe(reason, logRearm);
@ -409,7 +409,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
return;
}
// On Windows, rapid pet HTML reloads can leave Chromium's forwarded mouse
// On Windows, rapid familiar HTML reloads can leave Chromium's forwarded mouse
// tracking stale while the cursor is already over the transparent window.
// Toggle immediately, probe the current cursor hit target, then repeat the
// toggle shortly after load because Windows sometimes re-registers mouse
@ -428,7 +428,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
rendererReady = true;
lastInteractive = Boolean(interactive);
const sourceName = typeof source === "string" ? source : undefined;
if (sourceName !== "idle-forwarding-watch" || lastInteractive) debug("pet.window", "hit test", { windowId, interactive: lastInteractive, dragging, source: sourceName });
if (sourceName !== "idle-forwarding-watch" || lastInteractive) debug("familiar.window", "hit test", { windowId, interactive: lastInteractive, dragging, source: sourceName });
setPassthrough(!lastInteractive && !dragging);
if (lastInteractive || dragging) clearWindowsForwardingWatch();
else scheduleWindowsForwardingWatch("idle-forwarding-watch");
@ -445,10 +445,10 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
const startBounds = window.getBounds();
dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX: startBounds.x, startWindowY: startBounds.y, width: startBounds.width, height: startBounds.height };
petWindowDragging.set(window, true);
debug("pet.window", "drag start", { windowId, point, startBounds });
debug("familiar.window", "drag start", { windowId, point, startBounds });
clearWindowsForwardingWatch();
setPassthrough(false);
onPetEvent?.("pet:dragStart", {});
onPetEvent?.("familiar:dragStart", {});
};
const handleDragMove = (event: IpcMainEvent, point: unknown): void => {
@ -463,27 +463,27 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
const wasDragging = dragging !== null;
dragging = null;
petWindowDragging.set(window, false);
debug("pet.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
debug("familiar.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
// Do not resize/re-anchor after a drag — the user already positioned the window.
if (wasDragging) onPetEvent?.("pet:dragEnd", {});
if (wasDragging) onPetEvent?.("familiar:dragEnd", {});
};
const handleBubbleDismissed = (event: IpcMainEvent, dismissToken: unknown): void => {
if (!isFromWindow(event)) return;
debug("pet.window", "bubble dismissed", { windowId, dismissToken });
debug("familiar.window", "bubble dismissed", { windowId, dismissToken });
if (typeof dismissToken === "string") onBubbleDismissed?.(dismissToken);
};
const handlePromptRequested = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
debug("pet.window", "prompt requested", { windowId });
debug("familiar.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 });
debug("familiar.window", "scale start", { windowId });
setPassthrough(false);
};
@ -498,7 +498,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
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 });
debug("familiar.window", "scale end", { windowId, scale });
if (scale !== undefined && Number.isFinite(scale)) {
onScaleChanged?.(scale);
}
@ -507,13 +507,13 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
const handleBubbleAction = (event: IpcMainEvent, dismissToken: unknown, actionId: unknown): void => {
if (!isFromWindow(event)) return;
debug("pet.window", "bubble action", { windowId, dismissToken, actionId });
debug("familiar.window", "bubble action", { windowId, dismissToken, actionId });
if (typeof dismissToken === "string" && typeof actionId === "string" && actionId.length <= 64) onBubbleAction?.(dismissToken, actionId);
};
const handleBubbleSubmit = (event: IpcMainEvent, dismissToken: unknown, values: unknown): void => {
if (!isFromWindow(event)) return;
debug("pet.window", "bubble submit", { windowId, dismissToken });
debug("familiar.window", "bubble submit", { windowId, dismissToken });
if (typeof dismissToken !== "string" || typeof values !== "object" || values === null) return;
const out: Record<string, string | number> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>).slice(0, 8)) {
@ -523,12 +523,12 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
onBubbleSubmit?.(dismissToken, out);
};
const allowedPetEventNames = new Set(["pet:clicked", "pet:doubleClicked", "pet:hover", "pet:drop"]);
const allowedPetEventNames = new Set(["familiar:clicked", "familiar:doubleClicked", "familiar:hover", "familiar:drop"]);
const handlePetEvent = (event: IpcMainEvent, name: unknown, payload: unknown): void => {
if (!isFromWindow(event)) return;
if (typeof name !== "string" || !allowedPetEventNames.has(name)) return;
const data = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
if (name !== "pet:hover") debug("pet.window", "pet event", { windowId, name });
if (name !== "familiar:hover") debug("familiar.window", "familiar event", { windowId, name });
onPetEvent?.(name, data);
};
@ -538,7 +538,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("pet.window", "navigation reset passthrough", { windowId });
debug("familiar.window", "navigation reset passthrough", { windowId });
setPassthrough(false);
};
@ -546,7 +546,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
dragging = null;
petWindowDragging.set(window, false);
lastInteractive = false;
debug("pet.window", "load rearm passthrough", { windowId });
debug("familiar.window", "load rearm passthrough", { windowId });
rearmPassthroughAfterLoad();
};
@ -557,26 +557,26 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
const handleLoadFailure = (): void => {
dragging = null;
lastInteractive = false;
debug("pet.window", "load failure rearm passthrough", { windowId });
debug("familiar.window", "load failure rearm passthrough", { windowId });
setPassthrough(true);
};
const removeListeners = (): void => {
if (listenersRemoved) return;
listenersRemoved = true;
ipcMain.off("openpets:pet-ready", handleReady);
ipcMain.off("openpets:pet-hit-test", handleHitTest);
ipcMain.off("openpets:pet-drag-start", handleDragStart);
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);
ipcMain.off("openpets:bubble-action", handleBubbleAction);
ipcMain.off("openpets:bubble-submit", handleBubbleSubmit);
ipcMain.off("openpets:pet-event", handlePetEvent);
ipcMain.off("familiaros:familiar-ready", handleReady);
ipcMain.off("familiaros:familiar-hit-test", handleHitTest);
ipcMain.off("familiaros:familiar-drag-start", handleDragStart);
ipcMain.off("familiaros:familiar-drag-move", handleDragMove);
ipcMain.off("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.off("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.off("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.off("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.off("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.off("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.off("familiaros:bubble-action", handleBubbleAction);
ipcMain.off("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.off("familiaros:familiar-event", handlePetEvent);
clearRearmTimers();
clearWindowsForwardingWatch();
petMouseInteropRecovery.delete(window);
@ -593,19 +593,19 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
petMouseInteropRecovery.set(window, scheduleMouseInteropRecovery);
ipcMain.on("openpets:pet-ready", handleReady);
ipcMain.on("openpets:pet-hit-test", handleHitTest);
ipcMain.on("openpets:pet-drag-start", handleDragStart);
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);
ipcMain.on("openpets:bubble-action", handleBubbleAction);
ipcMain.on("openpets:bubble-submit", handleBubbleSubmit);
ipcMain.on("openpets:pet-event", handlePetEvent);
ipcMain.on("familiaros:familiar-ready", handleReady);
ipcMain.on("familiaros:familiar-hit-test", handleHitTest);
ipcMain.on("familiaros:familiar-drag-start", handleDragStart);
ipcMain.on("familiaros:familiar-drag-move", handleDragMove);
ipcMain.on("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.on("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.on("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.on("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.on("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.on("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.on("familiaros:bubble-action", handleBubbleAction);
ipcMain.on("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.on("familiaros:familiar-event", handlePetEvent);
webContents.on("did-start-navigation", resetForNavigation);
webContents.on("did-start-loading", resetForNavigation);
webContents.on("did-finish-load", rearmAfterLoad);
@ -641,7 +641,7 @@ function createBasePetWindow(title: string, position: Point): BrowserWindow {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: resolvePreloadPath("pet-preload.cjs"),
preload: resolvePreloadPath("familiar-preload.cjs"),
},
});
@ -650,7 +650,7 @@ function createBasePetWindow(title: string, position: Point): BrowserWindow {
window.on("show", () => applyPetAlwaysOnTop(window));
window.on("restore", () => applyPetAlwaysOnTop(window));
// Show the pet window on all macOS Spaces (desktop workspaces).
// Show the familiar window on all macOS Spaces (desktop workspaces).
// Without this, the window is bound to the Space where it was created
// and disappears when the user switches to another Space.
if (process.platform === "darwin") {
@ -666,18 +666,18 @@ function createBasePetWindow(title: string, position: Point): BrowserWindow {
event.preventDefault();
});
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
logError("pet.window", "renderer load failed", { windowId: window.id, errorCode, errorDescription });
console.error("Failed to load default pet window.", { errorCode, errorDescription });
logError("familiar.window", "renderer load failed", { windowId: window.id, errorCode, errorDescription });
console.error("Failed to load default familiar window.", { errorCode, errorDescription });
});
window.webContents.on("console-message", (_event, level, message, line, sourceId) => {
const fields = { windowId: window.id, level, line, sourceId, message };
if (level >= 3) logError("pet.window", "renderer console", fields);
else if (level === 2) warn("pet.window", "renderer console", fields);
else debug("pet.window", "renderer console", fields);
if (level >= 3) logError("familiar.window", "renderer console", fields);
else if (level === 2) warn("familiar.window", "renderer console", fields);
else debug("familiar.window", "renderer console", fields);
});
window.webContents.on("render-process-gone", (_event, details) => {
logError("pet.window", "renderer process gone", { windowId: window.id, details });
console.error("Default pet renderer process gone.", details);
logError("familiar.window", "renderer process gone", { windowId: window.id, details });
console.error("Default familiar renderer process gone.", details);
});
return window;
@ -698,7 +698,7 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
petWindowLastDisplay.set(window, display);
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, hasPluginBubble: Boolean(pluginBubbles?.transient), hasPinned: Boolean(pluginBubbles?.pinned), defaultPetId: getAppStateSnapshot().preferences.defaultPetId });
debug("familiar.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, hasPluginBubble: Boolean(pluginBubbles?.transient), hasPinned: Boolean(pluginBubbles?.pinned), defaultPetId: getAppStateSnapshot().preferences.defaultPetId });
const render = await createDefaultPetRender(paused, display, badge, dismissToken, pluginBubbles);
applyLinuxPetWindowShape(window, scale, Boolean(display?.message || display?.reactionMessage || display?.reaction || badge || paused || pluginBubbles?.transient || pluginBubbles?.pinned));
if (tryUpdateLoadedPetContent(window, render, "default", sequence)) return;
@ -706,8 +706,8 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
petWindowRenderCache.set(window, render.cacheKey);
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
}).catch((error: unknown) => {
logError("pet.window", "default content load failed", error instanceof Error ? error : { error });
console.error("Failed to load default pet URL.", error);
logError("familiar.window", "default content load failed", error instanceof Error ? error : { error });
console.error("Failed to load default familiar URL.", error);
});
}
@ -716,22 +716,22 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin
petWindowLastDisplay.set(window, display);
try {
const state = getAppStateSnapshot();
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet || pet.broken || pet.id === builtInPet.id) {
throw new Error(`Cannot render explicit pet: ${petId}`);
const familiar = state.familiars.installed.find((candidate) => candidate.id === petId);
if (!familiar || familiar.broken || familiar.id === builtInPet.id) {
throw new Error(`Cannot render explicit familiar: ${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), fullMessage: display?.fullMessage === true, badge });
debug("familiar.window", "explicit content render begin", { windowId: window.id, sequence, petId, displayName: familiar.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, pluginBubbles);
const render = await createInstalledPetRender(familiar.id, familiar.displayName, false, display, scale, badge, `explicit:${familiar.id}`, dismissToken, pluginBubbles);
applyLinuxPetWindowShape(window, scale, Boolean(display?.message || display?.reactionMessage || display?.reaction || badge || pluginBubbles?.transient || pluginBubbles?.pinned));
if (tryUpdateLoadedPetContent(window, render, `explicit-${pet.id}`, sequence)) return;
await loadPetHtmlFile(window, render.html, `explicit-${pet.id}`, sequence);
if (tryUpdateLoadedPetContent(window, render, `explicit-${familiar.id}`, sequence)) return;
await loadPetHtmlFile(window, render.html, `explicit-${familiar.id}`, sequence);
petWindowRenderCache.set(window, render.cacheKey);
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
} catch (error: unknown) {
logError("pet.window", "explicit content load failed", error instanceof Error ? error : { petId, error });
console.error(`Failed to load explicit pet ${petId} URL.`, error);
logError("familiar.window", "explicit content load failed", error instanceof Error ? error : { petId, error });
console.error(`Failed to load explicit familiar ${petId} URL.`, error);
}
}
@ -768,43 +768,43 @@ export function clearTransientReaction(display: PetTransientDisplay): PetTransie
export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-reaction-state", state);
window.webContents.send("familiaros:familiar-reaction-state", state);
}
/** Override the pet sprite with a plugin-bundled spritesheet strip (§5), or clear with null. */
/** Override the familiar sprite with a plugin-bundled spritesheet strip (§5), or clear with null. */
export function setPetSpriteOverride(window: BrowserWindow, override: { readonly filePath: string; readonly fps: number; readonly loop: boolean } | null): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-sprite-override", override ? { fileUrl: pathToFileURL(override.filePath).toString(), fps: override.fps, loop: override.loop } : null);
window.webContents.send("familiaros:familiar-sprite-override", override ? { fileUrl: pathToFileURL(override.filePath).toString(), fps: override.fps, loop: override.loop } : null);
}
/** Scale override for a single pet window (plugin setScale). */
/** Scale override for a single familiar window (plugin setScale). */
export function setPetWindowScale(window: BrowserWindow, scale: number): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-scale-override", scale);
window.webContents.send("familiaros:familiar-scale-override", scale);
}
export type PetWindowAudioPayload = { readonly kind: "named"; readonly name: string; readonly volume: number } | { readonly kind: "data"; readonly dataUrl: string; readonly volume: number };
/** Play a sound through the pet window's WebAudio pipeline. */
/** Play a sound through the familiar window's WebAudio pipeline. */
export function playPetWindowAudio(window: BrowserWindow, payload: PetWindowAudioPayload): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:play-audio", payload);
window.webContents.send("familiaros:play-audio", payload);
}
export function stopPetWindowAudio(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:stop-audio");
window.webContents.send("familiaros:stop-audio");
}
/** Speak text via the renderer speechSynthesis voice (plugin voice.speak). */
export function speakPetWindowTts(window: BrowserWindow, text: string, opts: { readonly voice?: string; readonly rate?: number }): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:tts-speak", { text, voice: opts.voice, rate: opts.rate });
window.webContents.send("familiaros:tts-speak", { text, voice: opts.voice, rate: opts.rate });
}
export function stopPetWindowTts(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:tts-stop");
window.webContents.send("familiaros:tts-stop");
}
function tryUpdateLoadedPetContent(window: BrowserWindow, render: PetContentRender, name: string, sequence: number): boolean {
@ -812,8 +812,8 @@ function tryUpdateLoadedPetContent(window: BrowserWindow, render: PetContentRend
if (petWindowRenderCache.get(window) !== render.cacheKey) return false;
const url = window.webContents.getURL();
if (!isAllowedPetDocumentUrl(url)) return false;
debug("pet.window", "content update in place", { windowId: window.id, name, sequence, reactionState: render.reactionState });
window.webContents.send("openpets:pet-content-state", { bodyHtml: render.bodyHtml, reactionState: render.reactionState });
debug("familiar.window", "content update in place", { windowId: window.id, name, sequence, reactionState: render.reactionState });
window.webContents.send("familiaros:familiar-content-state", { bodyHtml: render.bodyHtml, reactionState: render.reactionState });
return true;
}
@ -868,9 +868,9 @@ function applyLinuxPetWindowShape(window: BrowserWindow, scale: PetScaleValue, h
try {
window.setShape(shape);
debug("pet.window", "linux window shape applied", { windowId: window.id, scale, hasBubble, shape });
debug("familiar.window", "linux window shape applied", { windowId: window.id, scale, hasBubble, shape });
} catch (error) {
logError("pet.window", "linux window shape failed", error instanceof Error ? error : { error });
logError("familiar.window", "linux window shape failed", error instanceof Error ? error : { error });
}
}
@ -882,7 +882,7 @@ async function createDefaultPetRender(paused: boolean, display: PetTransientDisp
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
const hasPinned = Boolean(pluginBubbles?.pinned);
const bodyHtml = createPetBodyMarkup("OpenPets default pet", createBubbleMarkup(display, paused, badge, dismissToken, pluginBubbles), `<div class="sprite" role="img" aria-label="Claude animated default pet"></div>`, createPinnedBubbleMarkup(pluginBubbles), hasPinned);
const bodyHtml = createPetBodyMarkup("FamiliarOS default familiar", createBubbleMarkup(display, paused, badge, dismissToken, pluginBubbles), `<div class="sprite" role="img" aria-label="Claude animated default familiar"></div>`, createPinnedBubbleMarkup(pluginBubbles), hasPinned);
const reactionState = getReactionSpriteState(display?.reaction);
const stateRows = defaultPetSprite.states;
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
@ -897,7 +897,7 @@ async function createDefaultPetRender(paused: boolean, display: PetTransientDisp
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<title>FamiliarOS Default Familiar</title>
<style>
${createPetWindowCss(paused, scale)}
.sprite {
@ -911,13 +911,13 @@ async function createDefaultPetRender(paused: boolean, display: PetTransientDisp
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation: familiar-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".sprite")}
@keyframes pet-frames {
@keyframes familiar-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
@ -932,7 +932,7 @@ async function createDefaultPetRender(paused: boolean, display: PetTransientDisp
async function tryCreateInstalledPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): Promise<PetContentRender | null> {
const state = getAppStateSnapshot();
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
if (!selected || selected.id === builtInPet.id || selected.broken) {
return null;
@ -941,11 +941,11 @@ async function tryCreateInstalledPetRender(paused: boolean, display: PetTransien
try {
return await createInstalledPetRender(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge, `default:${selected.id}`, dismissToken, pluginBubbles);
} catch (error) {
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
console.error(`Failed to render installed default familiar ${selected.id}; falling back to built-in familiar.`, error);
try {
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed pet rendering failed.");
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed familiar rendering failed.");
} catch (markError) {
console.error(`Failed to mark installed pet ${selected.id} broken.`, markError);
console.error(`Failed to mark installed familiar ${selected.id} broken.`, markError);
}
return null;
}
@ -955,7 +955,7 @@ async function createInstalledPetRender(petId: string, displayName: string, paus
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
throw new Error("Installed pet spritesheet is missing or too large.");
throw new Error("Installed familiar spritesheet is missing or too large.");
}
const imageUrl = pathToFileURL(spritesheetPath).toString();
@ -974,7 +974,7 @@ async function createInstalledPetRender(petId: string, displayName: string, paus
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<title>FamiliarOS Default Familiar</title>
<style>
${createPetWindowCss(paused, scale)}
.installed-card { width: ${Math.ceil(defaultPetSprite.frameWidth * scale)}px; height: ${Math.ceil(defaultPetSprite.frameHeight * scale)}px; overflow: visible; position: relative; }
@ -992,13 +992,13 @@ async function createInstalledPetRender(petId: string, displayName: string, paus
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation: familiar-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".installed-sprite")}
@keyframes pet-frames {
@keyframes familiar-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
@ -1015,8 +1015,8 @@ function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: s
return `<div class="stage${hasPinned ? " has-pinned" : ""}" aria-label="${stageLabel}">
${pinnedBubble}
${bubble}
<div class="pet-hitbox" aria-hidden="true">
<div class="pet-shell">
<div class="familiar-hitbox" aria-hidden="true">
<div class="familiar-shell">
${spriteMarkup}
</div>
</div>
@ -1044,13 +1044,13 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue, layout?: Part
--bubble-max-height-long: ${bubbleMaxHeightLong}px;
--bubble-max-height-very-long: ${bubbleMaxHeightVeryLong}px;
}
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
:root { color-scheme: dark; --familiar-opacity: ${opacity}; --play-state: ${playState}; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
html { color: #172033; }
body { -webkit-app-region: no-drag; pointer-events: none; }
.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; }
.familiar-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; }
.familiar-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--familiar-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(var(--bubble-max-width), calc(100vw - 24px)); max-height: min(var(--bubble-max-height), calc(100vh - ${bubbleBottom + 16}px)); 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.is-below { bottom: auto; top: ${bubbleTop}px; transform-origin: 64% 0; }
.bubble.is-below::after { top: -7px; bottom: auto; border: none; border-top: 1px solid rgba(255, 255, 255, 0.56); border-left: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 0; border-top-left-radius: 3px; transform: translateX(-50%) rotate(45deg); }
@ -1144,7 +1144,7 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue, layout?: Part
.bubble.is-pinned.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.94), rgba(254, 202, 202, 0.92)); }
.bubble.is-pinned.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.94), rgba(251, 207, 232, 0.92)); }
.bubble.is-pinned.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.94), rgba(226, 232, 240, 0.92)); }
.stage.has-pinned .pet-hitbox { bottom: ${Math.max(0, petBottom - hitPadding) + 28}px; }
.stage.has-pinned .familiar-hitbox { bottom: ${Math.max(0, petBottom - hitPadding) + 28}px; }
.stage.has-pinned .bubble:not(.is-pinned) { bottom: ${bubbleBottom + 28}px; }
.bubble.is-plugin.accent-blue { background: linear-gradient(135deg, rgba(219, 234, 254, 0.97), rgba(191, 219, 254, 0.94)); }
.bubble.is-plugin.accent-purple { background: linear-gradient(135deg, rgba(237, 233, 254, 0.97), rgba(221, 214, 254, 0.94)); }
@ -1253,7 +1253,7 @@ function createSpriteRule(selector: string, state: UniversalSpriteState): string
return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`;
}
function getReactionSpriteState(reaction: OpenPetsReaction | undefined): UniversalSpriteState {
function getReactionSpriteState(reaction: FamiliarOSReaction | undefined): UniversalSpriteState {
return resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
}
@ -1375,7 +1375,7 @@ export function pluginBubblesCacheKey(pluginBubbles: PetPluginBubbles | null): s
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): string {
if (pluginBubbles?.transient) return createPluginBubbleMarkup(pluginBubbles.transient, false);
const suppressReactionMessage = display?.suppressReactionMessage === true;
const text = display?.message ?? display?.reactionMessage ?? (!suppressReactionMessage && display?.reaction ? pickReactionMessage(display.reaction, Math.random, getActiveLocale()) : undefined) ?? (paused ? t("pet.paused") : "");
const text = display?.message ?? display?.reactionMessage ?? (!suppressReactionMessage && display?.reaction ? pickReactionMessage(display.reaction, Math.random, getActiveLocale()) : undefined) ?? (paused ? t("familiar.paused") : "");
const status = !paused && !suppressReactionMessage && badgeReaction ? getStatusBadge(badgeReaction) : null;
if (!text && !status) return "";
const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage);
@ -1396,14 +1396,14 @@ const statusBadgeIcons = {
} as const;
function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon?: string; readonly iconSvg?: string; readonly label: string } | null {
if (reaction === "thinking") return { className: "is-busy", icon: "", label: t("pet.status.thinking") };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: t("pet.status.working") };
if (reaction === "editing") return { className: "is-busy", icon: "", label: t("pet.status.editing") };
if (reaction === "testing") return { className: "is-busy", icon: "", label: t("pet.status.testing") };
if (reaction === "waiting") return { className: "is-waiting", icon: "", label: t("pet.status.waiting") };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: t("pet.status.done") };
if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: t("pet.status.oops") };
if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: t("pet.status.hi") };
if (reaction === "thinking") return { className: "is-busy", icon: "", label: t("familiar.status.thinking") };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: t("familiar.status.working") };
if (reaction === "editing") return { className: "is-busy", icon: "", label: t("familiar.status.editing") };
if (reaction === "testing") return { className: "is-busy", icon: "", label: t("familiar.status.testing") };
if (reaction === "waiting") return { className: "is-waiting", icon: "", label: t("familiar.status.waiting") };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: t("familiar.status.done") };
if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: t("familiar.status.oops") };
if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: t("familiar.status.hi") };
return null;
}
@ -1445,7 +1445,7 @@ function installMotionStatePublisher(window: BrowserWindow): void {
const sendMotionState = (state: PetMotionState): void => {
if (window.isDestroyed() || lastSent === state) return;
lastSent = state;
window.webContents.send("openpets:pet-motion", state);
window.webContents.send("familiaros:familiar-motion", state);
};
const scheduleIdle = (): void => {
@ -1472,7 +1472,7 @@ function installMotionStatePublisher(window: BrowserWindow): void {
window.on("moved", handleMove);
window.webContents.on("did-finish-load", () => {
lastSent = "idle";
window.webContents.send("openpets:pet-motion", "idle");
window.webContents.send("familiaros:familiar-motion", "idle");
});
window.on("closed", () => {
if (idleTimer) clearTimeout(idleTimer);
@ -1491,7 +1491,7 @@ function allocateWindowLoadSequence(window: BrowserWindow): number {
function sendBubbleLayout(window: BrowserWindow, layout: PetWindowBubbleLayout): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-bubble-layout", {
window.webContents.send("familiaros:familiar-bubble-layout", {
bubbleMaxHeight: layout.bubbleMaxHeight,
bubbleMaxHeightLong: layout.bubbleMaxHeightLong,
bubbleMaxHeightVeryLong: layout.bubbleMaxHeightVeryLong,
@ -1634,39 +1634,39 @@ function estimateWrappedLineCount(message: string, charsPerLine: number): number
}
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";
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "familiar";
const previous = windowLoadChains.get(window) ?? Promise.resolve();
const next = previous.catch(() => {}).then(async () => {
if (window.isDestroyed()) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed" });
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed" });
return;
}
if (windowLoadSequences.get(window) !== sequence) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, latestSequence: windowLoadSequences.get(window), reason: "superseded" });
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, latestSequence: windowLoadSequences.get(window), reason: "superseded" });
return;
}
const dir = join(app.getPath("userData"), "rendered-pets");
const dir = join(app.getPath("userData"), "rendered-familiars");
await mkdir(dir, { recursive: true });
const filePath = join(dir, `${safeName}.html`);
await writeFile(filePath, html, "utf8");
if (window.isDestroyed()) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed-after-write" });
debug("familiar.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed-after-write" });
return;
}
debug("pet.window", "load file begin", { windowId: window.id, name: safeName, sequence, filePath });
debug("familiar.window", "load file begin", { windowId: window.id, name: safeName, sequence, filePath });
window.setIgnoreMouseEvents(false);
try {
await window.loadFile(filePath);
debug("pet.window", "load file complete", { windowId: window.id, name: safeName, sequence, url: window.webContents.getURL() });
debug("familiar.window", "load file complete", { windowId: window.id, name: safeName, sequence, url: window.webContents.getURL() });
} catch (error) {
if (!window.isDestroyed()) {
if (process.platform === "linux") window.setIgnoreMouseEvents(false);
else window.setIgnoreMouseEvents(true, { forward: true });
}
logError("pet.window", "load file rejected", error instanceof Error ? error : { windowId: window.id, name: safeName, sequence, error });
logError("familiar.window", "load file rejected", error instanceof Error ? error : { windowId: window.id, name: safeName, sequence, error });
throw error;
}
});

View file

@ -4,13 +4,13 @@ import { dirname, join } from "node:path";
import { app } from "electron";
import type { OpenPetsMemoryKind } from "./local-ipc-protocol.js";
import type { FamiliarOSMemoryKind } from "./local-ipc-protocol.js";
import { extractPromptMemoryCandidates } from "./prompt-memory-extraction.js";
export interface OpenPetsMemoryEntry {
export interface FamiliarOSMemoryEntry {
readonly id: string;
readonly text: string;
readonly kind: OpenPetsMemoryKind;
readonly kind: FamiliarOSMemoryKind;
readonly tags: readonly string[];
readonly importance: number;
readonly createdAt: number;
@ -20,19 +20,19 @@ export interface OpenPetsMemoryEntry {
readonly source: "chat" | "mcp";
}
export interface OpenPetsMemorySearchHit {
readonly entry: OpenPetsMemoryEntry;
export interface FamiliarOSMemorySearchHit {
readonly entry: FamiliarOSMemoryEntry;
readonly score: number;
}
interface StoredOpenPetsMemoryV1 {
interface StoredFamiliarOSMemoryV1 {
readonly version: 1;
readonly entries: readonly OpenPetsMemoryEntry[];
readonly entries: readonly FamiliarOSMemoryEntry[];
}
interface StoreMemoryInput {
readonly text: string;
readonly kind?: OpenPetsMemoryKind;
readonly kind?: FamiliarOSMemoryKind;
readonly tags?: readonly string[];
readonly importance?: number;
readonly source?: "chat" | "mcp";
@ -40,13 +40,13 @@ interface StoreMemoryInput {
interface ExtractedMemoryCandidate {
readonly text: string;
readonly kind: OpenPetsMemoryKind;
readonly kind: FamiliarOSMemoryKind;
readonly tags: readonly string[];
readonly importance: number;
}
const memoryStoreFileName = "openpets-memory.json";
const memoryMarkdownMirrorFileName = "openpets-memory.md";
const memoryStoreFileName = "familiaros-memory.json";
const memoryMarkdownMirrorFileName = "familiaros-memory.md";
const maxMemoryEntries = 200;
const maxMemoryTextChars = 480;
const maxMemoryTags = 8;
@ -54,9 +54,9 @@ const defaultMemoryImportance = 3;
const maxRelevantMemoryChars = 1_600;
const maxRelevantMemoryItems = 6;
let cachedMemoryStore: StoredOpenPetsMemoryV1 | null = null;
let cachedMemoryStore: StoredFamiliarOSMemoryV1 | null = null;
export function listOpenPetsMemories(limit = 20): readonly OpenPetsMemoryEntry[] {
export function listFamiliarOSMemories(limit = 20): readonly FamiliarOSMemoryEntry[] {
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))
@ -64,11 +64,11 @@ export function listOpenPetsMemories(limit = 20): readonly OpenPetsMemoryEntry[]
return entries.map(cloneMemoryEntry);
}
export function searchOpenPetsMemories(query: string, limit = 8): readonly OpenPetsMemorySearchHit[] {
export function searchFamiliarOSMemories(query: string, limit = 8): readonly FamiliarOSMemorySearchHit[] {
const trimmedQuery = normalizeMemoryText(query);
const nextLimit = clampMemoryLimit(limit, 8);
if (!trimmedQuery) {
return listOpenPetsMemories(nextLimit).map((entry) => ({ entry, score: 0 }));
return listFamiliarOSMemories(nextLimit).map((entry) => ({ entry, score: 0 }));
}
const now = Date.now();
@ -128,7 +128,7 @@ function scoreChatHistoryEntry(entry: ChatHistorySearchEntry, queryTokens: Set<s
return tokenOverlap * 0.6 + exactMatchBonus + lengthPenalty * 0.2 + recencyBonus;
}
export function storeOpenPetsMemory(input: StoreMemoryInput): OpenPetsMemoryEntry {
export function storeFamiliarOSMemory(input: StoreMemoryInput): FamiliarOSMemoryEntry {
const text = normalizeMemoryText(input.text);
if (!text) {
throw new Error("Memory text cannot be empty.");
@ -143,7 +143,7 @@ export function storeOpenPetsMemory(input: StoreMemoryInput): OpenPetsMemoryEntr
const store = getMemoryStore();
const existing = store.entries.find((entry) => normalizeMemoryComparisonValue(entry.text) === normalizedText);
if (existing) {
const mergedEntry: OpenPetsMemoryEntry = {
const mergedEntry: FamiliarOSMemoryEntry = {
...existing,
text,
kind: pickPreferredMemoryKind(existing.kind, kind),
@ -161,7 +161,7 @@ export function storeOpenPetsMemory(input: StoreMemoryInput): OpenPetsMemoryEntr
return cloneMemoryEntry(mergedEntry);
}
const nextEntry: OpenPetsMemoryEntry = {
const nextEntry: FamiliarOSMemoryEntry = {
id: `mem-${randomUUID()}`,
text,
kind,
@ -180,7 +180,7 @@ export function storeOpenPetsMemory(input: StoreMemoryInput): OpenPetsMemoryEntr
return cloneMemoryEntry(nextEntry);
}
export function updateOpenPetsMemory(id: string, input: StoreMemoryInput): OpenPetsMemoryEntry | null {
export function updateFamiliarOSMemory(id: string, input: StoreMemoryInput): FamiliarOSMemoryEntry | null {
const trimmedId = typeof id === "string" ? id.trim() : "";
if (!trimmedId) {
throw new Error("Memory id cannot be empty.");
@ -196,7 +196,7 @@ export function updateOpenPetsMemory(id: string, input: StoreMemoryInput): OpenP
const index = store.entries.findIndex((entry) => entry.id === trimmedId);
if (index < 0) return null;
const existing = store.entries[index];
const updated: OpenPetsMemoryEntry = {
const updated: FamiliarOSMemoryEntry = {
...existing,
text,
kind,
@ -211,7 +211,7 @@ export function updateOpenPetsMemory(id: string, input: StoreMemoryInput): OpenP
return cloneMemoryEntry(updated);
}
export function forgetOpenPetsMemory(id: string): boolean {
export function forgetFamiliarOSMemory(id: string): boolean {
const trimmedId = typeof id === "string" ? id.trim() : "";
if (!trimmedId) {
throw new Error("Memory id cannot be empty.");
@ -237,14 +237,14 @@ export interface ChatHistorySearchEntry {
const recallIntentPattern = /(^|[^a-z0-9])(remember|recall|what do you know|who am i|what is my name|what's my name|tell me about me|about myself|myself|do you remember)($|[^a-z0-9])/i;
export function buildRelevantMemoryContext(prompt: string, chatHistory?: readonly ChatHistorySearchEntry[]): string | undefined {
const memoryHits = searchOpenPetsMemories(prompt, maxRelevantMemoryItems);
const memoryHits = searchFamiliarOSMemories(prompt, maxRelevantMemoryItems);
const chatHits = chatHistory && chatHistory.length > 0
? searchChatHistory(prompt, chatHistory, maxRelevantMemoryItems)
: [];
const hasRecallIntent = recallIntentPattern.test(normalizeMemoryComparisonValue(prompt));
const fallbackMemories = memoryHits.length === 0 || hasRecallIntent
? listOpenPetsMemories(3).filter((entry) => !memoryHits.some((hit) => hit.entry.id === entry.id))
? listFamiliarOSMemories(3).filter((entry) => !memoryHits.some((hit) => hit.entry.id === entry.id))
: [];
if (memoryHits.length === 0 && chatHits.length === 0 && fallbackMemories.length === 0) {
@ -297,15 +297,15 @@ export function buildRelevantMemoryContext(prompt: string, chatHistory?: readonl
export { extractPromptMemoryCandidates };
export function capturePromptMemories(prompt: string): readonly OpenPetsMemoryEntry[] {
export function capturePromptMemories(prompt: string): readonly FamiliarOSMemoryEntry[] {
const candidates = extractPromptMemoryCandidates(prompt);
if (candidates.length === 0) {
return [];
}
return candidates.map((candidate) => storeOpenPetsMemory({ ...candidate, source: "chat" }));
return candidates.map((candidate) => storeFamiliarOSMemory({ ...candidate, source: "chat" }));
}
function getMemoryStore(): StoredOpenPetsMemoryV1 {
function getMemoryStore(): StoredFamiliarOSMemoryV1 {
if (cachedMemoryStore) {
return cachedMemoryStore;
}
@ -319,7 +319,7 @@ function getMemoryStore(): StoredOpenPetsMemoryV1 {
return cachedMemoryStore;
}
function commitMemoryStore(store: StoredOpenPetsMemoryV1): void {
function commitMemoryStore(store: StoredFamiliarOSMemoryV1): void {
cachedMemoryStore = {
version: 1,
entries: store.entries.map(cloneMemoryEntry),
@ -335,7 +335,7 @@ function getMemoryMarkdownMirrorPath(): string {
return join(app.getPath("userData"), memoryMarkdownMirrorFileName);
}
function writeMemoryStoreToDisk(store: StoredOpenPetsMemoryV1): void {
function writeMemoryStoreToDisk(store: StoredFamiliarOSMemoryV1): void {
const path = getMemoryStorePath();
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
@ -348,11 +348,11 @@ function writeMemoryStoreToDisk(store: StoredOpenPetsMemoryV1): void {
renameSync(markdownTempPath, markdownPath);
}
function buildMemoryMarkdownMirror(store: StoredOpenPetsMemoryV1): string {
function buildMemoryMarkdownMirror(store: StoredFamiliarOSMemoryV1): string {
const lines = [
"# OpenPets Memory",
"# FamiliarOS Memory",
"",
"This file mirrors the local OpenPets memory store for inspection and backup.",
"This file mirrors the local FamiliarOS memory store for inspection and backup.",
"",
];
@ -377,10 +377,10 @@ function buildMemoryMarkdownMirror(store: StoredOpenPetsMemoryV1): string {
return `${lines.join("\n")}\n`;
}
function normalizeStoredMemory(value: unknown): StoredOpenPetsMemoryV1 {
function normalizeStoredMemory(value: unknown): StoredFamiliarOSMemoryV1 {
const record = isRecord(value) ? value : {};
const entries = Array.isArray(record.entries)
? record.entries.map((entry) => normalizeStoredMemoryEntry(entry)).filter((entry): entry is OpenPetsMemoryEntry => Boolean(entry))
? record.entries.map((entry) => normalizeStoredMemoryEntry(entry)).filter((entry): entry is FamiliarOSMemoryEntry => Boolean(entry))
: [];
return {
version: 1,
@ -388,7 +388,7 @@ function normalizeStoredMemory(value: unknown): StoredOpenPetsMemoryV1 {
};
}
function normalizeStoredMemoryEntry(value: unknown): OpenPetsMemoryEntry | undefined {
function normalizeStoredMemoryEntry(value: unknown): FamiliarOSMemoryEntry | undefined {
if (!isRecord(value) || typeof value.id !== "string") {
return undefined;
}
@ -438,7 +438,7 @@ function normalizeMemoryText(value: unknown): string | undefined {
return normalized;
}
function normalizeMemoryKind(value: unknown): OpenPetsMemoryKind {
function normalizeMemoryKind(value: unknown): FamiliarOSMemoryKind {
return value === "identity" || value === "preference" || value === "fact" ? value : "note";
}
@ -481,7 +481,7 @@ function clampMemoryLimit(value: number, fallback: number): number {
return Math.min(Math.max(Math.round(value), 1), 25);
}
function pruneMemoryEntries(entries: readonly OpenPetsMemoryEntry[]): readonly OpenPetsMemoryEntry[] {
function pruneMemoryEntries(entries: readonly FamiliarOSMemoryEntry[]): readonly FamiliarOSMemoryEntry[] {
if (entries.length <= maxMemoryEntries) {
return [...entries];
}
@ -491,12 +491,12 @@ function pruneMemoryEntries(entries: readonly OpenPetsMemoryEntry[]): readonly O
.slice(0, maxMemoryEntries);
}
function scoreRetentionPriority(entry: OpenPetsMemoryEntry, now: number): number {
function scoreRetentionPriority(entry: FamiliarOSMemoryEntry, 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 {
function scoreMemoryEntry(entry: FamiliarOSMemoryEntry, queryTokens: ReadonlySet<string>, normalizedQuery: string, now: number): number {
const normalizedText = normalizeMemoryComparisonValue(entry.text);
const entryTokens = tokenize(`${entry.text} ${entry.tags.join(" ")}`);
let overlapCount = 0;
@ -541,11 +541,11 @@ function touchMemoryEntries(ids: readonly string[], now: number): void {
}
}
function getMemoryEntryById(id: string): OpenPetsMemoryEntry | undefined {
function getMemoryEntryById(id: string): FamiliarOSMemoryEntry | undefined {
return getMemoryStore().entries.find((entry) => entry.id === id);
}
function cloneMemoryEntry(entry: OpenPetsMemoryEntry): OpenPetsMemoryEntry {
function cloneMemoryEntry(entry: FamiliarOSMemoryEntry): FamiliarOSMemoryEntry {
return {
...entry,
tags: [...entry.tags],
@ -556,8 +556,8 @@ function mergeMemoryTags(left: readonly string[], right: readonly string[]): rea
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>;
function pickPreferredMemoryKind(left: FamiliarOSMemoryKind, right: FamiliarOSMemoryKind): FamiliarOSMemoryKind {
const rank = { note: 0, fact: 1, preference: 2, identity: 3 } satisfies Record<FamiliarOSMemoryKind, number>;
return rank[right] >= rank[left] ? right : left;
}

View file

@ -1,7 +1,7 @@
// Main-process i18n facade: holds the active locale (derived from the user's
// stored preference + the OS locale) and exposes `t()` for main-process call
// sites such as the tray. Renderer windows do not import this; they receive a
// resolved message map over IPC (see windows.ts `openpets:get-i18n`).
// resolved message map over IPC (see windows.ts `familiaros:get-i18n`).
//
// `electron` is loaded lazily (via createRequire, inside `systemLocale()`)
// rather than imported at module scope: this facade is pulled into the plugin

View file

@ -7,38 +7,38 @@
export const en = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Update available: {version}...",
"tray.defaultPet": "Default Pet: {name}",
"tray.toggleDefaultPet.show": "Show Default Pet",
"tray.toggleDefaultPet.hide": "Hide Default Pet",
"tray.pauseAllPets": "Pause All Pets",
"tray.resumeAllPets": "Resume All Pets",
"tray.managePets": "Manage Pets...",
"tray.defaultPet": "Default Familiar: {name}",
"tray.toggleDefaultPet.show": "Show Default Familiar",
"tray.toggleDefaultPet.hide": "Hide Default Familiar",
"tray.pauseAllPets": "Pause All Familiars",
"tray.resumeAllPets": "Resume All Familiars",
"tray.managePets": "Manage Familiars...",
"tray.controlCenter": "Control Center...",
"tray.website": "Website...",
"tray.integrations": "Integrations...",
"tray.plugins": "Plugins...",
"tray.settings": "Settings...",
"tray.openLogsFolder": "Open Logs Folder...",
"tray.quit": "Quit OpenPets",
"tray.quit": "Quit FamiliarOS",
// --- Shared ---
"common.latest": "latest",
"common.builtInPet": "Built-in Pet",
"common.builtInPet": "Built-in Familiar",
"common.cancel": "Cancel",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "Paused",
"pet.status.thinking": "Thinking",
"pet.status.working": "Working",
"pet.status.editing": "Editing",
"pet.status.testing": "Testing",
"pet.status.waiting": "Waiting",
"pet.status.done": "Done",
"pet.status.oops": "Oops",
"pet.status.hi": "Hi",
"pet.menu.hidePet": "Hide Pet",
"pet.menu.closePet": "Close pet",
"pet.menu.openControlCenter": "Open Control Center",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "Paused",
"familiar.status.thinking": "Thinking",
"familiar.status.working": "Working",
"familiar.status.editing": "Editing",
"familiar.status.testing": "Testing",
"familiar.status.waiting": "Waiting",
"familiar.status.done": "Done",
"familiar.status.oops": "Oops",
"familiar.status.hi": "Hi",
"familiar.menu.hidePet": "Hide Familiar",
"familiar.menu.closePet": "Close familiar",
"familiar.menu.openControlCenter": "Open Control Center",
// --- Common (renderer, shared across views) ---
"common.retry": "Retry",
@ -48,7 +48,7 @@ export const en = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Dashboard",
"nav.pets": "Pets",
"nav.familiars": "Familiars",
"nav.settings": "Settings",
"nav.plugins": "Plugins",
"nav.integrations": "Integrations",
@ -56,8 +56,8 @@ export const en = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Dashboard",
"route.dashboard.description": "Overview of your active companions, status, and system metrics.",
"route.pets.title": "Pets",
"route.pets.description": "Install, import, preview, and choose your default desktop companion.",
"route.familiars.title": "Familiars",
"route.familiars.description": "Install, import, preview, and choose your default desktop companion.",
"route.settings.title": "Settings",
"route.settings.description": "Configure startup behaviors, scale preferences, and animation settings.",
"route.plugins.title": "Plugins",
@ -67,13 +67,13 @@ export const en = {
// --- App shell (renderer) ---
"app.controlCenter": "Control Center",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "Gathering companion metrics...",
"dashboard.hero.eyebrow": "Primary Companion",
"dashboard.hero.desc": "Ready for your next coding session.",
"dashboard.hero.changePet": "Change Pet",
"dashboard.hero.changePet": "Change Familiar",
"dashboard.lastActive.none": "No activity yet",
"dashboard.update.available": "Update available",
"dashboard.update.error": "Check failed",
@ -85,7 +85,7 @@ export const en = {
"dashboard.stat.reactions": "Reactions",
"dashboard.stat.reactions.footer": "Total animations triggered",
"dashboard.stat.topCompanion": "Top Companion",
"dashboard.stat.topCompanion.footer": "Most active pet lately",
"dashboard.stat.topCompanion.footer": "Most active familiar lately",
"dashboard.activity.title": "Activity Overview",
"dashboard.activity.topReactions": "Top Reactions",
"dashboard.activity.noReactions": "No reactions recorded yet. Start coding!",
@ -96,17 +96,17 @@ export const en = {
"dashboard.reactionMix.reactions": "reactions",
"dashboard.reactionMix.empty": "No reaction mix yet.",
"dashboard.companions.title": "Top Companions",
"dashboard.companions.subtitle": "Most active pets",
"dashboard.companions.subtitle": "Most active familiars",
"dashboard.companions.empty": "No companion activity yet.",
"dashboard.lastActive.label": "Last active: ",
"dashboard.system.title": "System Health",
"dashboard.system.pets": "Pets",
"dashboard.system.pets.value": "{count} installed",
"dashboard.system.familiars": "Familiars",
"dashboard.system.familiars.value": "{count} installed",
"dashboard.system.plugins": "Plugins",
"dashboard.system.plugins.enabled": "{count} enabled",
"dashboard.system.catalog": "Catalog",
"dashboard.system.catalog.offline": "Offline",
"dashboard.system.catalog.pets": "{count} pets",
"dashboard.system.catalog.familiars": "{count} familiars",
"dashboard.system.catalog.ready": "Ready",
"dashboard.system.updates": "Updates",
"dashboard.system.version": "Version",
@ -114,77 +114,77 @@ export const en = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Coming Soon • Next Migration Target",
// --- Pets filters (renderer) ---
"pets.filter.all": "All",
"pets.filter.installed": "Installed",
"pets.filter.featured": "Featured",
"pets.filter.originals": "Originals",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Search pets...",
"pets.import": "Import pet",
"pets.gallery": "Gallery",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "All",
"familiars.filter.installed": "Installed",
"familiars.filter.featured": "Featured",
"familiars.filter.originals": "Originals",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "Search familiars...",
"familiars.import": "Import familiar",
"familiars.gallery": "Gallery",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Default",
"pets.badge.original": "Original",
"pets.badge.featured": "Featured",
"pets.badge.installed": "Installed",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Broken",
"pets.badge.ready": "Ready",
"pets.badge.originals": "Originals",
"pets.action.viewPet": "View pet",
"pets.action.install": "Install",
"pets.action.import": "Import",
"pets.action.default": "Default",
"pets.action.remove": "Remove",
"pets.action.refresh": "Refresh",
"pets.aria.view": "View {name}",
"pets.aria.install": "Install {name}",
"pets.aria.import": "Import {name} from Codex",
"pets.aria.setDefault": "Set {name} as default",
"pets.aria.remove": "Remove {name}",
"pets.busy.installing": "Installing",
"pets.busy.importing": "Importing",
"pets.busy.settingDefault": "Setting default",
"pets.busy.removing": "Removing",
"pets.busy.loadingPage": "Loading page",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "Default",
"familiars.badge.original": "Original",
"familiars.badge.featured": "Featured",
"familiars.badge.installed": "Installed",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "Broken",
"familiars.badge.ready": "Ready",
"familiars.badge.originals": "Originals",
"familiars.action.viewPet": "View familiar",
"familiars.action.install": "Install",
"familiars.action.import": "Import",
"familiars.action.default": "Default",
"familiars.action.remove": "Remove",
"familiars.action.refresh": "Refresh",
"familiars.aria.view": "View {name}",
"familiars.aria.install": "Install {name}",
"familiars.aria.import": "Import {name} from Codex",
"familiars.aria.setDefault": "Set {name} as default",
"familiars.aria.remove": "Remove {name}",
"familiars.busy.installing": "Installing",
"familiars.busy.importing": "Importing",
"familiars.busy.settingDefault": "Setting default",
"familiars.busy.removing": "Removing",
"familiars.busy.loadingPage": "Loading page",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Prev",
"pets.pager.next": "Next",
"pets.pager.count": "{count} pets",
"pets.pager.page": " · Page {page} of {pageCount}",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "Prev",
"familiars.pager.next": "Next",
"familiars.pager.count": "{count} familiars",
"familiars.pager.page": " · Page {page} of {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} pet details",
"pets.detail.closeAria": "Close pet details",
"pets.detail.eyebrow": "Pet detail",
"pets.detail.previewAnimations": "Preview Animations",
"pets.detail.preview.idle": "Idle",
"pets.detail.preview.thinking": "Thinking",
"pets.detail.preview.happy": "Happy",
"pets.detail.preview.wave": "Wave",
"pets.detail.installPet": "Install Pet",
"pets.detail.importCodexPet": "Import Codex Pet",
"pets.detail.setDefaultPet": "Set Default Pet",
"pets.detail.remove": "Remove",
"pets.detail.refresh": "Refresh",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "{name} familiar details",
"familiars.detail.closeAria": "Close familiar details",
"familiars.detail.eyebrow": "Familiar detail",
"familiars.detail.previewAnimations": "Preview Animations",
"familiars.detail.preview.idle": "Idle",
"familiars.detail.preview.thinking": "Thinking",
"familiars.detail.preview.happy": "Happy",
"familiars.detail.preview.wave": "Wave",
"familiars.detail.installPet": "Install Familiar",
"familiars.detail.importCodexPet": "Import Codex Familiar",
"familiars.detail.setDefaultPet": "Set Default Familiar",
"familiars.detail.remove": "Remove",
"familiars.detail.refresh": "Refresh",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "This installed pet is broken and cannot be selected as default.",
"pets.status.defaultProtected": "Default built-in pet. Protected from removal.",
"pets.status.default": "Default pet.",
"pets.status.installedCodex": "Installed and ready to become your default pet. Also found in ~/.codex/pets.",
"pets.status.installed": "Installed and ready to become your default pet.",
"pets.status.availableCodex": "Available to import from ~/.codex/pets.",
"pets.status.availableCatalog": "Available to install from the catalog.",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "This installed familiar is broken and cannot be selected as default.",
"familiars.status.defaultProtected": "Default built-in familiar. Protected from removal.",
"familiars.status.default": "Default familiar.",
"familiars.status.installedCodex": "Installed and ready to become your default familiar. Also found in ~/.codex/familiars.",
"familiars.status.installed": "Installed and ready to become your default familiar.",
"familiars.status.availableCodex": "Available to import from ~/.codex/familiars.",
"familiars.status.availableCatalog": "Available to install from the catalog.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} thumbnail",
"pets.spriteLabel.thumb": "{name} thumb",
"pets.spriteLabel.animatedPreview": "{name} animated preview",
"pets.spriteLabel.statePreview": "{name} {state} preview",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "{name} thumbnail",
"familiars.spriteLabel.thumb": "{name} thumb",
"familiars.spriteLabel.animatedPreview": "{name} animated preview",
"familiars.spriteLabel.statePreview": "{name} {state} preview",
// --- Settings: general (renderer) ---
"settings.nav.general": "General",
@ -192,22 +192,25 @@ export const en = {
"settings.nav.plugins": "Plugin Platform",
"settings.general.eyebrow": "Environment",
"settings.general.title": "General Settings",
"settings.general.showOnLaunch.title": "Show pet on launch",
"settings.general.showOnLaunch.description": "Keep OpenPets in the tray but hide the pet until requested.",
"settings.general.showOnLaunch.title": "Show familiar on launch",
"settings.general.showOnLaunch.description": "Keep FamiliarOS in the tray but hide the familiar until requested.",
"settings.general.launchAtLogin.title": "Launch at login",
"settings.general.launchAtLogin.supported": "Start OpenPets automatically when your computer starts.",
"settings.general.launchAtLogin.supported": "Start FamiliarOS automatically when your computer starts.",
"settings.general.launchAtLogin.unsupported": "Not supported on this platform.",
"settings.general.petScale.title": "Pet scale",
"settings.general.petScale.description": "Adjust how large the default desktop pet appears.",
"settings.general.resetPosition": "Reset Pet Position",
"settings.general.petScale.title": "Familiar scale",
"settings.general.petScale.description": "Adjust how large the default desktop familiar appears.",
"settings.general.familiarName.title": "Familiar name",
"settings.general.familiarName.description": "Give your default Familiar a custom name.",
"settings.general.resetPosition": "Reset Familiar Position",
"settings.general.systemStatus": "System Status",
"settings.general.updateAvailable": "Update Available",
"settings.general.checking": "Checking…",
"settings.general.checkForUpdates": "Check for Updates",
"settings.toast.startupSaved": "Startup preference saved.",
"settings.toast.loginStartupSaved": "Login startup preference saved.",
"settings.toast.petScaleSaved": "Pet scale saved.",
"settings.toast.positionReset": "Default pet position reset.",
"settings.toast.petScaleSaved": "Familiar scale saved.",
"settings.toast.familiarNameSaved": "Familiar name saved.",
"settings.toast.positionReset": "Default familiar position reset.",
"settings.busy.saving": "Saving",
"settings.busy.resetting": "Resetting",
"settings.busy.opening": "Opening",
@ -225,7 +228,7 @@ export const en = {
"settings.reactions.eyebrow": "Behavior",
"settings.reactions.title": "Reaction Mapping",
"settings.reactions.resetDefaults": "Reset to Defaults",
"settings.reactions.description": "Customize which animation plays for each agent reaction. Previews use the default pet.",
"settings.reactions.description": "Customize which animation plays for each agent reaction. Previews use the default familiar.",
"settings.reactions.previewAria": "Animation: {state}",
"settings.toast.reactionsReset": "Reaction animations reset.",
"settings.toast.reactionSaved": "Reaction animation saved.",
@ -258,7 +261,7 @@ export const en = {
"settings.reaction.waiting.label": "Waiting",
"settings.reaction.waiting.description": "Agent is blocked or waiting for permission.",
"settings.reaction.waving.label": "Waving",
"settings.reaction.waving.description": "Pet is greeting or getting attention.",
"settings.reaction.waving.description": "Familiar is greeting or getting attention.",
"settings.reaction.success.label": "Success",
"settings.reaction.success.description": "Task completed successfully.",
"settings.reaction.error.label": "Error",
@ -275,10 +278,10 @@ export const en = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.tts.provider.title": "Provider",
"settings.tts.provider.description": "Voice engine used by the pet and floating chat.",
"settings.tts.provider.description": "Voice engine used by the familiar and floating chat.",
"settings.tts.voice.title": "Voice",
"settings.tts.voice.description": "Select a voice for the current provider.",
"settings.tts.speed.title": "Speed",
@ -310,7 +313,7 @@ export const en = {
"settings.plugins.audio.description": "Allow plugin chimes, alerts, and bundled sounds.",
"settings.plugins.voice.title": "Plugins may speak (voice)",
"settings.plugins.voice.description": "Allow text-to-speech through the system voice.",
"settings.plugins.dynamicSpeech.title": "Allow AI-generated pet speech",
"settings.plugins.dynamicSpeech.title": "Allow AI-generated familiar speech",
"settings.plugins.dynamicSpeech.description": "Sensitive: lets approved plugins show model-generated bubbles.",
"settings.plugins.microphone.title": "Allow microphone (push-to-talk)",
"settings.plugins.microphone.description": "Sensitive: lets approved plugins capture one-shot voice input.",
@ -438,22 +441,22 @@ export const en = {
"plugins.busy.uninstalling": "Uninstalling",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Speech",
"plugins.permission.pet:reaction": "Reactions",
"plugins.permission.pet:move": "Movement",
"plugins.permission.familiar:speak": "Speech",
"plugins.permission.familiar:reaction": "Reactions",
"plugins.permission.familiar:move": "Movement",
"plugins.permission.timer": "Timers",
"plugins.permission.schedule": "Schedule",
"plugins.permission.storage": "Storage",
"plugins.permission.status": "Status",
"plugins.permission.commands": "Commands",
"plugins.permission.network": "Network",
"plugins.permission.pet:interact": "Bubble buttons",
"plugins.permission.pet:pin": "Pinned bubble",
"plugins.permission.pet:animate": "Custom animation",
"plugins.permission.pet:speak:dynamic": "AI speech",
"plugins.permission.pet:drop": "Drag & drop",
"plugins.permission.pets:read": "Read pets",
"plugins.permission.pets:manage": "Manage pets",
"plugins.permission.familiar:interact": "Bubble buttons",
"plugins.permission.familiar:pin": "Pinned bubble",
"plugins.permission.familiar:animate": "Custom animation",
"plugins.permission.familiar:speak:dynamic": "AI speech",
"plugins.permission.familiar:drop": "Drag & drop",
"plugins.permission.familiars:read": "Read familiars",
"plugins.permission.familiars:manage": "Manage familiars",
"plugins.permission.audio": "Sound",
"plugins.permission.events": "Events",
"plugins.permission.ui:toast": "Toasts",
@ -477,14 +480,14 @@ export const en = {
"integrations.commandMode.local": "Local development",
"integrations.loading": "Loading integrations…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Connect Claude Code to your OpenPets companion.",
"integrations.claude.description": "Connect Claude Code to your FamiliarOS companion.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Connect OpenCode globally to your OpenPets companion.",
"integrations.opencode.description": "Connect OpenCode globally to your FamiliarOS companion.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Connect Cursor to your OpenPets companion via global MCP config.",
"integrations.cursor.description": "Connect Cursor to your FamiliarOS companion via global MCP config.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Connect Pi coding-agent activity through the OpenPets Pi extension package.",
"integrations.pi.description": "Connect Pi coding-agent activity through the FamiliarOS Pi extension package.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -500,13 +503,13 @@ export const en = {
"integrations.commandSource": "Command Source",
"integrations.cliMode": "CLI mode",
"integrations.localUnavailable": " unavailable",
"integrations.commandModeHelp": "Use the published package for normal setup, bundled for the desktop app build, or local while developing OpenPets.",
"integrations.commandModeHelp": "Use the published package for normal setup, bundled for the desktop app build, or local while developing FamiliarOS.",
"integrations.connection": "Connection",
"integrations.statusRouting": "Status & Routing",
"integrations.globalSetup": "Global Setup",
"integrations.globalMcp": "Global MCP",
"integrations.petRouting": "Pet Routing",
"integrations.defaultPet": "Default Pet",
"integrations.petRouting": "Familiar Routing",
"integrations.defaultPet": "Default Familiar",
"integrations.configuration": "Configuration",
"integrations.commandPaths": "Command Paths",
"integrations.claudeCommand": "Claude Command",
@ -534,12 +537,12 @@ export const en = {
"integrations.rulesPreview": "Rules Preview",
"integrations.pi.manualSetup": "Manual Setup",
"integrations.pi.extension": "Pi Extension",
"integrations.pi.intro": "Install the OpenPets Pi extension from Pi, then use the slash commands inside a Pi session.",
"integrations.pi.intro": "Install the FamiliarOS Pi extension from Pi, then use the slash commands inside a Pi session.",
"integrations.pi.globalInstall": "Global install",
"integrations.pi.projectInstall": "Project install",
"integrations.pi.remove": "Remove",
"integrations.pi.slashCommands": "Slash commands",
"integrations.pi.outro": "Use global install for all Pi workspaces, or project install when you only want OpenPets in the current project.",
"integrations.pi.outro": "Use global install for all Pi workspaces, or project install when you only want FamiliarOS in the current project.",
"integrations.toast.pathSaved": "Path saved.",
"integrations.busy.installing": "Installing",
"integrations.busy.replacing": "Replacing",
@ -554,21 +557,21 @@ export const en = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Language",
"settings.language.description": "Display language for OpenPets menus and windows.",
"settings.language.description": "Display language for FamiliarOS menus and windows.",
"settings.language.system": "System default",
} as const;

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const es419: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Actualización disponible: {version}...",
"tray.defaultPet": "Mascota predeterminada: {name}",
"tray.toggleDefaultPet.show": "Mostrar mascota predeterminada",
"tray.toggleDefaultPet.hide": "Ocultar mascota predeterminada",
"tray.pauseAllPets": "Pausar todas las mascotas",
"tray.resumeAllPets": "Reanudar todas las mascotas",
"tray.managePets": "Administrar mascotas...",
"tray.defaultPet": "Familiar predeterminada: {name}",
"tray.toggleDefaultPet.show": "Mostrar Familiar predeterminada",
"tray.toggleDefaultPet.hide": "Ocultar Familiar predeterminada",
"tray.pauseAllPets": "Pausar todas las Familiars",
"tray.resumeAllPets": "Reanudar todas las Familiars",
"tray.managePets": "Administrar Familiars...",
"tray.controlCenter": "Centro de control...",
"tray.website": "Sitio web...",
"tray.integrations": "Integraciones...",
"tray.plugins": "Complementos...",
"tray.settings": "Configuración...",
"tray.openLogsFolder": "Abrir carpeta de registros...",
"tray.quit": "Salir de OpenPets",
"tray.quit": "Salir de FamiliarOS",
// --- Shared ---
"common.latest": "más reciente",
"common.builtInPet": "Mascota integrada",
"common.builtInPet": "Familiar integrada",
"common.cancel": "Cancelar",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "En pausa",
"pet.status.thinking": "Pensando",
"pet.status.working": "Trabajando",
"pet.status.editing": "Editando",
"pet.status.testing": "Probando",
"pet.status.waiting": "Esperando",
"pet.status.done": "Listo",
"pet.status.oops": "Ups",
"pet.status.hi": "Hola",
"pet.menu.hidePet": "Ocultar mascota",
"pet.menu.closePet": "Cerrar mascota",
"pet.menu.openControlCenter": "Abrir Centro de control",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "En pausa",
"familiar.status.thinking": "Pensando",
"familiar.status.working": "Trabajando",
"familiar.status.editing": "Editando",
"familiar.status.testing": "Probando",
"familiar.status.waiting": "Esperando",
"familiar.status.done": "Listo",
"familiar.status.oops": "Ups",
"familiar.status.hi": "Hola",
"familiar.menu.hidePet": "Ocultar Familiar",
"familiar.menu.closePet": "Cerrar Familiar",
"familiar.menu.openControlCenter": "Abrir Centro de control",
// --- Common (renderer, shared across views) ---
"common.retry": "Reintentar",
@ -45,7 +45,7 @@ export const es419: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Panel",
"nav.pets": "Mascotas",
"nav.familiars": "Familiars",
"nav.settings": "Configuración",
"nav.plugins": "Complementos",
"nav.integrations": "Integraciones",
@ -53,8 +53,8 @@ export const es419: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Panel",
"route.dashboard.description": "Resumen de tus compañeros activos, su estado y las métricas del sistema.",
"route.pets.title": "Mascotas",
"route.pets.description": "Instala, importa, previsualiza y elige tu compañero de escritorio predeterminado.",
"route.familiars.title": "Familiars",
"route.familiars.description": "Instala, importa, previsualiza y elige tu compañero de escritorio predeterminado.",
"route.settings.title": "Configuración",
"route.settings.description": "Configura los comportamientos de inicio, las preferencias de escala y los ajustes de animación.",
"route.plugins.title": "Complementos",
@ -64,13 +64,13 @@ export const es419: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "Centro de control",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "Recopilando métricas del compañero...",
"dashboard.hero.eyebrow": "Compañero principal",
"dashboard.hero.desc": "Listo para tu próxima sesión de programación.",
"dashboard.hero.changePet": "Cambiar mascota",
"dashboard.hero.changePet": "Cambiar Familiar",
"dashboard.lastActive.none": "Aún sin actividad",
"dashboard.update.available": "Actualización disponible",
"dashboard.update.error": "Falló la verificación",
@ -82,7 +82,7 @@ export const es419: Partial<Messages> = {
"dashboard.stat.reactions": "Reacciones",
"dashboard.stat.reactions.footer": "Total de animaciones activadas",
"dashboard.stat.topCompanion": "Compañero destacado",
"dashboard.stat.topCompanion.footer": "Mascota más activa últimamente",
"dashboard.stat.topCompanion.footer": "Familiar más activa últimamente",
"dashboard.activity.title": "Resumen de actividad",
"dashboard.activity.topReactions": "Reacciones principales",
"dashboard.activity.noReactions": "Aún no se registran reacciones. ¡Empieza a programar!",
@ -93,17 +93,17 @@ export const es419: Partial<Messages> = {
"dashboard.reactionMix.reactions": "reacciones",
"dashboard.reactionMix.empty": "Aún no hay mezcla de reacciones.",
"dashboard.companions.title": "Compañeros principales",
"dashboard.companions.subtitle": "Mascotas más activas",
"dashboard.companions.subtitle": "Familiars más activas",
"dashboard.companions.empty": "Aún no hay actividad de compañeros.",
"dashboard.lastActive.label": "Última actividad: ",
"dashboard.system.title": "Estado del sistema",
"dashboard.system.pets": "Mascotas",
"dashboard.system.pets.value": "{count} instaladas",
"dashboard.system.familiars": "Familiars",
"dashboard.system.familiars.value": "{count} instaladas",
"dashboard.system.plugins": "Complementos",
"dashboard.system.plugins.enabled": "{count} habilitados",
"dashboard.system.catalog": "Catálogo",
"dashboard.system.catalog.offline": "Sin conexión",
"dashboard.system.catalog.pets": "{count} mascotas",
"dashboard.system.catalog.familiars": "{count} Familiars",
"dashboard.system.catalog.ready": "Listo",
"dashboard.system.updates": "Actualizaciones",
"dashboard.system.version": "Versión",
@ -111,77 +111,77 @@ export const es419: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Próximamente • Siguiente objetivo de migración",
// --- Pets filters (renderer) ---
"pets.filter.all": "Todas",
"pets.filter.installed": "Instaladas",
"pets.filter.featured": "Destacadas",
"pets.filter.originals": "Originales",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Buscar mascotas...",
"pets.import": "Importar mascota",
"pets.gallery": "Galería",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "Todas",
"familiars.filter.installed": "Instaladas",
"familiars.filter.featured": "Destacadas",
"familiars.filter.originals": "Originales",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "Buscar Familiars...",
"familiars.import": "Importar Familiar",
"familiars.gallery": "Galería",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Predeterminada",
"pets.badge.original": "Original",
"pets.badge.featured": "Destacada",
"pets.badge.installed": "Instalada",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Dañada",
"pets.badge.ready": "Lista",
"pets.badge.originals": "Originales",
"pets.action.viewPet": "Ver mascota",
"pets.action.install": "Instalar",
"pets.action.import": "Importar",
"pets.action.default": "Predeterminada",
"pets.action.remove": "Quitar",
"pets.action.refresh": "Actualizar",
"pets.aria.view": "Ver {name}",
"pets.aria.install": "Instalar {name}",
"pets.aria.import": "Importar {name} desde Codex",
"pets.aria.setDefault": "Establecer {name} como predeterminada",
"pets.aria.remove": "Quitar {name}",
"pets.busy.installing": "Instalando",
"pets.busy.importing": "Importando",
"pets.busy.settingDefault": "Estableciendo predeterminada",
"pets.busy.removing": "Quitando",
"pets.busy.loadingPage": "Cargando página",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "Predeterminada",
"familiars.badge.original": "Original",
"familiars.badge.featured": "Destacada",
"familiars.badge.installed": "Instalada",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "Dañada",
"familiars.badge.ready": "Lista",
"familiars.badge.originals": "Originales",
"familiars.action.viewPet": "Ver Familiar",
"familiars.action.install": "Instalar",
"familiars.action.import": "Importar",
"familiars.action.default": "Predeterminada",
"familiars.action.remove": "Quitar",
"familiars.action.refresh": "Actualizar",
"familiars.aria.view": "Ver {name}",
"familiars.aria.install": "Instalar {name}",
"familiars.aria.import": "Importar {name} desde Codex",
"familiars.aria.setDefault": "Establecer {name} como predeterminada",
"familiars.aria.remove": "Quitar {name}",
"familiars.busy.installing": "Instalando",
"familiars.busy.importing": "Importando",
"familiars.busy.settingDefault": "Estableciendo predeterminada",
"familiars.busy.removing": "Quitando",
"familiars.busy.loadingPage": "Cargando página",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Anterior",
"pets.pager.next": "Siguiente",
"pets.pager.count": "{count} mascotas",
"pets.pager.page": " · Página {page} de {pageCount}",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "Anterior",
"familiars.pager.next": "Siguiente",
"familiars.pager.count": "{count} Familiars",
"familiars.pager.page": " · Página {page} de {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "Detalles de la mascota {name}",
"pets.detail.closeAria": "Cerrar detalles de la mascota",
"pets.detail.eyebrow": "Detalle de la mascota",
"pets.detail.previewAnimations": "Previsualizar animaciones",
"pets.detail.preview.idle": "Inactiva",
"pets.detail.preview.thinking": "Pensando",
"pets.detail.preview.happy": "Feliz",
"pets.detail.preview.wave": "Saludo",
"pets.detail.installPet": "Instalar mascota",
"pets.detail.importCodexPet": "Importar mascota de Codex",
"pets.detail.setDefaultPet": "Establecer como predeterminada",
"pets.detail.remove": "Quitar",
"pets.detail.refresh": "Actualizar",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "Detalles de la Familiar {name}",
"familiars.detail.closeAria": "Cerrar detalles de la Familiar",
"familiars.detail.eyebrow": "Detalle de la Familiar",
"familiars.detail.previewAnimations": "Previsualizar animaciones",
"familiars.detail.preview.idle": "Inactiva",
"familiars.detail.preview.thinking": "Pensando",
"familiars.detail.preview.happy": "Feliz",
"familiars.detail.preview.wave": "Saludo",
"familiars.detail.installPet": "Instalar Familiar",
"familiars.detail.importCodexPet": "Importar Familiar de Codex",
"familiars.detail.setDefaultPet": "Establecer como predeterminada",
"familiars.detail.remove": "Quitar",
"familiars.detail.refresh": "Actualizar",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "Esta mascota instalada está dañada y no se puede seleccionar como predeterminada.",
"pets.status.defaultProtected": "Mascota integrada predeterminada. Protegida contra eliminación.",
"pets.status.default": "Mascota predeterminada.",
"pets.status.installedCodex": "Instalada y lista para convertirse en tu mascota predeterminada. También se encuentra en ~/.codex/pets.",
"pets.status.installed": "Instalada y lista para convertirse en tu mascota predeterminada.",
"pets.status.availableCodex": "Disponible para importar desde ~/.codex/pets.",
"pets.status.availableCatalog": "Disponible para instalar desde el catálogo.",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "Esta Familiar instalada está dañada y no se puede seleccionar como predeterminada.",
"familiars.status.defaultProtected": "Familiar integrada predeterminada. Protegida contra eliminación.",
"familiars.status.default": "Familiar predeterminada.",
"familiars.status.installedCodex": "Instalada y lista para convertirse en tu Familiar predeterminada. También se encuentra en ~/.codex/familiars.",
"familiars.status.installed": "Instalada y lista para convertirse en tu Familiar predeterminada.",
"familiars.status.availableCodex": "Disponible para importar desde ~/.codex/familiars.",
"familiars.status.availableCatalog": "Disponible para instalar desde el catálogo.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "Miniatura de {name}",
"pets.spriteLabel.thumb": "Miniatura de {name}",
"pets.spriteLabel.animatedPreview": "Vista previa animada de {name}",
"pets.spriteLabel.statePreview": "Vista previa de {name} en estado {state}",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "Miniatura de {name}",
"familiars.spriteLabel.thumb": "Miniatura de {name}",
"familiars.spriteLabel.animatedPreview": "Vista previa animada de {name}",
"familiars.spriteLabel.statePreview": "Vista previa de {name} en estado {state}",
// --- Settings: general (renderer) ---
"settings.nav.general": "General",
@ -189,22 +189,22 @@ export const es419: Partial<Messages> = {
"settings.nav.plugins": "Plataforma de complementos",
"settings.general.eyebrow": "Entorno",
"settings.general.title": "Configuración general",
"settings.general.showOnLaunch.title": "Mostrar la mascota al iniciar",
"settings.general.showOnLaunch.description": "Mantén OpenPets en la bandeja pero oculta la mascota hasta que la solicites.",
"settings.general.showOnLaunch.title": "Mostrar la Familiar al iniciar",
"settings.general.showOnLaunch.description": "Mantén FamiliarOS en la bandeja pero oculta la Familiar hasta que la solicites.",
"settings.general.launchAtLogin.title": "Iniciar al iniciar sesión",
"settings.general.launchAtLogin.supported": "Inicia OpenPets automáticamente cuando se enciende tu computadora.",
"settings.general.launchAtLogin.supported": "Inicia FamiliarOS automáticamente cuando se enciende tu computadora.",
"settings.general.launchAtLogin.unsupported": "No es compatible con esta plataforma.",
"settings.general.petScale.title": "Escala de la mascota",
"settings.general.petScale.description": "Ajusta el tamaño con el que aparece la mascota de escritorio predeterminada.",
"settings.general.resetPosition": "Restablecer posición de la mascota",
"settings.general.petScale.title": "Escala de la Familiar",
"settings.general.petScale.description": "Ajusta el tamaño con el que aparece la Familiar de escritorio predeterminada.",
"settings.general.resetPosition": "Restablecer posición de la Familiar",
"settings.general.systemStatus": "Estado del sistema",
"settings.general.updateAvailable": "Actualización disponible",
"settings.general.checking": "Verificando…",
"settings.general.checkForUpdates": "Buscar actualizaciones",
"settings.toast.startupSaved": "Preferencia de inicio guardada.",
"settings.toast.loginStartupSaved": "Preferencia de inicio de sesión guardada.",
"settings.toast.petScaleSaved": "Escala de la mascota guardada.",
"settings.toast.positionReset": "Posición de la mascota predeterminada restablecida.",
"settings.toast.petScaleSaved": "Escala de la Familiar guardada.",
"settings.toast.positionReset": "Posición de la Familiar predeterminada restablecida.",
"settings.busy.saving": "Guardando",
"settings.busy.resetting": "Restableciendo",
"settings.busy.opening": "Abriendo",
@ -222,7 +222,7 @@ export const es419: Partial<Messages> = {
"settings.reactions.eyebrow": "Comportamiento",
"settings.reactions.title": "Asignación de reacciones",
"settings.reactions.resetDefaults": "Restablecer valores predeterminados",
"settings.reactions.description": "Personaliza qué animación se reproduce para cada reacción del agente. Las vistas previas usan la mascota predeterminada.",
"settings.reactions.description": "Personaliza qué animación se reproduce para cada reacción del agente. Las vistas previas usan la Familiar predeterminada.",
"settings.reactions.previewAria": "Animación: {state}",
"settings.toast.reactionsReset": "Animaciones de reacción restablecidas.",
"settings.toast.reactionSaved": "Animación de reacción guardada.",
@ -255,7 +255,7 @@ export const es419: Partial<Messages> = {
"settings.reaction.waiting.label": "Esperando",
"settings.reaction.waiting.description": "El agente está bloqueado o esperando permisos.",
"settings.reaction.waving.label": "Saludando",
"settings.reaction.waving.description": "La mascota saluda o llama la atención.",
"settings.reaction.waving.description": "La Familiar saluda o llama la atención.",
"settings.reaction.success.label": "Éxito",
"settings.reaction.success.description": "La tarea se completó correctamente.",
"settings.reaction.error.label": "Error",
@ -272,13 +272,13 @@ export const es419: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "Los complementos pueden reproducir sonido",
"settings.plugins.audio.description": "Permite tonos, alertas y sonidos incluidos de los complementos.",
"settings.plugins.voice.title": "Los complementos pueden hablar (voz)",
"settings.plugins.voice.description": "Permite la conversión de texto a voz mediante la voz del sistema.",
"settings.plugins.dynamicSpeech.title": "Permitir habla de la mascota generada por IA",
"settings.plugins.dynamicSpeech.title": "Permitir habla de la Familiar generada por IA",
"settings.plugins.dynamicSpeech.description": "Sensible: permite que los complementos aprobados muestren globos generados por el modelo.",
"settings.plugins.microphone.title": "Permitir micrófono (presionar para hablar)",
"settings.plugins.microphone.description": "Sensible: permite que los complementos aprobados capturen una entrada de voz puntual.",
@ -401,22 +401,22 @@ export const es419: Partial<Messages> = {
"plugins.busy.uninstalling": "Desinstalando",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Habla",
"plugins.permission.pet:reaction": "Reacciones",
"plugins.permission.pet:move": "Movimiento",
"plugins.permission.familiar:speak": "Habla",
"plugins.permission.familiar:reaction": "Reacciones",
"plugins.permission.familiar:move": "Movimiento",
"plugins.permission.timer": "Temporizadores",
"plugins.permission.schedule": "Programación",
"plugins.permission.storage": "Almacenamiento",
"plugins.permission.status": "Estado",
"plugins.permission.commands": "Comandos",
"plugins.permission.network": "Red",
"plugins.permission.pet:interact": "Botones de globo",
"plugins.permission.pet:pin": "Globo fijado",
"plugins.permission.pet:animate": "Animación personalizada",
"plugins.permission.pet:speak:dynamic": "Habla por IA",
"plugins.permission.pet:drop": "Arrastrar y soltar",
"plugins.permission.pets:read": "Leer mascotas",
"plugins.permission.pets:manage": "Administrar mascotas",
"plugins.permission.familiar:interact": "Botones de globo",
"plugins.permission.familiar:pin": "Globo fijado",
"plugins.permission.familiar:animate": "Animación personalizada",
"plugins.permission.familiar:speak:dynamic": "Habla por IA",
"plugins.permission.familiar:drop": "Arrastrar y soltar",
"plugins.permission.familiars:read": "Leer Familiars",
"plugins.permission.familiars:manage": "Administrar Familiars",
"plugins.permission.audio": "Sonido",
"plugins.permission.events": "Eventos",
"plugins.permission.ui:toast": "Notificaciones emergentes",
@ -440,14 +440,14 @@ export const es419: Partial<Messages> = {
"integrations.commandMode.local": "Desarrollo local",
"integrations.loading": "Cargando integraciones…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Conecta Claude Code a tu compañero de OpenPets.",
"integrations.claude.description": "Conecta Claude Code a tu compañero de FamiliarOS.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Conecta OpenCode globalmente a tu compañero de OpenPets.",
"integrations.opencode.description": "Conecta OpenCode globalmente a tu compañero de FamiliarOS.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Conecta Cursor a tu compañero de OpenPets mediante la configuración global de MCP.",
"integrations.cursor.description": "Conecta Cursor a tu compañero de FamiliarOS mediante la configuración global de MCP.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Conecta la actividad del agente de programación Pi mediante el paquete de extensión Pi de OpenPets.",
"integrations.pi.description": "Conecta la actividad del agente de programación Pi mediante el paquete de extensión Pi de FamiliarOS.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const es419: Partial<Messages> = {
"integrations.commandSource": "Origen del comando",
"integrations.cliMode": "Modo de CLI",
"integrations.localUnavailable": " no disponible",
"integrations.commandModeHelp": "Usa el paquete publicado para una configuración normal, el incluido para la compilación de la app de escritorio, o el local mientras desarrollas OpenPets.",
"integrations.commandModeHelp": "Usa el paquete publicado para una configuración normal, el incluido para la compilación de la app de escritorio, o el local mientras desarrollas FamiliarOS.",
"integrations.connection": "Conexión",
"integrations.statusRouting": "Estado y enrutamiento",
"integrations.globalSetup": "Configuración global",
"integrations.globalMcp": "MCP global",
"integrations.petRouting": "Enrutamiento de mascotas",
"integrations.defaultPet": "Mascota predeterminada",
"integrations.petRouting": "Enrutamiento de Familiars",
"integrations.defaultPet": "Familiar predeterminada",
"integrations.configuration": "Configuración",
"integrations.commandPaths": "Rutas de comandos",
"integrations.claudeCommand": "Comando de Claude",
@ -497,12 +497,12 @@ export const es419: Partial<Messages> = {
"integrations.rulesPreview": "Vista previa de reglas",
"integrations.pi.manualSetup": "Configuración manual",
"integrations.pi.extension": "Extensión Pi",
"integrations.pi.intro": "Instala la extensión Pi de OpenPets desde Pi y luego usa los comandos de barra dentro de una sesión de Pi.",
"integrations.pi.intro": "Instala la extensión Pi de FamiliarOS desde Pi y luego usa los comandos de barra dentro de una sesión de Pi.",
"integrations.pi.globalInstall": "Instalación global",
"integrations.pi.projectInstall": "Instalación del proyecto",
"integrations.pi.remove": "Quitar",
"integrations.pi.slashCommands": "Comandos de barra",
"integrations.pi.outro": "Usa la instalación global para todos los espacios de trabajo de Pi, o la instalación del proyecto cuando solo quieras OpenPets en el proyecto actual.",
"integrations.pi.outro": "Usa la instalación global para todos los espacios de trabajo de Pi, o la instalación del proyecto cuando solo quieras FamiliarOS en el proyecto actual.",
"integrations.toast.pathSaved": "Ruta guardada.",
"integrations.busy.installing": "Instalando",
"integrations.busy.replacing": "Reemplazando",
@ -517,21 +517,21 @@ export const es419: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Idioma",
"settings.language.description": "Idioma de visualización de los menús y ventanas de OpenPets.",
"settings.language.description": "Idioma de visualización de los menús y ventanas de FamiliarOS.",
"settings.language.system": "Predeterminado del sistema",
};

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const ja: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "アップデートがあります: {version}...",
"tray.defaultPet": "デフォルトのペット: {name}",
"tray.toggleDefaultPet.show": "デフォルトのペットを表示",
"tray.toggleDefaultPet.hide": "デフォルトのペットを非表示",
"tray.pauseAllPets": "すべてのペットを一時停止",
"tray.resumeAllPets": "すべてのペットを再開",
"tray.managePets": "ペットを管理...",
"tray.defaultPet": "デフォルトのFamiliar: {name}",
"tray.toggleDefaultPet.show": "デフォルトのFamiliarを表示",
"tray.toggleDefaultPet.hide": "デフォルトのFamiliarを非表示",
"tray.pauseAllPets": "すべてのFamiliarを一時停止",
"tray.resumeAllPets": "すべてのFamiliarを再開",
"tray.managePets": "Familiarを管理...",
"tray.controlCenter": "コントロールセンター...",
"tray.website": "ウェブサイト...",
"tray.integrations": "連携...",
"tray.plugins": "プラグイン...",
"tray.settings": "設定...",
"tray.openLogsFolder": "ログフォルダを開く...",
"tray.quit": "OpenPets を終了",
"tray.quit": "FamiliarOS を終了",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "組み込みペット",
"common.builtInPet": "組み込みFamiliar",
"common.cancel": "キャンセル",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "一時停止中",
"pet.status.thinking": "思考中",
"pet.status.working": "作業中",
"pet.status.editing": "編集中",
"pet.status.testing": "テスト中",
"pet.status.waiting": "待機中",
"pet.status.done": "完了",
"pet.status.oops": "エラー",
"pet.status.hi": "こんにちは",
"pet.menu.hidePet": "ペットを隠す",
"pet.menu.closePet": "ペットを閉じる",
"pet.menu.openControlCenter": "コントロールセンターを開く",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "一時停止中",
"familiar.status.thinking": "思考中",
"familiar.status.working": "作業中",
"familiar.status.editing": "編集中",
"familiar.status.testing": "テスト中",
"familiar.status.waiting": "待機中",
"familiar.status.done": "完了",
"familiar.status.oops": "エラー",
"familiar.status.hi": "こんにちは",
"familiar.menu.hidePet": "Familiarを隠す",
"familiar.menu.closePet": "Familiarを閉じる",
"familiar.menu.openControlCenter": "コントロールセンターを開く",
// --- Common (renderer, shared across views) ---
"common.retry": "再試行",
@ -45,7 +45,7 @@ export const ja: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "ダッシュボード",
"nav.pets": "ペット",
"nav.familiars": "Familiar",
"nav.settings": "設定",
"nav.plugins": "プラグイン",
"nav.integrations": "連携",
@ -53,8 +53,8 @@ export const ja: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "ダッシュボード",
"route.dashboard.description": "アクティブな相棒、ステータス、システム指標の概要。",
"route.pets.title": "ペット",
"route.pets.description": "デスクトップの相棒をインストール、インポート、プレビューして選びましょう。",
"route.familiars.title": "Familiar",
"route.familiars.description": "デスクトップの相棒をインストール、インポート、プレビューして選びましょう。",
"route.settings.title": "設定",
"route.settings.description": "起動時の動作、サイズ設定、アニメーション設定を構成します。",
"route.plugins.title": "プラグイン",
@ -64,13 +64,13 @@ export const ja: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "コントロールセンター",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "相棒の指標を収集中...",
"dashboard.hero.eyebrow": "メインの相棒",
"dashboard.hero.desc": "次のコーディングセッションの準備は万全です。",
"dashboard.hero.changePet": "ペットを変更",
"dashboard.hero.changePet": "Familiarを変更",
"dashboard.lastActive.none": "まだ活動がありません",
"dashboard.update.available": "アップデートあり",
"dashboard.update.error": "確認に失敗",
@ -82,7 +82,7 @@ export const ja: Partial<Messages> = {
"dashboard.stat.reactions": "リアクション",
"dashboard.stat.reactions.footer": "発生したアニメーションの合計",
"dashboard.stat.topCompanion": "トップの相棒",
"dashboard.stat.topCompanion.footer": "最近もっとも活発なペット",
"dashboard.stat.topCompanion.footer": "最近もっとも活発なFamiliar",
"dashboard.activity.title": "アクティビティ概要",
"dashboard.activity.topReactions": "トップリアクション",
"dashboard.activity.noReactions": "まだリアクションがありません。コーディングを始めましょう!",
@ -93,17 +93,17 @@ export const ja: Partial<Messages> = {
"dashboard.reactionMix.reactions": "リアクション",
"dashboard.reactionMix.empty": "まだリアクションの内訳がありません。",
"dashboard.companions.title": "トップの相棒",
"dashboard.companions.subtitle": "もっとも活発なペット",
"dashboard.companions.subtitle": "もっとも活発なFamiliar",
"dashboard.companions.empty": "まだ相棒の活動がありません。",
"dashboard.lastActive.label": "最終アクティブ: ",
"dashboard.system.title": "システムの状態",
"dashboard.system.pets": "ペット",
"dashboard.system.pets.value": "{count} 個インストール済み",
"dashboard.system.familiars": "Familiar",
"dashboard.system.familiars.value": "{count} 個インストール済み",
"dashboard.system.plugins": "プラグイン",
"dashboard.system.plugins.enabled": "{count} 個有効",
"dashboard.system.catalog": "カタログ",
"dashboard.system.catalog.offline": "オフライン",
"dashboard.system.catalog.pets": "{count} 個のペット",
"dashboard.system.catalog.familiars": "{count} 個のFamiliar",
"dashboard.system.catalog.ready": "準備完了",
"dashboard.system.updates": "アップデート",
"dashboard.system.version": "バージョン",
@ -111,77 +111,77 @@ export const ja: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "近日公開 • 次の移行対象",
// --- Pets filters (renderer) ---
"pets.filter.all": "すべて",
"pets.filter.installed": "インストール済み",
"pets.filter.featured": "おすすめ",
"pets.filter.originals": "オリジナル",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "ペットを検索...",
"pets.import": "ペットをインポート",
"pets.gallery": "ギャラリー",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "すべて",
"familiars.filter.installed": "インストール済み",
"familiars.filter.featured": "おすすめ",
"familiars.filter.originals": "オリジナル",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "Familiarを検索...",
"familiars.import": "Familiarをインポート",
"familiars.gallery": "ギャラリー",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "デフォルト",
"pets.badge.original": "オリジナル",
"pets.badge.featured": "おすすめ",
"pets.badge.installed": "インストール済み",
"pets.badge.codex": "Codex",
"pets.badge.broken": "破損",
"pets.badge.ready": "準備完了",
"pets.badge.originals": "オリジナル",
"pets.action.viewPet": "ペットを表示",
"pets.action.install": "インストール",
"pets.action.import": "インポート",
"pets.action.default": "デフォルト",
"pets.action.remove": "削除",
"pets.action.refresh": "更新",
"pets.aria.view": "{name} を表示",
"pets.aria.install": "{name} をインストール",
"pets.aria.import": "{name} を Codex からインポート",
"pets.aria.setDefault": "{name} をデフォルトに設定",
"pets.aria.remove": "{name} を削除",
"pets.busy.installing": "インストール中",
"pets.busy.importing": "インポート中",
"pets.busy.settingDefault": "デフォルトに設定中",
"pets.busy.removing": "削除中",
"pets.busy.loadingPage": "ページを読み込み中",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "デフォルト",
"familiars.badge.original": "オリジナル",
"familiars.badge.featured": "おすすめ",
"familiars.badge.installed": "インストール済み",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "破損",
"familiars.badge.ready": "準備完了",
"familiars.badge.originals": "オリジナル",
"familiars.action.viewPet": "Familiarを表示",
"familiars.action.install": "インストール",
"familiars.action.import": "インポート",
"familiars.action.default": "デフォルト",
"familiars.action.remove": "削除",
"familiars.action.refresh": "更新",
"familiars.aria.view": "{name} を表示",
"familiars.aria.install": "{name} をインストール",
"familiars.aria.import": "{name} を Codex からインポート",
"familiars.aria.setDefault": "{name} をデフォルトに設定",
"familiars.aria.remove": "{name} を削除",
"familiars.busy.installing": "インストール中",
"familiars.busy.importing": "インポート中",
"familiars.busy.settingDefault": "デフォルトに設定中",
"familiars.busy.removing": "削除中",
"familiars.busy.loadingPage": "ページを読み込み中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "前へ",
"pets.pager.next": "次へ",
"pets.pager.count": "{count} 個のペット",
"pets.pager.page": " · {pageCount} ページ中 {page} ページ目",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "前へ",
"familiars.pager.next": "次へ",
"familiars.pager.count": "{count} 個のFamiliar",
"familiars.pager.page": " · {pageCount} ページ中 {page} ページ目",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} のペット詳細",
"pets.detail.closeAria": "ペット詳細を閉じる",
"pets.detail.eyebrow": "ペット詳細",
"pets.detail.previewAnimations": "アニメーションをプレビュー",
"pets.detail.preview.idle": "待機",
"pets.detail.preview.thinking": "思考中",
"pets.detail.preview.happy": "うれしい",
"pets.detail.preview.wave": "手を振る",
"pets.detail.installPet": "ペットをインストール",
"pets.detail.importCodexPet": "Codex ペットをインポート",
"pets.detail.setDefaultPet": "デフォルトのペットに設定",
"pets.detail.remove": "削除",
"pets.detail.refresh": "更新",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "{name} のFamiliar詳細",
"familiars.detail.closeAria": "Familiar詳細を閉じる",
"familiars.detail.eyebrow": "Familiar詳細",
"familiars.detail.previewAnimations": "アニメーションをプレビュー",
"familiars.detail.preview.idle": "待機",
"familiars.detail.preview.thinking": "思考中",
"familiars.detail.preview.happy": "うれしい",
"familiars.detail.preview.wave": "手を振る",
"familiars.detail.installPet": "Familiarをインストール",
"familiars.detail.importCodexPet": "Codex Familiarをインポート",
"familiars.detail.setDefaultPet": "デフォルトのFamiliarに設定",
"familiars.detail.remove": "削除",
"familiars.detail.refresh": "更新",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "このインストール済みペットは破損しており、デフォルトに設定できません。",
"pets.status.defaultProtected": "デフォルトの組み込みペット。削除から保護されています。",
"pets.status.default": "デフォルトのペット。",
"pets.status.installedCodex": "インストール済みで、デフォルトのペットに設定できます。~/.codex/pets にもあります。",
"pets.status.installed": "インストール済みで、デフォルトのペットに設定できます。",
"pets.status.availableCodex": "~/.codex/pets からインポートできます。",
"pets.status.availableCatalog": "カタログからインストールできます。",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "このインストール済みFamiliarは破損しており、デフォルトに設定できません。",
"familiars.status.defaultProtected": "デフォルトの組み込みFamiliar。削除から保護されています。",
"familiars.status.default": "デフォルトのFamiliar。",
"familiars.status.installedCodex": "インストール済みで、デフォルトのFamiliarに設定できます。~/.codex/familiars にもあります。",
"familiars.status.installed": "インストール済みで、デフォルトのFamiliarに設定できます。",
"familiars.status.availableCodex": "~/.codex/familiars からインポートできます。",
"familiars.status.availableCatalog": "カタログからインストールできます。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} のサムネイル",
"pets.spriteLabel.thumb": "{name} のサムネイル",
"pets.spriteLabel.animatedPreview": "{name} のアニメーションプレビュー",
"pets.spriteLabel.statePreview": "{name} の {state} プレビュー",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "{name} のサムネイル",
"familiars.spriteLabel.thumb": "{name} のサムネイル",
"familiars.spriteLabel.animatedPreview": "{name} のアニメーションプレビュー",
"familiars.spriteLabel.statePreview": "{name} の {state} プレビュー",
// --- Settings: general (renderer) ---
"settings.nav.general": "一般",
@ -189,22 +189,22 @@ export const ja: Partial<Messages> = {
"settings.nav.plugins": "プラグインプラットフォーム",
"settings.general.eyebrow": "環境",
"settings.general.title": "一般設定",
"settings.general.showOnLaunch.title": "起動時にペットを表示",
"settings.general.showOnLaunch.description": "OpenPets をトレイに常駐させつつ、要求があるまでペットを非表示にします。",
"settings.general.showOnLaunch.title": "起動時にFamiliarを表示",
"settings.general.showOnLaunch.description": "FamiliarOS をトレイに常駐させつつ、要求があるまでFamiliarを非表示にします。",
"settings.general.launchAtLogin.title": "ログイン時に起動",
"settings.general.launchAtLogin.supported": "コンピューターの起動時に OpenPets を自動的に開始します。",
"settings.general.launchAtLogin.supported": "コンピューターの起動時に FamiliarOS を自動的に開始します。",
"settings.general.launchAtLogin.unsupported": "このプラットフォームではサポートされていません。",
"settings.general.petScale.title": "ペットのサイズ",
"settings.general.petScale.description": "デフォルトのデスクトップペットの表示サイズを調整します。",
"settings.general.resetPosition": "ペットの位置をリセット",
"settings.general.petScale.title": "Familiarのサイズ",
"settings.general.petScale.description": "デフォルトのデスクトップFamiliarの表示サイズを調整します。",
"settings.general.resetPosition": "Familiarの位置をリセット",
"settings.general.systemStatus": "システムステータス",
"settings.general.updateAvailable": "アップデートあり",
"settings.general.checking": "確認中…",
"settings.general.checkForUpdates": "アップデートを確認",
"settings.toast.startupSaved": "起動時の設定を保存しました。",
"settings.toast.loginStartupSaved": "ログイン起動の設定を保存しました。",
"settings.toast.petScaleSaved": "ペットのサイズを保存しました。",
"settings.toast.positionReset": "デフォルトペットの位置をリセットしました。",
"settings.toast.petScaleSaved": "Familiarのサイズを保存しました。",
"settings.toast.positionReset": "デフォルトFamiliarの位置をリセットしました。",
"settings.busy.saving": "保存中",
"settings.busy.resetting": "リセット中",
"settings.busy.opening": "開いています",
@ -222,7 +222,7 @@ export const ja: Partial<Messages> = {
"settings.reactions.eyebrow": "動作",
"settings.reactions.title": "リアクション設定",
"settings.reactions.resetDefaults": "デフォルトに戻す",
"settings.reactions.description": "各エージェントのリアクションで再生するアニメーションをカスタマイズします。プレビューはデフォルトのペットを使用します。",
"settings.reactions.description": "各エージェントのリアクションで再生するアニメーションをカスタマイズします。プレビューはデフォルトのFamiliarを使用します。",
"settings.reactions.previewAria": "アニメーション: {state}",
"settings.toast.reactionsReset": "リアクションアニメーションをリセットしました。",
"settings.toast.reactionSaved": "リアクションアニメーションを保存しました。",
@ -255,7 +255,7 @@ export const ja: Partial<Messages> = {
"settings.reaction.waiting.label": "待機中",
"settings.reaction.waiting.description": "エージェントがブロック中または許可待ち。",
"settings.reaction.waving.label": "手を振る",
"settings.reaction.waving.description": "ペットがあいさつまたは注目を促しています。",
"settings.reaction.waving.description": "Familiarがあいさつまたは注目を促しています。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "タスクが正常に完了しました。",
"settings.reaction.error.label": "エラー",
@ -272,13 +272,13 @@ export const ja: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "プラグインによる音の再生を許可",
"settings.plugins.audio.description": "プラグインのチャイム、アラート、付属サウンドを許可します。",
"settings.plugins.voice.title": "プラグインによる音声発話を許可",
"settings.plugins.voice.description": "システム音声によるテキスト読み上げを許可します。",
"settings.plugins.dynamicSpeech.title": "AI 生成のペット発話を許可",
"settings.plugins.dynamicSpeech.title": "AI 生成のFamiliar発話を許可",
"settings.plugins.dynamicSpeech.description": "機微: 承認済みプラグインがモデル生成の吹き出しを表示できるようにします。",
"settings.plugins.microphone.title": "マイクを許可(プッシュトゥトーク)",
"settings.plugins.microphone.description": "機微: 承認済みプラグインが単発の音声入力を取得できるようにします。",
@ -401,22 +401,22 @@ export const ja: Partial<Messages> = {
"plugins.busy.uninstalling": "アンインストール中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "発話",
"plugins.permission.pet:reaction": "リアクション",
"plugins.permission.pet:move": "移動",
"plugins.permission.familiar:speak": "発話",
"plugins.permission.familiar:reaction": "リアクション",
"plugins.permission.familiar:move": "移動",
"plugins.permission.timer": "タイマー",
"plugins.permission.schedule": "スケジュール",
"plugins.permission.storage": "ストレージ",
"plugins.permission.status": "ステータス",
"plugins.permission.commands": "コマンド",
"plugins.permission.network": "ネットワーク",
"plugins.permission.pet:interact": "吹き出しボタン",
"plugins.permission.pet:pin": "固定吹き出し",
"plugins.permission.pet:animate": "カスタムアニメーション",
"plugins.permission.pet:speak:dynamic": "AI 発話",
"plugins.permission.pet:drop": "ドラッグ&ドロップ",
"plugins.permission.pets:read": "ペットの読み取り",
"plugins.permission.pets:manage": "ペットの管理",
"plugins.permission.familiar:interact": "吹き出しボタン",
"plugins.permission.familiar:pin": "固定吹き出し",
"plugins.permission.familiar:animate": "カスタムアニメーション",
"plugins.permission.familiar:speak:dynamic": "AI 発話",
"plugins.permission.familiar:drop": "ドラッグ&ドロップ",
"plugins.permission.familiars:read": "Familiarの読み取り",
"plugins.permission.familiars:manage": "Familiarの管理",
"plugins.permission.audio": "サウンド",
"plugins.permission.events": "イベント",
"plugins.permission.ui:toast": "トースト",
@ -440,14 +440,14 @@ export const ja: Partial<Messages> = {
"integrations.commandMode.local": "ローカル開発",
"integrations.loading": "連携を読み込み中…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Claude Code を OpenPets の相棒に接続します。",
"integrations.claude.description": "Claude Code を FamiliarOS の相棒に接続します。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "OpenCode をグローバルに OpenPets の相棒に接続します。",
"integrations.opencode.description": "OpenCode をグローバルに FamiliarOS の相棒に接続します。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "グローバル MCP 設定を通じて Cursor を OpenPets の相棒に接続します。",
"integrations.cursor.description": "グローバル MCP 設定を通じて Cursor を FamiliarOS の相棒に接続します。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手動",
"integrations.pi.description": "OpenPets Pi 拡張パッケージを通じて Pi コーディングエージェントの活動を接続します。",
"integrations.pi.description": "FamiliarOS Pi 拡張パッケージを通じて Pi コーディングエージェントの活動を接続します。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const ja: Partial<Messages> = {
"integrations.commandSource": "コマンドソース",
"integrations.cliMode": "CLI モード",
"integrations.localUnavailable": " は利用できません",
"integrations.commandModeHelp": "通常のセットアップには公開パッケージ、デスクトップアプリのビルドには同梱、OpenPets の開発時にはローカルを使用します。",
"integrations.commandModeHelp": "通常のセットアップには公開パッケージ、デスクトップアプリのビルドには同梱、FamiliarOS の開発時にはローカルを使用します。",
"integrations.connection": "接続",
"integrations.statusRouting": "ステータスとルーティング",
"integrations.globalSetup": "グローバルセットアップ",
"integrations.globalMcp": "グローバル MCP",
"integrations.petRouting": "ペットのルーティング",
"integrations.defaultPet": "デフォルトのペット",
"integrations.petRouting": "Familiarのルーティング",
"integrations.defaultPet": "デフォルトのFamiliar",
"integrations.configuration": "設定",
"integrations.commandPaths": "コマンドパス",
"integrations.claudeCommand": "Claude コマンド",
@ -497,12 +497,12 @@ export const ja: Partial<Messages> = {
"integrations.rulesPreview": "ルールプレビュー",
"integrations.pi.manualSetup": "手動セットアップ",
"integrations.pi.extension": "Pi 拡張機能",
"integrations.pi.intro": "Pi から OpenPets Pi 拡張機能をインストールし、Pi セッション内でスラッシュコマンドを使用します。",
"integrations.pi.intro": "Pi から FamiliarOS Pi 拡張機能をインストールし、Pi セッション内でスラッシュコマンドを使用します。",
"integrations.pi.globalInstall": "グローバルインストール",
"integrations.pi.projectInstall": "プロジェクトインストール",
"integrations.pi.remove": "削除",
"integrations.pi.slashCommands": "スラッシュコマンド",
"integrations.pi.outro": "すべての Pi ワークスペースに使うにはグローバルインストール、現在のプロジェクトだけで OpenPets を使うにはプロジェクトインストールを選びます。",
"integrations.pi.outro": "すべての Pi ワークスペースに使うにはグローバルインストール、現在のプロジェクトだけで FamiliarOS を使うにはプロジェクトインストールを選びます。",
"integrations.toast.pathSaved": "パスを保存しました。",
"integrations.busy.installing": "インストール中",
"integrations.busy.replacing": "置き換え中",
@ -517,21 +517,21 @@ export const ja: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "言語",
"settings.language.description": "OpenPets のメニューとウィンドウの表示言語。",
"settings.language.description": "FamiliarOS のメニューとウィンドウの表示言語。",
"settings.language.system": "システムのデフォルト",
};

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const ko: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "업데이트 사용 가능: {version}...",
"tray.defaultPet": "기본 : {name}",
"tray.toggleDefaultPet.show": "기본 표시",
"tray.toggleDefaultPet.hide": "기본 숨기기",
"tray.pauseAllPets": "모든 일시정지",
"tray.resumeAllPets": "모든 재개",
"tray.managePets": " 관리...",
"tray.defaultPet": "기본 Familiar: {name}",
"tray.toggleDefaultPet.show": "기본 Familiar 표시",
"tray.toggleDefaultPet.hide": "기본 Familiar 숨기기",
"tray.pauseAllPets": "모든 Familiar 일시정지",
"tray.resumeAllPets": "모든 Familiar 재개",
"tray.managePets": "Familiar 관리...",
"tray.controlCenter": "컨트롤 센터...",
"tray.website": "웹사이트...",
"tray.integrations": "연동...",
"tray.plugins": "플러그인...",
"tray.settings": "설정...",
"tray.openLogsFolder": "로그 폴더 열기...",
"tray.quit": "OpenPets 종료",
"tray.quit": "FamiliarOS 종료",
// --- Shared ---
"common.latest": "최신",
"common.builtInPet": "기본 내장 ",
"common.builtInPet": "기본 내장 Familiar",
"common.cancel": "취소",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "일시정지됨",
"pet.status.thinking": "생각 중",
"pet.status.working": "작업 중",
"pet.status.editing": "편집 중",
"pet.status.testing": "테스트 중",
"pet.status.waiting": "기다리는 중",
"pet.status.done": "완료",
"pet.status.oops": "오류",
"pet.status.hi": "안녕",
"pet.menu.hidePet": "펫 숨기기",
"pet.menu.closePet": "펫 닫기",
"pet.menu.openControlCenter": "컨트롤 센터 열기",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "일시정지됨",
"familiar.status.thinking": "생각 중",
"familiar.status.working": "작업 중",
"familiar.status.editing": "편집 중",
"familiar.status.testing": "테스트 중",
"familiar.status.waiting": "기다리는 중",
"familiar.status.done": "완료",
"familiar.status.oops": "오류",
"familiar.status.hi": "안녕",
"familiar.menu.hidePet": "Familiar 숨기기",
"familiar.menu.closePet": "Familiar 닫기",
"familiar.menu.openControlCenter": "컨트롤 센터 열기",
// --- Common (renderer, shared across views) ---
"common.retry": "다시 시도",
@ -45,7 +45,7 @@ export const ko: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "대시보드",
"nav.pets": "펫",
"nav.familiars": "Familiar",
"nav.settings": "설정",
"nav.plugins": "플러그인",
"nav.integrations": "연동",
@ -53,8 +53,8 @@ export const ko: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "대시보드",
"route.dashboard.description": "활성 동반자, 상태, 시스템 지표를 한눈에 확인하세요.",
"route.pets.title": "펫",
"route.pets.description": "기본 데스크톱 동반자를 설치, 가져오기, 미리보기하고 선택하세요.",
"route.familiars.title": "Familiar",
"route.familiars.description": "기본 데스크톱 동반자를 설치, 가져오기, 미리보기하고 선택하세요.",
"route.settings.title": "설정",
"route.settings.description": "시작 동작, 크기 설정, 애니메이션 설정을 구성하세요.",
"route.plugins.title": "플러그인",
@ -64,13 +64,13 @@ export const ko: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "컨트롤 센터",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "동반자 지표를 수집하는 중...",
"dashboard.hero.eyebrow": "주요 동반자",
"dashboard.hero.desc": "다음 코딩 세션을 위한 준비 완료.",
"dashboard.hero.changePet": " 변경",
"dashboard.hero.changePet": "Familiar 변경",
"dashboard.lastActive.none": "아직 활동 없음",
"dashboard.update.available": "업데이트 사용 가능",
"dashboard.update.error": "확인 실패",
@ -82,7 +82,7 @@ export const ko: Partial<Messages> = {
"dashboard.stat.reactions": "반응",
"dashboard.stat.reactions.footer": "실행된 애니메이션 총수",
"dashboard.stat.topCompanion": "최고 동반자",
"dashboard.stat.topCompanion.footer": "최근 가장 활발한 ",
"dashboard.stat.topCompanion.footer": "최근 가장 활발한 Familiar",
"dashboard.activity.title": "활동 개요",
"dashboard.activity.topReactions": "주요 반응",
"dashboard.activity.noReactions": "아직 기록된 반응이 없습니다. 코딩을 시작하세요!",
@ -93,17 +93,17 @@ export const ko: Partial<Messages> = {
"dashboard.reactionMix.reactions": "반응",
"dashboard.reactionMix.empty": "아직 반응 구성이 없습니다.",
"dashboard.companions.title": "최고 동반자",
"dashboard.companions.subtitle": "가장 활발한 ",
"dashboard.companions.subtitle": "가장 활발한 Familiar",
"dashboard.companions.empty": "아직 동반자 활동이 없습니다.",
"dashboard.lastActive.label": "마지막 활동: ",
"dashboard.system.title": "시스템 상태",
"dashboard.system.pets": "펫",
"dashboard.system.pets.value": "{count}개 설치됨",
"dashboard.system.familiars": "Familiar",
"dashboard.system.familiars.value": "{count}개 설치됨",
"dashboard.system.plugins": "플러그인",
"dashboard.system.plugins.enabled": "{count}개 활성화됨",
"dashboard.system.catalog": "카탈로그",
"dashboard.system.catalog.offline": "오프라인",
"dashboard.system.catalog.pets": "펫 {count}개",
"dashboard.system.catalog.familiars": "Familiar {count}개",
"dashboard.system.catalog.ready": "준비됨",
"dashboard.system.updates": "업데이트",
"dashboard.system.version": "버전",
@ -111,77 +111,77 @@ export const ko: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "출시 예정 • 다음 마이그레이션 대상",
// --- Pets filters (renderer) ---
"pets.filter.all": "전체",
"pets.filter.installed": "설치됨",
"pets.filter.featured": "추천",
"pets.filter.originals": "오리지널",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "펫 검색...",
"pets.import": "펫 가져오기",
"pets.gallery": "갤러리",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "전체",
"familiars.filter.installed": "설치됨",
"familiars.filter.featured": "추천",
"familiars.filter.originals": "오리지널",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "Familiar 검색...",
"familiars.import": "Familiar 가져오기",
"familiars.gallery": "갤러리",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "기본",
"pets.badge.original": "오리지널",
"pets.badge.featured": "추천",
"pets.badge.installed": "설치됨",
"pets.badge.codex": "Codex",
"pets.badge.broken": "손상됨",
"pets.badge.ready": "준비됨",
"pets.badge.originals": "오리지널",
"pets.action.viewPet": "펫 보기",
"pets.action.install": "설치",
"pets.action.import": "가져오기",
"pets.action.default": "기본",
"pets.action.remove": "제거",
"pets.action.refresh": "새로고침",
"pets.aria.view": "{name} 보기",
"pets.aria.install": "{name} 설치",
"pets.aria.import": "Codex에서 {name} 가져오기",
"pets.aria.setDefault": "{name}을(를) 기본으로 설정",
"pets.aria.remove": "{name} 제거",
"pets.busy.installing": "설치 중",
"pets.busy.importing": "가져오는 중",
"pets.busy.settingDefault": "기본 설정 중",
"pets.busy.removing": "제거 중",
"pets.busy.loadingPage": "페이지 불러오는 중",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "기본",
"familiars.badge.original": "오리지널",
"familiars.badge.featured": "추천",
"familiars.badge.installed": "설치됨",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "손상됨",
"familiars.badge.ready": "준비됨",
"familiars.badge.originals": "오리지널",
"familiars.action.viewPet": "Familiar 보기",
"familiars.action.install": "설치",
"familiars.action.import": "가져오기",
"familiars.action.default": "기본",
"familiars.action.remove": "제거",
"familiars.action.refresh": "새로고침",
"familiars.aria.view": "{name} 보기",
"familiars.aria.install": "{name} 설치",
"familiars.aria.import": "Codex에서 {name} 가져오기",
"familiars.aria.setDefault": "{name}을(를) 기본으로 설정",
"familiars.aria.remove": "{name} 제거",
"familiars.busy.installing": "설치 중",
"familiars.busy.importing": "가져오는 중",
"familiars.busy.settingDefault": "기본 설정 중",
"familiars.busy.removing": "제거 중",
"familiars.busy.loadingPage": "페이지 불러오는 중",
// --- Pets pager (renderer) ---
"pets.pager.prev": "이전",
"pets.pager.next": "다음",
"pets.pager.count": "펫 {count}개",
"pets.pager.page": " · {pageCount}페이지 중 {page}페이지",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "이전",
"familiars.pager.next": "다음",
"familiars.pager.count": "Familiar {count}개",
"familiars.pager.page": " · {pageCount}페이지 중 {page}페이지",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 펫 상세 정보",
"pets.detail.closeAria": "펫 상세 정보 닫기",
"pets.detail.eyebrow": "펫 상세 정보",
"pets.detail.previewAnimations": "애니메이션 미리보기",
"pets.detail.preview.idle": "대기",
"pets.detail.preview.thinking": "생각 중",
"pets.detail.preview.happy": "행복",
"pets.detail.preview.wave": "손 흔들기",
"pets.detail.installPet": "펫 설치",
"pets.detail.importCodexPet": "Codex 펫 가져오기",
"pets.detail.setDefaultPet": "기본 펫으로 설정",
"pets.detail.remove": "제거",
"pets.detail.refresh": "새로고침",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "{name} Familiar 상세 정보",
"familiars.detail.closeAria": "Familiar 상세 정보 닫기",
"familiars.detail.eyebrow": "Familiar 상세 정보",
"familiars.detail.previewAnimations": "애니메이션 미리보기",
"familiars.detail.preview.idle": "대기",
"familiars.detail.preview.thinking": "생각 중",
"familiars.detail.preview.happy": "행복",
"familiars.detail.preview.wave": "손 흔들기",
"familiars.detail.installPet": "Familiar 설치",
"familiars.detail.importCodexPet": "Codex Familiar 가져오기",
"familiars.detail.setDefaultPet": "기본 Familiar으로 설정",
"familiars.detail.remove": "제거",
"familiars.detail.refresh": "새로고침",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "이 설치된 펫은 손상되어 기본 펫으로 선택할 수 없습니다.",
"pets.status.defaultProtected": "기본 내장 펫입니다. 제거할 수 없도록 보호됩니다.",
"pets.status.default": "기본 펫입니다.",
"pets.status.installedCodex": "설치되어 기본 펫으로 설정할 준비가 되었습니다. ~/.codex/pets에서도 찾을 수 있습니다.",
"pets.status.installed": "설치되어 기본 펫으로 설정할 준비가 되었습니다.",
"pets.status.availableCodex": "~/.codex/pets에서 가져올 수 있습니다.",
"pets.status.availableCatalog": "카탈로그에서 설치할 수 있습니다.",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "이 설치된 Familiar은 손상되어 기본 Familiar으로 선택할 수 없습니다.",
"familiars.status.defaultProtected": "기본 내장 Familiar입니다. 제거할 수 없도록 보호됩니다.",
"familiars.status.default": "기본 Familiar입니다.",
"familiars.status.installedCodex": "설치되어 기본 Familiar으로 설정할 준비가 되었습니다. ~/.codex/pets에서도 찾을 수 있습니다.",
"familiars.status.installed": "설치되어 기본 Familiar으로 설정할 준비가 되었습니다.",
"familiars.status.availableCodex": "~/.codex/pets에서 가져올 수 있습니다.",
"familiars.status.availableCatalog": "카탈로그에서 설치할 수 있습니다.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 썸네일",
"pets.spriteLabel.thumb": "{name} 썸네일",
"pets.spriteLabel.animatedPreview": "{name} 애니메이션 미리보기",
"pets.spriteLabel.statePreview": "{name} {state} 미리보기",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "{name} 썸네일",
"familiars.spriteLabel.thumb": "{name} 썸네일",
"familiars.spriteLabel.animatedPreview": "{name} 애니메이션 미리보기",
"familiars.spriteLabel.statePreview": "{name} {state} 미리보기",
// --- Settings: general (renderer) ---
"settings.nav.general": "일반",
@ -189,22 +189,22 @@ export const ko: Partial<Messages> = {
"settings.nav.plugins": "플러그인 플랫폼",
"settings.general.eyebrow": "환경",
"settings.general.title": "일반 설정",
"settings.general.showOnLaunch.title": "실행 시 표시",
"settings.general.showOnLaunch.description": "OpenPets를 트레이에 유지하되 요청하기 전까지 펫을 숨깁니다.",
"settings.general.showOnLaunch.title": "실행 시 Familiar 표시",
"settings.general.showOnLaunch.description": "FamiliarOS를 트레이에 유지하되 요청하기 전까지 Familiar을 숨깁니다.",
"settings.general.launchAtLogin.title": "로그인 시 실행",
"settings.general.launchAtLogin.supported": "컴퓨터가 시작될 때 OpenPets를 자동으로 실행합니다.",
"settings.general.launchAtLogin.supported": "컴퓨터가 시작될 때 FamiliarOS를 자동으로 실행합니다.",
"settings.general.launchAtLogin.unsupported": "이 플랫폼에서는 지원되지 않습니다.",
"settings.general.petScale.title": " 크기",
"settings.general.petScale.description": "기본 데스크톱 이 표시되는 크기를 조정합니다.",
"settings.general.resetPosition": " 위치 초기화",
"settings.general.petScale.title": "Familiar 크기",
"settings.general.petScale.description": "기본 데스크톱 Familiar이 표시되는 크기를 조정합니다.",
"settings.general.resetPosition": "Familiar 위치 초기화",
"settings.general.systemStatus": "시스템 상태",
"settings.general.updateAvailable": "업데이트 사용 가능",
"settings.general.checking": "확인 중…",
"settings.general.checkForUpdates": "업데이트 확인",
"settings.toast.startupSaved": "시작 설정이 저장되었습니다.",
"settings.toast.loginStartupSaved": "로그인 시작 설정이 저장되었습니다.",
"settings.toast.petScaleSaved": " 크기가 저장되었습니다.",
"settings.toast.positionReset": "기본 위치가 초기화되었습니다.",
"settings.toast.petScaleSaved": "Familiar 크기가 저장되었습니다.",
"settings.toast.positionReset": "기본 Familiar 위치가 초기화되었습니다.",
"settings.busy.saving": "저장 중",
"settings.busy.resetting": "초기화 중",
"settings.busy.opening": "여는 중",
@ -222,7 +222,7 @@ export const ko: Partial<Messages> = {
"settings.reactions.eyebrow": "동작",
"settings.reactions.title": "반응 매핑",
"settings.reactions.resetDefaults": "기본값으로 초기화",
"settings.reactions.description": "각 에이전트 반응에 재생할 애니메이션을 맞춤 설정하세요. 미리보기는 기본 을 사용합니다.",
"settings.reactions.description": "각 에이전트 반응에 재생할 애니메이션을 맞춤 설정하세요. 미리보기는 기본 Familiar을 사용합니다.",
"settings.reactions.previewAria": "애니메이션: {state}",
"settings.toast.reactionsReset": "반응 애니메이션이 초기화되었습니다.",
"settings.toast.reactionSaved": "반응 애니메이션이 저장되었습니다.",
@ -255,7 +255,7 @@ export const ko: Partial<Messages> = {
"settings.reaction.waiting.label": "기다리는 중",
"settings.reaction.waiting.description": "에이전트가 차단되었거나 권한을 기다리는 중입니다.",
"settings.reaction.waving.label": "손 흔들기",
"settings.reaction.waving.description": "이 인사하거나 주의를 끕니다.",
"settings.reaction.waving.description": "Familiar이 인사하거나 주의를 끕니다.",
"settings.reaction.success.label": "성공",
"settings.reaction.success.description": "작업이 성공적으로 완료되었습니다.",
"settings.reaction.error.label": "오류",
@ -272,13 +272,13 @@ export const ko: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "플러그인 소리 재생 허용",
"settings.plugins.audio.description": "플러그인 알림음, 경고음, 번들 사운드를 허용합니다.",
"settings.plugins.voice.title": "플러그인 음성 말하기 허용",
"settings.plugins.voice.description": "시스템 음성을 통한 텍스트 음성 변환을 허용합니다.",
"settings.plugins.dynamicSpeech.title": "AI 생성 말하기 허용",
"settings.plugins.dynamicSpeech.title": "AI 생성 Familiar 말하기 허용",
"settings.plugins.dynamicSpeech.description": "민감: 승인된 플러그인이 모델 생성 말풍선을 표시할 수 있도록 합니다.",
"settings.plugins.microphone.title": "마이크 허용 (푸시 투 토크)",
"settings.plugins.microphone.description": "민감: 승인된 플러그인이 일회성 음성 입력을 캡처할 수 있도록 합니다.",
@ -401,22 +401,22 @@ export const ko: Partial<Messages> = {
"plugins.busy.uninstalling": "제거 중",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "말하기",
"plugins.permission.pet:reaction": "반응",
"plugins.permission.pet:move": "이동",
"plugins.permission.familiar:speak": "말하기",
"plugins.permission.familiar:reaction": "반응",
"plugins.permission.familiar:move": "이동",
"plugins.permission.timer": "타이머",
"plugins.permission.schedule": "일정",
"plugins.permission.storage": "저장소",
"plugins.permission.status": "상태",
"plugins.permission.commands": "명령",
"plugins.permission.network": "네트워크",
"plugins.permission.pet:interact": "말풍선 버튼",
"plugins.permission.pet:pin": "고정 말풍선",
"plugins.permission.pet:animate": "맞춤 애니메이션",
"plugins.permission.pet:speak:dynamic": "AI 말하기",
"plugins.permission.pet:drop": "드래그 앤 드롭",
"plugins.permission.pets:read": "펫 읽기",
"plugins.permission.pets:manage": "펫 관리",
"plugins.permission.familiar:interact": "말풍선 버튼",
"plugins.permission.familiar:pin": "고정 말풍선",
"plugins.permission.familiar:animate": "맞춤 애니메이션",
"plugins.permission.familiar:speak:dynamic": "AI 말하기",
"plugins.permission.familiar:drop": "드래그 앤 드롭",
"plugins.permission.familiars:read": "Familiar 읽기",
"plugins.permission.familiars:manage": "Familiar 관리",
"plugins.permission.audio": "소리",
"plugins.permission.events": "이벤트",
"plugins.permission.ui:toast": "토스트",
@ -440,14 +440,14 @@ export const ko: Partial<Messages> = {
"integrations.commandMode.local": "로컬 개발",
"integrations.loading": "연동을 불러오는 중…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Claude Code를 OpenPets 동반자에 연결하세요.",
"integrations.claude.description": "Claude Code를 FamiliarOS 동반자에 연결하세요.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "OpenCode를 OpenPets 동반자에 전역으로 연결하세요.",
"integrations.opencode.description": "OpenCode를 FamiliarOS 동반자에 전역으로 연결하세요.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "전역 MCP 구성을 통해 Cursor를 OpenPets 동반자에 연결하세요.",
"integrations.cursor.description": "전역 MCP 구성을 통해 Cursor를 FamiliarOS 동반자에 연결하세요.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "수동",
"integrations.pi.description": "OpenPets Pi 확장 패키지를 통해 Pi 코딩 에이전트 활동을 연결하세요.",
"integrations.pi.description": "FamiliarOS Pi 확장 패키지를 통해 Pi 코딩 에이전트 활동을 연결하세요.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const ko: Partial<Messages> = {
"integrations.commandSource": "명령 소스",
"integrations.cliMode": "CLI 모드",
"integrations.localUnavailable": " 사용 불가",
"integrations.commandModeHelp": "일반 설정에는 게시된 패키지를, 데스크톱 앱 빌드에는 번들을, OpenPets 개발 중에는 로컬을 사용하세요.",
"integrations.commandModeHelp": "일반 설정에는 게시된 패키지를, 데스크톱 앱 빌드에는 번들을, FamiliarOS 개발 중에는 로컬을 사용하세요.",
"integrations.connection": "연결",
"integrations.statusRouting": "상태 및 라우팅",
"integrations.globalSetup": "전역 설정",
"integrations.globalMcp": "전역 MCP",
"integrations.petRouting": " 라우팅",
"integrations.defaultPet": "기본 ",
"integrations.petRouting": "Familiar 라우팅",
"integrations.defaultPet": "기본 Familiar",
"integrations.configuration": "구성",
"integrations.commandPaths": "명령 경로",
"integrations.claudeCommand": "Claude 명령",
@ -497,12 +497,12 @@ export const ko: Partial<Messages> = {
"integrations.rulesPreview": "규칙 미리보기",
"integrations.pi.manualSetup": "수동 설정",
"integrations.pi.extension": "Pi 확장",
"integrations.pi.intro": "Pi에서 OpenPets Pi 확장을 설치한 다음 Pi 세션 안에서 슬래시 명령을 사용하세요.",
"integrations.pi.intro": "Pi에서 FamiliarOS Pi 확장을 설치한 다음 Pi 세션 안에서 슬래시 명령을 사용하세요.",
"integrations.pi.globalInstall": "전역 설치",
"integrations.pi.projectInstall": "프로젝트 설치",
"integrations.pi.remove": "제거",
"integrations.pi.slashCommands": "슬래시 명령",
"integrations.pi.outro": "모든 Pi 워크스페이스에는 전역 설치를, 현재 프로젝트에서만 OpenPets를 사용하려면 프로젝트 설치를 사용하세요.",
"integrations.pi.outro": "모든 Pi 워크스페이스에는 전역 설치를, 현재 프로젝트에서만 FamiliarOS를 사용하려면 프로젝트 설치를 사용하세요.",
"integrations.toast.pathSaved": "경로가 저장되었습니다.",
"integrations.busy.installing": "설치 중",
"integrations.busy.replacing": "교체 중",
@ -517,21 +517,21 @@ export const ko: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "언어",
"settings.language.description": "OpenPets 메뉴와 창의 표시 언어입니다.",
"settings.language.description": "FamiliarOS 메뉴와 창의 표시 언어입니다.",
"settings.language.system": "시스템 기본값",
};

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const ptBR: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Atualização disponível: {version}...",
"tray.defaultPet": "Pet padrão: {name}",
"tray.toggleDefaultPet.show": "Mostrar pet padrão",
"tray.toggleDefaultPet.hide": "Ocultar pet padrão",
"tray.pauseAllPets": "Pausar todos os pets",
"tray.resumeAllPets": "Retomar todos os pets",
"tray.managePets": "Gerenciar pets...",
"tray.defaultPet": "Familiar padrão: {name}",
"tray.toggleDefaultPet.show": "Mostrar familiar padrão",
"tray.toggleDefaultPet.hide": "Ocultar familiar padrão",
"tray.pauseAllPets": "Pausar todos os Familiares",
"tray.resumeAllPets": "Retomar todos os Familiares",
"tray.managePets": "Gerenciar Familiares...",
"tray.controlCenter": "Central de controle...",
"tray.website": "Site...",
"tray.integrations": "Integrações...",
"tray.plugins": "Plugins...",
"tray.settings": "Configurações...",
"tray.openLogsFolder": "Abrir pasta de logs...",
"tray.quit": "Sair do OpenPets",
"tray.quit": "Sair do FamiliarOS",
// --- Shared ---
"common.latest": "mais recente",
"common.builtInPet": "Pet integrado",
"common.builtInPet": "Familiar integrado",
"common.cancel": "Cancelar",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "Pausado",
"pet.status.thinking": "Pensando",
"pet.status.working": "Trabalhando",
"pet.status.editing": "Editando",
"pet.status.testing": "Testando",
"pet.status.waiting": "Aguardando",
"pet.status.done": "Concluído",
"pet.status.oops": "Ops",
"pet.status.hi": "Oi",
"pet.menu.hidePet": "Ocultar pet",
"pet.menu.closePet": "Fechar pet",
"pet.menu.openControlCenter": "Abrir Central de controle",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "Pausado",
"familiar.status.thinking": "Pensando",
"familiar.status.working": "Trabalhando",
"familiar.status.editing": "Editando",
"familiar.status.testing": "Testando",
"familiar.status.waiting": "Aguardando",
"familiar.status.done": "Concluído",
"familiar.status.oops": "Ops",
"familiar.status.hi": "Oi",
"familiar.menu.hidePet": "Ocultar familiar",
"familiar.menu.closePet": "Fechar familiar",
"familiar.menu.openControlCenter": "Abrir Central de controle",
// --- Common (renderer, shared across views) ---
"common.retry": "Tentar novamente",
@ -45,7 +45,7 @@ export const ptBR: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Painel",
"nav.pets": "Pets",
"nav.familiars": "Familiars",
"nav.settings": "Configurações",
"nav.plugins": "Plugins",
"nav.integrations": "Integrações",
@ -53,8 +53,8 @@ export const ptBR: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Painel",
"route.dashboard.description": "Visão geral dos seus companheiros ativos, status e métricas do sistema.",
"route.pets.title": "Pets",
"route.pets.description": "Instale, importe, visualize e escolha o seu companheiro de desktop padrão.",
"route.familiars.title": "Familiars",
"route.familiars.description": "Instale, importe, visualize e escolha o seu companheiro de desktop padrão.",
"route.settings.title": "Configurações",
"route.settings.description": "Configure comportamentos de inicialização, preferências de escala e ajustes de animação.",
"route.plugins.title": "Plugins",
@ -64,13 +64,13 @@ export const ptBR: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "Central de controle",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "Coletando métricas dos companheiros...",
"dashboard.hero.eyebrow": "Companheiro principal",
"dashboard.hero.desc": "Pronto para a sua próxima sessão de programação.",
"dashboard.hero.changePet": "Trocar pet",
"dashboard.hero.changePet": "Trocar familiar",
"dashboard.lastActive.none": "Nenhuma atividade ainda",
"dashboard.update.available": "Atualização disponível",
"dashboard.update.error": "Falha na verificação",
@ -82,7 +82,7 @@ export const ptBR: Partial<Messages> = {
"dashboard.stat.reactions": "Reações",
"dashboard.stat.reactions.footer": "Total de animações acionadas",
"dashboard.stat.topCompanion": "Companheiro favorito",
"dashboard.stat.topCompanion.footer": "Pet mais ativo recentemente",
"dashboard.stat.topCompanion.footer": "Familiar mais ativo recentemente",
"dashboard.activity.title": "Visão geral da atividade",
"dashboard.activity.topReactions": "Principais reações",
"dashboard.activity.noReactions": "Nenhuma reação registrada ainda. Comece a programar!",
@ -93,17 +93,17 @@ export const ptBR: Partial<Messages> = {
"dashboard.reactionMix.reactions": "reações",
"dashboard.reactionMix.empty": "Nenhum mix de reações ainda.",
"dashboard.companions.title": "Principais companheiros",
"dashboard.companions.subtitle": "Pets mais ativos",
"dashboard.companions.subtitle": "Familiars mais ativos",
"dashboard.companions.empty": "Nenhuma atividade de companheiro ainda.",
"dashboard.lastActive.label": "Última atividade: ",
"dashboard.system.title": "Saúde do sistema",
"dashboard.system.pets": "Pets",
"dashboard.system.pets.value": "{count} instalados",
"dashboard.system.familiars": "Familiars",
"dashboard.system.familiars.value": "{count} instalados",
"dashboard.system.plugins": "Plugins",
"dashboard.system.plugins.enabled": "{count} ativados",
"dashboard.system.catalog": "Catálogo",
"dashboard.system.catalog.offline": "Offline",
"dashboard.system.catalog.pets": "{count} pets",
"dashboard.system.catalog.familiars": "{count} Familiares",
"dashboard.system.catalog.ready": "Pronto",
"dashboard.system.updates": "Atualizações",
"dashboard.system.version": "Versão",
@ -111,77 +111,77 @@ export const ptBR: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Em breve • Próximo alvo de migração",
// --- Pets filters (renderer) ---
"pets.filter.all": "Todos",
"pets.filter.installed": "Instalados",
"pets.filter.featured": "Destaques",
"pets.filter.originals": "Originais",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Buscar pets...",
"pets.import": "Importar pet",
"pets.gallery": "Galeria",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "Todos",
"familiars.filter.installed": "Instalados",
"familiars.filter.featured": "Destaques",
"familiars.filter.originals": "Originais",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "Buscar Familiares...",
"familiars.import": "Importar familiar",
"familiars.gallery": "Galeria",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Padrão",
"pets.badge.original": "Original",
"pets.badge.featured": "Destaque",
"pets.badge.installed": "Instalado",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Com defeito",
"pets.badge.ready": "Pronto",
"pets.badge.originals": "Originais",
"pets.action.viewPet": "Ver pet",
"pets.action.install": "Instalar",
"pets.action.import": "Importar",
"pets.action.default": "Padrão",
"pets.action.remove": "Remover",
"pets.action.refresh": "Atualizar",
"pets.aria.view": "Ver {name}",
"pets.aria.install": "Instalar {name}",
"pets.aria.import": "Importar {name} do Codex",
"pets.aria.setDefault": "Definir {name} como padrão",
"pets.aria.remove": "Remover {name}",
"pets.busy.installing": "Instalando",
"pets.busy.importing": "Importando",
"pets.busy.settingDefault": "Definindo padrão",
"pets.busy.removing": "Removendo",
"pets.busy.loadingPage": "Carregando página",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "Padrão",
"familiars.badge.original": "Original",
"familiars.badge.featured": "Destaque",
"familiars.badge.installed": "Instalado",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "Com defeito",
"familiars.badge.ready": "Pronto",
"familiars.badge.originals": "Originais",
"familiars.action.viewPet": "Ver familiar",
"familiars.action.install": "Instalar",
"familiars.action.import": "Importar",
"familiars.action.default": "Padrão",
"familiars.action.remove": "Remover",
"familiars.action.refresh": "Atualizar",
"familiars.aria.view": "Ver {name}",
"familiars.aria.install": "Instalar {name}",
"familiars.aria.import": "Importar {name} do Codex",
"familiars.aria.setDefault": "Definir {name} como padrão",
"familiars.aria.remove": "Remover {name}",
"familiars.busy.installing": "Instalando",
"familiars.busy.importing": "Importando",
"familiars.busy.settingDefault": "Definindo padrão",
"familiars.busy.removing": "Removendo",
"familiars.busy.loadingPage": "Carregando página",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Anterior",
"pets.pager.next": "Próxima",
"pets.pager.count": "{count} pets",
"pets.pager.page": " · Página {page} de {pageCount}",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "Anterior",
"familiars.pager.next": "Próxima",
"familiars.pager.count": "{count} Familiares",
"familiars.pager.page": " · Página {page} de {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "Detalhes do pet {name}",
"pets.detail.closeAria": "Fechar detalhes do pet",
"pets.detail.eyebrow": "Detalhe do pet",
"pets.detail.previewAnimations": "Pré-visualizar animações",
"pets.detail.preview.idle": "Ocioso",
"pets.detail.preview.thinking": "Pensando",
"pets.detail.preview.happy": "Feliz",
"pets.detail.preview.wave": "Aceno",
"pets.detail.installPet": "Instalar pet",
"pets.detail.importCodexPet": "Importar pet do Codex",
"pets.detail.setDefaultPet": "Definir pet padrão",
"pets.detail.remove": "Remover",
"pets.detail.refresh": "Atualizar",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "Detalhes do familiar {name}",
"familiars.detail.closeAria": "Fechar detalhes do familiar",
"familiars.detail.eyebrow": "Detalhe do familiar",
"familiars.detail.previewAnimations": "Pré-visualizar animações",
"familiars.detail.preview.idle": "Ocioso",
"familiars.detail.preview.thinking": "Pensando",
"familiars.detail.preview.happy": "Feliz",
"familiars.detail.preview.wave": "Aceno",
"familiars.detail.installPet": "Instalar familiar",
"familiars.detail.importCodexPet": "Importar familiar do Codex",
"familiars.detail.setDefaultPet": "Definir familiar padrão",
"familiars.detail.remove": "Remover",
"familiars.detail.refresh": "Atualizar",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "Este pet instalado está com defeito e não pode ser definido como padrão.",
"pets.status.defaultProtected": "Pet integrado padrão. Protegido contra remoção.",
"pets.status.default": "Pet padrão.",
"pets.status.installedCodex": "Instalado e pronto para se tornar o seu pet padrão. Também encontrado em ~/.codex/pets.",
"pets.status.installed": "Instalado e pronto para se tornar o seu pet padrão.",
"pets.status.availableCodex": "Disponível para importar de ~/.codex/pets.",
"pets.status.availableCatalog": "Disponível para instalar a partir do catálogo.",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "Este familiar instalado está com defeito e não pode ser definido como padrão.",
"familiars.status.defaultProtected": "Familiar integrado padrão. Protegido contra remoção.",
"familiars.status.default": "Familiar padrão.",
"familiars.status.installedCodex": "Instalado e pronto para se tornar o seu familiar padrão. Também encontrado em ~/.codex/Familiares.",
"familiars.status.installed": "Instalado e pronto para se tornar o seu familiar padrão.",
"familiars.status.availableCodex": "Disponível para importar de ~/.codex/Familiares.",
"familiars.status.availableCatalog": "Disponível para instalar a partir do catálogo.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "Miniatura de {name}",
"pets.spriteLabel.thumb": "Miniatura de {name}",
"pets.spriteLabel.animatedPreview": "Pré-visualização animada de {name}",
"pets.spriteLabel.statePreview": "Pré-visualização de {name} {state}",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "Miniatura de {name}",
"familiars.spriteLabel.thumb": "Miniatura de {name}",
"familiars.spriteLabel.animatedPreview": "Pré-visualização animada de {name}",
"familiars.spriteLabel.statePreview": "Pré-visualização de {name} {state}",
// --- Settings: general (renderer) ---
"settings.nav.general": "Geral",
@ -189,22 +189,22 @@ export const ptBR: Partial<Messages> = {
"settings.nav.plugins": "Plataforma de plugins",
"settings.general.eyebrow": "Ambiente",
"settings.general.title": "Configurações gerais",
"settings.general.showOnLaunch.title": "Mostrar pet ao iniciar",
"settings.general.showOnLaunch.description": "Mantenha o OpenPets na bandeja, mas oculte o pet até que seja solicitado.",
"settings.general.showOnLaunch.title": "Mostrar familiar ao iniciar",
"settings.general.showOnLaunch.description": "Mantenha o FamiliarOS na bandeja, mas oculte o familiar até que seja solicitado.",
"settings.general.launchAtLogin.title": "Iniciar ao fazer login",
"settings.general.launchAtLogin.supported": "Inicie o OpenPets automaticamente quando o seu computador ligar.",
"settings.general.launchAtLogin.supported": "Inicie o FamiliarOS automaticamente quando o seu computador ligar.",
"settings.general.launchAtLogin.unsupported": "Não compatível com esta plataforma.",
"settings.general.petScale.title": "Escala do pet",
"settings.general.petScale.description": "Ajuste o tamanho com que o pet padrão aparece no desktop.",
"settings.general.resetPosition": "Redefinir posição do pet",
"settings.general.petScale.title": "Escala do familiar",
"settings.general.petScale.description": "Ajuste o tamanho com que o familiar padrão aparece no desktop.",
"settings.general.resetPosition": "Redefinir posição do familiar",
"settings.general.systemStatus": "Status do sistema",
"settings.general.updateAvailable": "Atualização disponível",
"settings.general.checking": "Verificando…",
"settings.general.checkForUpdates": "Verificar atualizações",
"settings.toast.startupSaved": "Preferência de inicialização salva.",
"settings.toast.loginStartupSaved": "Preferência de inicialização no login salva.",
"settings.toast.petScaleSaved": "Escala do pet salva.",
"settings.toast.positionReset": "Posição do pet padrão redefinida.",
"settings.toast.petScaleSaved": "Escala do familiar salva.",
"settings.toast.positionReset": "Posição do familiar padrão redefinida.",
"settings.busy.saving": "Salvando",
"settings.busy.resetting": "Redefinindo",
"settings.busy.opening": "Abrindo",
@ -222,7 +222,7 @@ export const ptBR: Partial<Messages> = {
"settings.reactions.eyebrow": "Comportamento",
"settings.reactions.title": "Mapeamento de reações",
"settings.reactions.resetDefaults": "Restaurar padrões",
"settings.reactions.description": "Personalize qual animação é reproduzida para cada reação do agente. As pré-visualizações usam o pet padrão.",
"settings.reactions.description": "Personalize qual animação é reproduzida para cada reação do agente. As pré-visualizações usam o familiar padrão.",
"settings.reactions.previewAria": "Animação: {state}",
"settings.toast.reactionsReset": "Animações de reação redefinidas.",
"settings.toast.reactionSaved": "Animação de reação salva.",
@ -255,7 +255,7 @@ export const ptBR: Partial<Messages> = {
"settings.reaction.waiting.label": "Aguardando",
"settings.reaction.waiting.description": "O agente está bloqueado ou aguardando permissão.",
"settings.reaction.waving.label": "Acenando",
"settings.reaction.waving.description": "O pet está cumprimentando ou chamando atenção.",
"settings.reaction.waving.description": "O familiar está cumprimentando ou chamando atenção.",
"settings.reaction.success.label": "Sucesso",
"settings.reaction.success.description": "A tarefa foi concluída com sucesso.",
"settings.reaction.error.label": "Erro",
@ -272,13 +272,13 @@ export const ptBR: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "Plugins podem reproduzir som",
"settings.plugins.audio.description": "Permitir sinos, alertas e sons incluídos dos plugins.",
"settings.plugins.voice.title": "Plugins podem falar (voz)",
"settings.plugins.voice.description": "Permitir conversão de texto em fala pela voz do sistema.",
"settings.plugins.dynamicSpeech.title": "Permitir fala do pet gerada por IA",
"settings.plugins.dynamicSpeech.title": "Permitir fala do familiar gerada por IA",
"settings.plugins.dynamicSpeech.description": "Sensível: permite que plugins aprovados exibam balões gerados por modelo.",
"settings.plugins.microphone.title": "Permitir microfone (apertar para falar)",
"settings.plugins.microphone.description": "Sensível: permite que plugins aprovados capturem entrada de voz pontual.",
@ -401,22 +401,22 @@ export const ptBR: Partial<Messages> = {
"plugins.busy.uninstalling": "Desinstalando",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Fala",
"plugins.permission.pet:reaction": "Reações",
"plugins.permission.pet:move": "Movimento",
"plugins.permission.familiar:speak": "Fala",
"plugins.permission.familiar:reaction": "Reações",
"plugins.permission.familiar:move": "Movimento",
"plugins.permission.timer": "Temporizadores",
"plugins.permission.schedule": "Agenda",
"plugins.permission.storage": "Armazenamento",
"plugins.permission.status": "Status",
"plugins.permission.commands": "Comandos",
"plugins.permission.network": "Rede",
"plugins.permission.pet:interact": "Botões de balão",
"plugins.permission.pet:pin": "Balão fixado",
"plugins.permission.pet:animate": "Animação personalizada",
"plugins.permission.pet:speak:dynamic": "Fala por IA",
"plugins.permission.pet:drop": "Arrastar e soltar",
"plugins.permission.pets:read": "Ler pets",
"plugins.permission.pets:manage": "Gerenciar pets",
"plugins.permission.familiar:interact": "Botões de balão",
"plugins.permission.familiar:pin": "Balão fixado",
"plugins.permission.familiar:animate": "Animação personalizada",
"plugins.permission.familiar:speak:dynamic": "Fala por IA",
"plugins.permission.familiar:drop": "Arrastar e soltar",
"plugins.permission.familiars:read": "Ler Familiares",
"plugins.permission.familiars:manage": "Gerenciar Familiares",
"plugins.permission.audio": "Som",
"plugins.permission.events": "Eventos",
"plugins.permission.ui:toast": "Avisos",
@ -440,14 +440,14 @@ export const ptBR: Partial<Messages> = {
"integrations.commandMode.local": "Desenvolvimento local",
"integrations.loading": "Carregando integrações…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Conecte o Claude Code ao seu companheiro OpenPets.",
"integrations.claude.description": "Conecte o Claude Code ao seu companheiro FamiliarOS.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Conecte o OpenCode globalmente ao seu companheiro OpenPets.",
"integrations.opencode.description": "Conecte o OpenCode globalmente ao seu companheiro FamiliarOS.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Conecte o Cursor ao seu companheiro OpenPets via configuração global de MCP.",
"integrations.cursor.description": "Conecte o Cursor ao seu companheiro FamiliarOS via configuração global de MCP.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Conecte a atividade do agente de programação Pi através do pacote de extensão Pi do OpenPets.",
"integrations.pi.description": "Conecte a atividade do agente de programação Pi através do pacote de extensão Pi do FamiliarOS.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const ptBR: Partial<Messages> = {
"integrations.commandSource": "Origem do comando",
"integrations.cliMode": "Modo CLI",
"integrations.localUnavailable": " indisponível",
"integrations.commandModeHelp": "Use o pacote publicado para configuração normal, o incluído para o build do app de desktop, ou o local enquanto desenvolve o OpenPets.",
"integrations.commandModeHelp": "Use o pacote publicado para configuração normal, o incluído para o build do app de desktop, ou o local enquanto desenvolve o FamiliarOS.",
"integrations.connection": "Conexão",
"integrations.statusRouting": "Status e roteamento",
"integrations.globalSetup": "Configuração global",
"integrations.globalMcp": "MCP global",
"integrations.petRouting": "Roteamento de pet",
"integrations.defaultPet": "Pet padrão",
"integrations.petRouting": "Roteamento de familiar",
"integrations.defaultPet": "Familiar padrão",
"integrations.configuration": "Configuração",
"integrations.commandPaths": "Caminhos de comando",
"integrations.claudeCommand": "Comando do Claude",
@ -497,12 +497,12 @@ export const ptBR: Partial<Messages> = {
"integrations.rulesPreview": "Pré-visualização das regras",
"integrations.pi.manualSetup": "Configuração manual",
"integrations.pi.extension": "Extensão Pi",
"integrations.pi.intro": "Instale a extensão Pi do OpenPets a partir do Pi e use os comandos de barra dentro de uma sessão do Pi.",
"integrations.pi.intro": "Instale a extensão Pi do FamiliarOS a partir do Pi e use os comandos de barra dentro de uma sessão do Pi.",
"integrations.pi.globalInstall": "Instalação global",
"integrations.pi.projectInstall": "Instalação no projeto",
"integrations.pi.remove": "Remover",
"integrations.pi.slashCommands": "Comandos de barra",
"integrations.pi.outro": "Use a instalação global para todos os workspaces do Pi, ou a instalação no projeto quando quiser o OpenPets apenas no projeto atual.",
"integrations.pi.outro": "Use a instalação global para todos os workspaces do Pi, ou a instalação no projeto quando quiser o FamiliarOS apenas no projeto atual.",
"integrations.toast.pathSaved": "Caminho salvo.",
"integrations.busy.installing": "Instalando",
"integrations.busy.replacing": "Substituindo",
@ -517,21 +517,21 @@ export const ptBR: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Idioma",
"settings.language.description": "Idioma de exibição dos menus e janelas do OpenPets.",
"settings.language.description": "Idioma de exibição dos menus e janelas do FamiliarOS.",
"settings.language.system": "Padrão do sistema",
};

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const zhHans: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "有可用更新:{version}...",
"tray.defaultPet": "默认宠物{name}",
"tray.toggleDefaultPet.show": "显示默认宠物",
"tray.toggleDefaultPet.hide": "隐藏默认宠物",
"tray.pauseAllPets": "暂停所有宠物",
"tray.resumeAllPets": "恢复所有宠物",
"tray.managePets": "管理宠物...",
"tray.defaultPet": "默认Familiar{name}",
"tray.toggleDefaultPet.show": "显示默认Familiar",
"tray.toggleDefaultPet.hide": "隐藏默认Familiar",
"tray.pauseAllPets": "暂停所有Familiar",
"tray.resumeAllPets": "恢复所有Familiar",
"tray.managePets": "管理Familiar...",
"tray.controlCenter": "控制中心...",
"tray.website": "网站...",
"tray.integrations": "集成...",
"tray.plugins": "插件...",
"tray.settings": "设置...",
"tray.openLogsFolder": "打开日志文件夹...",
"tray.quit": "退出 OpenPets",
"tray.quit": "退出 FamiliarOS",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "内置宠物",
"common.builtInPet": "内置Familiar",
"common.cancel": "取消",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "已暂停",
"pet.status.thinking": "思考中",
"pet.status.working": "工作中",
"pet.status.editing": "编辑中",
"pet.status.testing": "测试中",
"pet.status.waiting": "等待中",
"pet.status.done": "完成",
"pet.status.oops": "出错了",
"pet.status.hi": "你好",
"pet.menu.hidePet": "隐藏宠物",
"pet.menu.closePet": "关闭宠物",
"pet.menu.openControlCenter": "打开控制中心",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "已暂停",
"familiar.status.thinking": "思考中",
"familiar.status.working": "工作中",
"familiar.status.editing": "编辑中",
"familiar.status.testing": "测试中",
"familiar.status.waiting": "等待中",
"familiar.status.done": "完成",
"familiar.status.oops": "出错了",
"familiar.status.hi": "你好",
"familiar.menu.hidePet": "隐藏Familiar",
"familiar.menu.closePet": "关闭Familiar",
"familiar.menu.openControlCenter": "打开控制中心",
// --- Common (renderer, shared across views) ---
"common.retry": "重试",
@ -45,7 +45,7 @@ export const zhHans: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "仪表盘",
"nav.pets": "宠物",
"nav.familiars": "Familiar",
"nav.settings": "设置",
"nav.plugins": "插件",
"nav.integrations": "集成",
@ -53,8 +53,8 @@ export const zhHans: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "仪表盘",
"route.dashboard.description": "查看活跃伙伴、状态及系统指标的概览。",
"route.pets.title": "宠物",
"route.pets.description": "安装、导入、预览并选择你的默认桌面伙伴。",
"route.familiars.title": "Familiar",
"route.familiars.description": "安装、导入、预览并选择你的默认桌面伙伴。",
"route.settings.title": "设置",
"route.settings.description": "配置启动行为、缩放偏好和动画设置。",
"route.plugins.title": "插件",
@ -64,13 +64,13 @@ export const zhHans: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "控制中心",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "正在收集伙伴指标...",
"dashboard.hero.eyebrow": "主要伙伴",
"dashboard.hero.desc": "已准备好开始你的下一次编程。",
"dashboard.hero.changePet": "更换宠物",
"dashboard.hero.changePet": "更换Familiar",
"dashboard.lastActive.none": "暂无活动",
"dashboard.update.available": "有可用更新",
"dashboard.update.error": "检查失败",
@ -82,7 +82,7 @@ export const zhHans: Partial<Messages> = {
"dashboard.stat.reactions": "反应",
"dashboard.stat.reactions.footer": "触发的动画总数",
"dashboard.stat.topCompanion": "最活跃伙伴",
"dashboard.stat.topCompanion.footer": "近期最活跃的宠物",
"dashboard.stat.topCompanion.footer": "近期最活跃的Familiar",
"dashboard.activity.title": "活动概览",
"dashboard.activity.topReactions": "热门反应",
"dashboard.activity.noReactions": "暂无反应记录。开始编程吧!",
@ -93,17 +93,17 @@ export const zhHans: Partial<Messages> = {
"dashboard.reactionMix.reactions": "次反应",
"dashboard.reactionMix.empty": "暂无反应分布。",
"dashboard.companions.title": "最活跃伙伴",
"dashboard.companions.subtitle": "最活跃的宠物",
"dashboard.companions.subtitle": "最活跃的Familiar",
"dashboard.companions.empty": "暂无伙伴活动。",
"dashboard.lastActive.label": "最近活动:",
"dashboard.system.title": "系统健康",
"dashboard.system.pets": "宠物",
"dashboard.system.pets.value": "已安装 {count} 个",
"dashboard.system.familiars": "Familiar",
"dashboard.system.familiars.value": "已安装 {count} 个",
"dashboard.system.plugins": "插件",
"dashboard.system.plugins.enabled": "已启用 {count} 个",
"dashboard.system.catalog": "目录",
"dashboard.system.catalog.offline": "离线",
"dashboard.system.catalog.pets": "{count} 个宠物",
"dashboard.system.catalog.familiars": "{count} 个Familiar",
"dashboard.system.catalog.ready": "就绪",
"dashboard.system.updates": "更新",
"dashboard.system.version": "版本",
@ -111,77 +111,77 @@ export const zhHans: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "敬请期待 • 下一个迁移目标",
// --- Pets filters (renderer) ---
"pets.filter.all": "全部",
"pets.filter.installed": "已安装",
"pets.filter.featured": "精选",
"pets.filter.originals": "原创",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "搜索宠物...",
"pets.import": "导入宠物",
"pets.gallery": "图库",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "全部",
"familiars.filter.installed": "已安装",
"familiars.filter.featured": "精选",
"familiars.filter.originals": "原创",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "搜索Familiar...",
"familiars.import": "导入Familiar",
"familiars.gallery": "图库",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "默认",
"pets.badge.original": "原创",
"pets.badge.featured": "精选",
"pets.badge.installed": "已安装",
"pets.badge.codex": "Codex",
"pets.badge.broken": "已损坏",
"pets.badge.ready": "就绪",
"pets.badge.originals": "原创",
"pets.action.viewPet": "查看宠物",
"pets.action.install": "安装",
"pets.action.import": "导入",
"pets.action.default": "设为默认",
"pets.action.remove": "移除",
"pets.action.refresh": "刷新",
"pets.aria.view": "查看 {name}",
"pets.aria.install": "安装 {name}",
"pets.aria.import": "从 Codex 导入 {name}",
"pets.aria.setDefault": "将 {name} 设为默认",
"pets.aria.remove": "移除 {name}",
"pets.busy.installing": "安装中",
"pets.busy.importing": "导入中",
"pets.busy.settingDefault": "设置默认中",
"pets.busy.removing": "移除中",
"pets.busy.loadingPage": "加载页面中",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "默认",
"familiars.badge.original": "原创",
"familiars.badge.featured": "精选",
"familiars.badge.installed": "已安装",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "已损坏",
"familiars.badge.ready": "就绪",
"familiars.badge.originals": "原创",
"familiars.action.viewPet": "查看Familiar",
"familiars.action.install": "安装",
"familiars.action.import": "导入",
"familiars.action.default": "设为默认",
"familiars.action.remove": "移除",
"familiars.action.refresh": "刷新",
"familiars.aria.view": "查看 {name}",
"familiars.aria.install": "安装 {name}",
"familiars.aria.import": "从 Codex 导入 {name}",
"familiars.aria.setDefault": "将 {name} 设为默认",
"familiars.aria.remove": "移除 {name}",
"familiars.busy.installing": "安装中",
"familiars.busy.importing": "导入中",
"familiars.busy.settingDefault": "设置默认中",
"familiars.busy.removing": "移除中",
"familiars.busy.loadingPage": "加载页面中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "上一页",
"pets.pager.next": "下一页",
"pets.pager.count": "{count} 个宠物",
"pets.pager.page": " · 第 {page} / {pageCount} 页",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "上一页",
"familiars.pager.next": "下一页",
"familiars.pager.count": "{count} 个Familiar",
"familiars.pager.page": " · 第 {page} / {pageCount} 页",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 宠物详情",
"pets.detail.closeAria": "关闭宠物详情",
"pets.detail.eyebrow": "宠物详情",
"pets.detail.previewAnimations": "预览动画",
"pets.detail.preview.idle": "待机",
"pets.detail.preview.thinking": "思考",
"pets.detail.preview.happy": "开心",
"pets.detail.preview.wave": "挥手",
"pets.detail.installPet": "安装宠物",
"pets.detail.importCodexPet": "导入 Codex 宠物",
"pets.detail.setDefaultPet": "设为默认宠物",
"pets.detail.remove": "移除",
"pets.detail.refresh": "刷新",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "{name} Familiar详情",
"familiars.detail.closeAria": "关闭Familiar详情",
"familiars.detail.eyebrow": "Familiar详情",
"familiars.detail.previewAnimations": "预览动画",
"familiars.detail.preview.idle": "待机",
"familiars.detail.preview.thinking": "思考",
"familiars.detail.preview.happy": "开心",
"familiars.detail.preview.wave": "挥手",
"familiars.detail.installPet": "安装Familiar",
"familiars.detail.importCodexPet": "导入 Codex Familiar",
"familiars.detail.setDefaultPet": "设为默认Familiar",
"familiars.detail.remove": "移除",
"familiars.detail.refresh": "刷新",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "此已安装的宠物已损坏,无法设为默认。",
"pets.status.defaultProtected": "默认内置宠物,受保护无法移除。",
"pets.status.default": "默认宠物。",
"pets.status.installedCodex": "已安装,可设为默认宠物。也可在 ~/.codex/pets 中找到。",
"pets.status.installed": "已安装,可设为默认宠物。",
"pets.status.availableCodex": "可从 ~/.codex/pets 导入。",
"pets.status.availableCatalog": "可从目录安装。",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "此已安装的Familiar已损坏,无法设为默认。",
"familiars.status.defaultProtected": "默认内置Familiar,受保护无法移除。",
"familiars.status.default": "默认Familiar。",
"familiars.status.installedCodex": "已安装可设为默认Familiar。也可在 ~/.codex/familiars 中找到。",
"familiars.status.installed": "已安装可设为默认Familiar。",
"familiars.status.availableCodex": "可从 ~/.codex/familiars 导入。",
"familiars.status.availableCatalog": "可从目录安装。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 缩略图",
"pets.spriteLabel.thumb": "{name} 缩略图",
"pets.spriteLabel.animatedPreview": "{name} 动画预览",
"pets.spriteLabel.statePreview": "{name} {state} 预览",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "{name} 缩略图",
"familiars.spriteLabel.thumb": "{name} 缩略图",
"familiars.spriteLabel.animatedPreview": "{name} 动画预览",
"familiars.spriteLabel.statePreview": "{name} {state} 预览",
// --- Settings: general (renderer) ---
"settings.nav.general": "常规",
@ -189,22 +189,22 @@ export const zhHans: Partial<Messages> = {
"settings.nav.plugins": "插件平台",
"settings.general.eyebrow": "环境",
"settings.general.title": "常规设置",
"settings.general.showOnLaunch.title": "启动时显示宠物",
"settings.general.showOnLaunch.description": "将 OpenPets 保留在托盘中,但在需要前隐藏宠物。",
"settings.general.showOnLaunch.title": "启动时显示Familiar",
"settings.general.showOnLaunch.description": "将 FamiliarOS 保留在托盘中但在需要前隐藏Familiar。",
"settings.general.launchAtLogin.title": "开机自启动",
"settings.general.launchAtLogin.supported": "在电脑启动时自动启动 OpenPets。",
"settings.general.launchAtLogin.supported": "在电脑启动时自动启动 FamiliarOS。",
"settings.general.launchAtLogin.unsupported": "此平台不支持。",
"settings.general.petScale.title": "宠物大小",
"settings.general.petScale.description": "调整默认桌面宠物的显示大小。",
"settings.general.resetPosition": "重置宠物位置",
"settings.general.petScale.title": "Familiar大小",
"settings.general.petScale.description": "调整默认桌面Familiar的显示大小。",
"settings.general.resetPosition": "重置Familiar位置",
"settings.general.systemStatus": "系统状态",
"settings.general.updateAvailable": "有可用更新",
"settings.general.checking": "检查中…",
"settings.general.checkForUpdates": "检查更新",
"settings.toast.startupSaved": "启动偏好已保存。",
"settings.toast.loginStartupSaved": "登录启动偏好已保存。",
"settings.toast.petScaleSaved": "宠物大小已保存。",
"settings.toast.positionReset": "默认宠物位置已重置。",
"settings.toast.petScaleSaved": "Familiar大小已保存。",
"settings.toast.positionReset": "默认Familiar位置已重置。",
"settings.busy.saving": "保存中",
"settings.busy.resetting": "重置中",
"settings.busy.opening": "打开中",
@ -222,7 +222,7 @@ export const zhHans: Partial<Messages> = {
"settings.reactions.eyebrow": "行为",
"settings.reactions.title": "反应映射",
"settings.reactions.resetDefaults": "恢复默认",
"settings.reactions.description": "自定义每个代理反应所播放的动画。预览使用默认宠物。",
"settings.reactions.description": "自定义每个代理反应所播放的动画。预览使用默认Familiar。",
"settings.reactions.previewAria": "动画:{state}",
"settings.toast.reactionsReset": "反应动画已重置。",
"settings.toast.reactionSaved": "反应动画已保存。",
@ -255,7 +255,7 @@ export const zhHans: Partial<Messages> = {
"settings.reaction.waiting.label": "等待中",
"settings.reaction.waiting.description": "代理被阻塞或正在等待权限。",
"settings.reaction.waving.label": "挥手",
"settings.reaction.waving.description": "宠物正在问候或引起注意。",
"settings.reaction.waving.description": "Familiar正在问候或引起注意。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "任务已成功完成。",
"settings.reaction.error.label": "错误",
@ -272,13 +272,13 @@ export const zhHans: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "允许插件播放声音",
"settings.plugins.audio.description": "允许插件播放提示音、警报和内置音效。",
"settings.plugins.voice.title": "允许插件说话(语音)",
"settings.plugins.voice.description": "允许通过系统语音进行文字转语音。",
"settings.plugins.dynamicSpeech.title": "允许 AI 生成宠物语音",
"settings.plugins.dynamicSpeech.title": "允许 AI 生成Familiar语音",
"settings.plugins.dynamicSpeech.description": "敏感:允许已批准的插件显示模型生成的气泡。",
"settings.plugins.microphone.title": "允许麦克风(按键说话)",
"settings.plugins.microphone.description": "敏感:允许已批准的插件捕获一次性语音输入。",
@ -401,22 +401,22 @@ export const zhHans: Partial<Messages> = {
"plugins.busy.uninstalling": "卸载中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "说话",
"plugins.permission.pet:reaction": "反应",
"plugins.permission.pet:move": "移动",
"plugins.permission.familiar:speak": "说话",
"plugins.permission.familiar:reaction": "反应",
"plugins.permission.familiar:move": "移动",
"plugins.permission.timer": "计时器",
"plugins.permission.schedule": "日程",
"plugins.permission.storage": "存储",
"plugins.permission.status": "状态",
"plugins.permission.commands": "命令",
"plugins.permission.network": "网络",
"plugins.permission.pet:interact": "气泡按钮",
"plugins.permission.pet:pin": "固定气泡",
"plugins.permission.pet:animate": "自定义动画",
"plugins.permission.pet:speak:dynamic": "AI 语音",
"plugins.permission.pet:drop": "拖放",
"plugins.permission.pets:read": "读取宠物",
"plugins.permission.pets:manage": "管理宠物",
"plugins.permission.familiar:interact": "气泡按钮",
"plugins.permission.familiar:pin": "固定气泡",
"plugins.permission.familiar:animate": "自定义动画",
"plugins.permission.familiar:speak:dynamic": "AI 语音",
"plugins.permission.familiar:drop": "拖放",
"plugins.permission.familiars:read": "读取Familiar",
"plugins.permission.familiars:manage": "管理Familiar",
"plugins.permission.audio": "声音",
"plugins.permission.events": "事件",
"plugins.permission.ui:toast": "提示消息",
@ -440,14 +440,14 @@ export const zhHans: Partial<Messages> = {
"integrations.commandMode.local": "本地开发",
"integrations.loading": "正在加载集成…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "将 Claude Code 连接到你的 OpenPets 伙伴。",
"integrations.claude.description": "将 Claude Code 连接到你的 FamiliarOS 伙伴。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "将 OpenCode 全局连接到你的 OpenPets 伙伴。",
"integrations.opencode.description": "将 OpenCode 全局连接到你的 FamiliarOS 伙伴。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "通过全局 MCP 配置将 Cursor 连接到你的 OpenPets 伙伴。",
"integrations.cursor.description": "通过全局 MCP 配置将 Cursor 连接到你的 FamiliarOS 伙伴。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手动",
"integrations.pi.description": "通过 OpenPets Pi 扩展包连接 Pi 编程代理活动。",
"integrations.pi.description": "通过 FamiliarOS Pi 扩展包连接 Pi 编程代理活动。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const zhHans: Partial<Messages> = {
"integrations.commandSource": "命令来源",
"integrations.cliMode": "CLI 模式",
"integrations.localUnavailable": " 不可用",
"integrations.commandModeHelp": "常规设置请使用已发布的包,桌面应用构建请使用内置版,开发 OpenPets 时请使用本地版。",
"integrations.commandModeHelp": "常规设置请使用已发布的包,桌面应用构建请使用内置版,开发 FamiliarOS 时请使用本地版。",
"integrations.connection": "连接",
"integrations.statusRouting": "状态与路由",
"integrations.globalSetup": "全局设置",
"integrations.globalMcp": "全局 MCP",
"integrations.petRouting": "宠物路由",
"integrations.defaultPet": "默认宠物",
"integrations.petRouting": "Familiar路由",
"integrations.defaultPet": "默认Familiar",
"integrations.configuration": "配置",
"integrations.commandPaths": "命令路径",
"integrations.claudeCommand": "Claude 命令",
@ -497,12 +497,12 @@ export const zhHans: Partial<Messages> = {
"integrations.rulesPreview": "规则预览",
"integrations.pi.manualSetup": "手动设置",
"integrations.pi.extension": "Pi 扩展",
"integrations.pi.intro": "从 Pi 安装 OpenPets Pi 扩展,然后在 Pi 会话中使用斜杠命令。",
"integrations.pi.intro": "从 Pi 安装 FamiliarOS Pi 扩展,然后在 Pi 会话中使用斜杠命令。",
"integrations.pi.globalInstall": "全局安装",
"integrations.pi.projectInstall": "项目安装",
"integrations.pi.remove": "移除",
"integrations.pi.slashCommands": "斜杠命令",
"integrations.pi.outro": "全局安装适用于所有 Pi 工作区,项目安装则仅在当前项目中启用 OpenPets。",
"integrations.pi.outro": "全局安装适用于所有 Pi 工作区,项目安装则仅在当前项目中启用 FamiliarOS。",
"integrations.toast.pathSaved": "路径已保存。",
"integrations.busy.installing": "安装中",
"integrations.busy.replacing": "替换中",
@ -517,21 +517,21 @@ export const zhHans: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "语言",
"settings.language.description": "OpenPets 菜单和窗口的显示语言。",
"settings.language.description": "FamiliarOS 菜单和窗口的显示语言。",
"settings.language.system": "跟随系统",
};

View file

@ -4,38 +4,38 @@ import type { Messages } from "../catalog.js";
export const zhHant: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "有可用更新:{version}...",
"tray.defaultPet": "預設寵物{name}",
"tray.toggleDefaultPet.show": "顯示預設寵物",
"tray.toggleDefaultPet.hide": "隱藏預設寵物",
"tray.pauseAllPets": "暫停所有寵物",
"tray.resumeAllPets": "恢復所有寵物",
"tray.managePets": "管理寵物...",
"tray.defaultPet": "預設Familiar{name}",
"tray.toggleDefaultPet.show": "顯示預設Familiar",
"tray.toggleDefaultPet.hide": "隱藏預設Familiar",
"tray.pauseAllPets": "暫停所有Familiar",
"tray.resumeAllPets": "恢復所有Familiar",
"tray.managePets": "管理Familiar...",
"tray.controlCenter": "控制中心...",
"tray.website": "網站...",
"tray.integrations": "整合...",
"tray.plugins": "外掛...",
"tray.settings": "設定...",
"tray.openLogsFolder": "開啟日誌資料夾...",
"tray.quit": "結束 OpenPets",
"tray.quit": "結束 FamiliarOS",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "內建寵物",
"common.builtInPet": "內建Familiar",
"common.cancel": "取消",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "已暫停",
"pet.status.thinking": "思考中",
"pet.status.working": "工作中",
"pet.status.editing": "編輯中",
"pet.status.testing": "測試中",
"pet.status.waiting": "等待中",
"pet.status.done": "完成",
"pet.status.oops": "出錯了",
"pet.status.hi": "你好",
"pet.menu.hidePet": "隱藏寵物",
"pet.menu.closePet": "關閉寵物",
"pet.menu.openControlCenter": "開啟控制中心",
// --- Familiar window (main process, src/familiar-window.ts) ---
"familiar.paused": "已暫停",
"familiar.status.thinking": "思考中",
"familiar.status.working": "工作中",
"familiar.status.editing": "編輯中",
"familiar.status.testing": "測試中",
"familiar.status.waiting": "等待中",
"familiar.status.done": "完成",
"familiar.status.oops": "出錯了",
"familiar.status.hi": "你好",
"familiar.menu.hidePet": "隱藏Familiar",
"familiar.menu.closePet": "關閉Familiar",
"familiar.menu.openControlCenter": "開啟控制中心",
// --- Common (renderer, shared across views) ---
"common.retry": "重試",
@ -45,7 +45,7 @@ export const zhHant: Partial<Messages> = {
// --- Navigation tabs (renderer) ---
"nav.dashboard": "儀表板",
"nav.pets": "寵物",
"nav.familiars": "Familiar",
"nav.settings": "設定",
"nav.plugins": "外掛",
"nav.integrations": "整合",
@ -53,8 +53,8 @@ export const zhHant: Partial<Messages> = {
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "儀表板",
"route.dashboard.description": "總覽你的活躍夥伴、狀態與系統指標。",
"route.pets.title": "寵物",
"route.pets.description": "安裝、匯入、預覽並選擇你的預設桌面夥伴。",
"route.familiars.title": "Familiar",
"route.familiars.description": "安裝、匯入、預覽並選擇你的預設桌面夥伴。",
"route.settings.title": "設定",
"route.settings.description": "設定啟動行為、大小偏好與動畫設定。",
"route.plugins.title": "外掛",
@ -64,13 +64,13 @@ export const zhHant: Partial<Messages> = {
// --- App shell (renderer) ---
"app.controlCenter": "控制中心",
"app.logo.alt": "OpenPets",
"app.logo.alt": "FamiliarOS",
// --- Dashboard (renderer) ---
"dashboard.loading": "正在蒐集夥伴指標...",
"dashboard.hero.eyebrow": "主要夥伴",
"dashboard.hero.desc": "準備好開始你的下一段寫程式時光。",
"dashboard.hero.changePet": "更換寵物",
"dashboard.hero.changePet": "更換Familiar",
"dashboard.lastActive.none": "尚無活動",
"dashboard.update.available": "有可用更新",
"dashboard.update.error": "檢查失敗",
@ -82,7 +82,7 @@ export const zhHant: Partial<Messages> = {
"dashboard.stat.reactions": "反應",
"dashboard.stat.reactions.footer": "觸發的動畫總數",
"dashboard.stat.topCompanion": "最佳夥伴",
"dashboard.stat.topCompanion.footer": "近期最活躍的寵物",
"dashboard.stat.topCompanion.footer": "近期最活躍的Familiar",
"dashboard.activity.title": "活動總覽",
"dashboard.activity.topReactions": "熱門反應",
"dashboard.activity.noReactions": "尚無反應記錄。開始寫程式吧!",
@ -93,17 +93,17 @@ export const zhHant: Partial<Messages> = {
"dashboard.reactionMix.reactions": "個反應",
"dashboard.reactionMix.empty": "尚無反應組合。",
"dashboard.companions.title": "最佳夥伴",
"dashboard.companions.subtitle": "最活躍的寵物",
"dashboard.companions.subtitle": "最活躍的Familiar",
"dashboard.companions.empty": "尚無夥伴活動。",
"dashboard.lastActive.label": "上次活躍:",
"dashboard.system.title": "系統狀態",
"dashboard.system.pets": "寵物",
"dashboard.system.pets.value": "已安裝 {count} 個",
"dashboard.system.familiars": "Familiar",
"dashboard.system.familiars.value": "已安裝 {count} 個",
"dashboard.system.plugins": "外掛",
"dashboard.system.plugins.enabled": "已啟用 {count} 個",
"dashboard.system.catalog": "目錄",
"dashboard.system.catalog.offline": "離線",
"dashboard.system.catalog.pets": "{count} 個寵物",
"dashboard.system.catalog.familiars": "{count} 個Familiar",
"dashboard.system.catalog.ready": "就緒",
"dashboard.system.updates": "更新",
"dashboard.system.version": "版本",
@ -111,77 +111,77 @@ export const zhHant: Partial<Messages> = {
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "即將推出 • 下一個移轉目標",
// --- Pets filters (renderer) ---
"pets.filter.all": "全部",
"pets.filter.installed": "已安裝",
"pets.filter.featured": "精選",
"pets.filter.originals": "原創",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "搜尋寵物...",
"pets.import": "匯入寵物",
"pets.gallery": "藝廊",
// --- Familiars filters (renderer) ---
"familiars.filter.all": "全部",
"familiars.filter.installed": "已安裝",
"familiars.filter.featured": "精選",
"familiars.filter.originals": "原創",
"familiars.filter.codex": "Codex",
"familiars.search.placeholder": "搜尋Familiar...",
"familiars.import": "匯入Familiar",
"familiars.gallery": "藝廊",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "預設",
"pets.badge.original": "原創",
"pets.badge.featured": "精選",
"pets.badge.installed": "已安裝",
"pets.badge.codex": "Codex",
"pets.badge.broken": "已損壞",
"pets.badge.ready": "就緒",
"pets.badge.originals": "原創",
"pets.action.viewPet": "檢視寵物",
"pets.action.install": "安裝",
"pets.action.import": "匯入",
"pets.action.default": "設為預設",
"pets.action.remove": "移除",
"pets.action.refresh": "重新整理",
"pets.aria.view": "檢視 {name}",
"pets.aria.install": "安裝 {name}",
"pets.aria.import": "從 Codex 匯入 {name}",
"pets.aria.setDefault": "將 {name} 設為預設",
"pets.aria.remove": "移除 {name}",
"pets.busy.installing": "安裝中",
"pets.busy.importing": "匯入中",
"pets.busy.settingDefault": "設定預設中",
"pets.busy.removing": "移除中",
"pets.busy.loadingPage": "載入頁面中",
// --- Familiars card badges/actions (renderer) ---
"familiars.badge.default": "預設",
"familiars.badge.original": "原創",
"familiars.badge.featured": "精選",
"familiars.badge.installed": "已安裝",
"familiars.badge.codex": "Codex",
"familiars.badge.broken": "已損壞",
"familiars.badge.ready": "就緒",
"familiars.badge.originals": "原創",
"familiars.action.viewPet": "檢視Familiar",
"familiars.action.install": "安裝",
"familiars.action.import": "匯入",
"familiars.action.default": "設為預設",
"familiars.action.remove": "移除",
"familiars.action.refresh": "重新整理",
"familiars.aria.view": "檢視 {name}",
"familiars.aria.install": "安裝 {name}",
"familiars.aria.import": "從 Codex 匯入 {name}",
"familiars.aria.setDefault": "將 {name} 設為預設",
"familiars.aria.remove": "移除 {name}",
"familiars.busy.installing": "安裝中",
"familiars.busy.importing": "匯入中",
"familiars.busy.settingDefault": "設定預設中",
"familiars.busy.removing": "移除中",
"familiars.busy.loadingPage": "載入頁面中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "上一頁",
"pets.pager.next": "下一頁",
"pets.pager.count": "{count} 個寵物",
"pets.pager.page": " · 第 {page} / {pageCount} 頁",
// --- Familiars pager (renderer) ---
"familiars.pager.prev": "上一頁",
"familiars.pager.next": "下一頁",
"familiars.pager.count": "{count} 個Familiar",
"familiars.pager.page": " · 第 {page} / {pageCount} 頁",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 寵物詳情",
"pets.detail.closeAria": "關閉寵物詳情",
"pets.detail.eyebrow": "寵物詳情",
"pets.detail.previewAnimations": "預覽動畫",
"pets.detail.preview.idle": "待機",
"pets.detail.preview.thinking": "思考",
"pets.detail.preview.happy": "開心",
"pets.detail.preview.wave": "揮手",
"pets.detail.installPet": "安裝寵物",
"pets.detail.importCodexPet": "匯入 Codex 寵物",
"pets.detail.setDefaultPet": "設為預設寵物",
"pets.detail.remove": "移除",
"pets.detail.refresh": "重新整理",
// --- Familiar detail dialog (renderer) ---
"familiars.detail.ariaLabel": "{name} Familiar詳情",
"familiars.detail.closeAria": "關閉Familiar詳情",
"familiars.detail.eyebrow": "Familiar詳情",
"familiars.detail.previewAnimations": "預覽動畫",
"familiars.detail.preview.idle": "待機",
"familiars.detail.preview.thinking": "思考",
"familiars.detail.preview.happy": "開心",
"familiars.detail.preview.wave": "揮手",
"familiars.detail.installPet": "安裝Familiar",
"familiars.detail.importCodexPet": "匯入 Codex Familiar",
"familiars.detail.setDefaultPet": "設為預設Familiar",
"familiars.detail.remove": "移除",
"familiars.detail.refresh": "重新整理",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "這個已安裝的寵物已損壞,無法設為預設。",
"pets.status.defaultProtected": "預設內建寵物,無法移除。",
"pets.status.default": "預設寵物。",
"pets.status.installedCodex": "已安裝,可設為你的預設寵物。也可在 ~/.codex/pets 中找到。",
"pets.status.installed": "已安裝,可設為你的預設寵物。",
"pets.status.availableCodex": "可從 ~/.codex/pets 匯入。",
"pets.status.availableCatalog": "可從目錄安裝。",
// --- Familiar detail status text (renderer) ---
"familiars.status.broken": "這個已安裝的Familiar已損壞,無法設為預設。",
"familiars.status.defaultProtected": "預設內建Familiar,無法移除。",
"familiars.status.default": "預設Familiar。",
"familiars.status.installedCodex": "已安裝可設為你的預設Familiar。也可在 ~/.codex/familiars 中找到。",
"familiars.status.installed": "已安裝可設為你的預設Familiar。",
"familiars.status.availableCodex": "可從 ~/.codex/familiars 匯入。",
"familiars.status.availableCatalog": "可從目錄安裝。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 縮圖",
"pets.spriteLabel.thumb": "{name} 縮圖",
"pets.spriteLabel.animatedPreview": "{name} 動畫預覽",
"pets.spriteLabel.statePreview": "{name} {state} 預覽",
// --- Familiar labels for SpriteFrame/PetImage (renderer) ---
"familiars.spriteLabel.thumbnail": "{name} 縮圖",
"familiars.spriteLabel.thumb": "{name} 縮圖",
"familiars.spriteLabel.animatedPreview": "{name} 動畫預覽",
"familiars.spriteLabel.statePreview": "{name} {state} 預覽",
// --- Settings: general (renderer) ---
"settings.nav.general": "一般",
@ -189,22 +189,22 @@ export const zhHant: Partial<Messages> = {
"settings.nav.plugins": "外掛平台",
"settings.general.eyebrow": "環境",
"settings.general.title": "一般設定",
"settings.general.showOnLaunch.title": "啟動時顯示寵物",
"settings.general.showOnLaunch.description": "將 OpenPets 保留在工作列,但在需要時才顯示寵物。",
"settings.general.showOnLaunch.title": "啟動時顯示Familiar",
"settings.general.showOnLaunch.description": "將 FamiliarOS 保留在工作列但在需要時才顯示Familiar。",
"settings.general.launchAtLogin.title": "登入時啟動",
"settings.general.launchAtLogin.supported": "在電腦開機時自動啟動 OpenPets。",
"settings.general.launchAtLogin.supported": "在電腦開機時自動啟動 FamiliarOS。",
"settings.general.launchAtLogin.unsupported": "此平台不支援。",
"settings.general.petScale.title": "寵物大小",
"settings.general.petScale.description": "調整預設桌面寵物的顯示大小。",
"settings.general.resetPosition": "重設寵物位置",
"settings.general.petScale.title": "Familiar大小",
"settings.general.petScale.description": "調整預設桌面Familiar的顯示大小。",
"settings.general.resetPosition": "重設Familiar位置",
"settings.general.systemStatus": "系統狀態",
"settings.general.updateAvailable": "有可用更新",
"settings.general.checking": "檢查中…",
"settings.general.checkForUpdates": "檢查更新",
"settings.toast.startupSaved": "已儲存啟動偏好。",
"settings.toast.loginStartupSaved": "已儲存登入啟動偏好。",
"settings.toast.petScaleSaved": "已儲存寵物大小。",
"settings.toast.positionReset": "已重設預設寵物位置。",
"settings.toast.petScaleSaved": "已儲存Familiar大小。",
"settings.toast.positionReset": "已重設預設Familiar位置。",
"settings.busy.saving": "儲存中",
"settings.busy.resetting": "重設中",
"settings.busy.opening": "開啟中",
@ -222,7 +222,7 @@ export const zhHant: Partial<Messages> = {
"settings.reactions.eyebrow": "行為",
"settings.reactions.title": "反應對應",
"settings.reactions.resetDefaults": "重設為預設值",
"settings.reactions.description": "自訂每個代理反應所播放的動畫。預覽使用預設寵物。",
"settings.reactions.description": "自訂每個代理反應所播放的動畫。預覽使用預設Familiar。",
"settings.reactions.previewAria": "動畫:{state}",
"settings.toast.reactionsReset": "已重設反應動畫。",
"settings.toast.reactionSaved": "已儲存反應動畫。",
@ -255,7 +255,7 @@ export const zhHant: Partial<Messages> = {
"settings.reaction.waiting.label": "等待中",
"settings.reaction.waiting.description": "代理受阻或正在等待權限。",
"settings.reaction.waving.label": "揮手",
"settings.reaction.waving.description": "寵物正在問候或吸引注意。",
"settings.reaction.waving.description": "Familiar正在問候或吸引注意。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "任務已成功完成。",
"settings.reaction.error.label": "錯誤",
@ -272,13 +272,13 @@ export const zhHant: Partial<Messages> = {
"settings.ai.description": "Model, endpoint, and system instructions used by the built-in floating chat.",
"settings.tts.eyebrow": "Voice",
"settings.tts.title": "Text-to-Speech",
"settings.tts.description": "Voice provider and controls for the pet and floating chat.",
"settings.tts.description": "Voice provider and controls for the familiar and floating chat.",
"settings.tts.placeholder": "TTS provider settings will appear here in the next build step.",
"settings.plugins.audio.title": "允許外掛播放聲音",
"settings.plugins.audio.description": "允許外掛的提示音、警示音與內建音效。",
"settings.plugins.voice.title": "允許外掛說話(語音)",
"settings.plugins.voice.description": "允許透過系統語音進行文字轉語音。",
"settings.plugins.dynamicSpeech.title": "允許 AI 生成的寵物對話",
"settings.plugins.dynamicSpeech.title": "允許 AI 生成的Familiar對話",
"settings.plugins.dynamicSpeech.description": "敏感:讓核准的外掛顯示由模型生成的對話泡泡。",
"settings.plugins.microphone.title": "允許麥克風(按住說話)",
"settings.plugins.microphone.description": "敏感:讓核准的外掛擷取單次語音輸入。",
@ -401,22 +401,22 @@ export const zhHant: Partial<Messages> = {
"plugins.busy.uninstalling": "解除安裝中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "說話",
"plugins.permission.pet:reaction": "反應",
"plugins.permission.pet:move": "移動",
"plugins.permission.familiar:speak": "說話",
"plugins.permission.familiar:reaction": "反應",
"plugins.permission.familiar:move": "移動",
"plugins.permission.timer": "計時器",
"plugins.permission.schedule": "排程",
"plugins.permission.storage": "儲存空間",
"plugins.permission.status": "狀態",
"plugins.permission.commands": "指令",
"plugins.permission.network": "網路",
"plugins.permission.pet:interact": "泡泡按鈕",
"plugins.permission.pet:pin": "釘選泡泡",
"plugins.permission.pet:animate": "自訂動畫",
"plugins.permission.pet:speak:dynamic": "AI 對話",
"plugins.permission.pet:drop": "拖放",
"plugins.permission.pets:read": "讀取寵物",
"plugins.permission.pets:manage": "管理寵物",
"plugins.permission.familiar:interact": "泡泡按鈕",
"plugins.permission.familiar:pin": "釘選泡泡",
"plugins.permission.familiar:animate": "自訂動畫",
"plugins.permission.familiar:speak:dynamic": "AI 對話",
"plugins.permission.familiar:drop": "拖放",
"plugins.permission.familiars:read": "讀取Familiar",
"plugins.permission.familiars:manage": "管理Familiar",
"plugins.permission.audio": "聲音",
"plugins.permission.events": "事件",
"plugins.permission.ui:toast": "提示訊息",
@ -440,14 +440,14 @@ export const zhHant: Partial<Messages> = {
"integrations.commandMode.local": "本機開發",
"integrations.loading": "正在載入整合…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "將 Claude Code 連接到你的 OpenPets 夥伴。",
"integrations.claude.description": "將 Claude Code 連接到你的 FamiliarOS 夥伴。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "將 OpenCode 全域連接到你的 OpenPets 夥伴。",
"integrations.opencode.description": "將 OpenCode 全域連接到你的 FamiliarOS 夥伴。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "透過全域 MCP 設定將 Cursor 連接到你的 OpenPets 夥伴。",
"integrations.cursor.description": "透過全域 MCP 設定將 Cursor 連接到你的 FamiliarOS 夥伴。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手動",
"integrations.pi.description": "透過 OpenPets Pi 擴充套件連接 Pi 程式設計代理的活動。",
"integrations.pi.description": "透過 FamiliarOS Pi 擴充套件連接 Pi 程式設計代理的活動。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
@ -463,13 +463,13 @@ export const zhHant: Partial<Messages> = {
"integrations.commandSource": "指令來源",
"integrations.cliMode": "CLI 模式",
"integrations.localUnavailable": " 無法使用",
"integrations.commandModeHelp": "一般設定請使用已發佈套件,桌面應用程式版本請使用內建,開發 OpenPets 時則使用本機。",
"integrations.commandModeHelp": "一般設定請使用已發佈套件,桌面應用程式版本請使用內建,開發 FamiliarOS 時則使用本機。",
"integrations.connection": "連線",
"integrations.statusRouting": "狀態與路由",
"integrations.globalSetup": "全域設定",
"integrations.globalMcp": "全域 MCP",
"integrations.petRouting": "寵物路由",
"integrations.defaultPet": "預設寵物",
"integrations.petRouting": "Familiar路由",
"integrations.defaultPet": "預設Familiar",
"integrations.configuration": "設定",
"integrations.commandPaths": "指令路徑",
"integrations.claudeCommand": "Claude 指令",
@ -497,12 +497,12 @@ export const zhHant: Partial<Messages> = {
"integrations.rulesPreview": "規則預覽",
"integrations.pi.manualSetup": "手動設定",
"integrations.pi.extension": "Pi 擴充套件",
"integrations.pi.intro": "從 Pi 安裝 OpenPets Pi 擴充套件,然後在 Pi 工作階段中使用斜線指令。",
"integrations.pi.intro": "從 Pi 安裝 FamiliarOS Pi 擴充套件,然後在 Pi 工作階段中使用斜線指令。",
"integrations.pi.globalInstall": "全域安裝",
"integrations.pi.projectInstall": "專案安裝",
"integrations.pi.remove": "移除",
"integrations.pi.slashCommands": "斜線指令",
"integrations.pi.outro": "所有 Pi 工作區皆使用全域安裝;只想在目前專案使用 OpenPets 時則選擇專案安裝。",
"integrations.pi.outro": "所有 Pi 工作區皆使用全域安裝;只想在目前專案使用 FamiliarOS 時則選擇專案安裝。",
"integrations.toast.pathSaved": "已儲存路徑。",
"integrations.busy.installing": "安裝中",
"integrations.busy.replacing": "取代中",
@ -517,21 +517,21 @@ export const zhHant: Partial<Messages> = {
"integrations.mcpToolServers.noTools": "None active",
"integrations.mcpToolServers.builtInChat": "Built-in Chat",
"integrations.mcpToolServers.saved": "Saved {count} tool server(s).",
"integrations.openpetsMcpServer.name": "OpenPets MCP Server",
"integrations.openpetsMcpServer.description": "External-agent bridge that exposes your pet to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.openpetsMcpServer.commandModeHelp": "Choose how the OpenPets MCP server is launched for external hosts.",
"integrations.openpetsMcpServer.testServer": "Test Server",
"integrations.openpetsMcpServer.testBusy": "Testing server…",
"integrations.openpetsMcpServer.testOk": "Server responded: {output}",
"integrations.openpetsMcpServer.testError": "Server test failed: {error}",
"integrations.openpetsMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.openpetsMcpServer.status": "Ready",
"integrations.familiarosMcpServer.name": "FamiliarOS MCP Server",
"integrations.familiarosMcpServer.description": "External-agent bridge that exposes your familiar to Claude Code, Cursor, Codex CLI, and other MCP hosts.",
"integrations.familiarosMcpServer.commandModeHelp": "Choose how the FamiliarOS MCP server is launched for external hosts.",
"integrations.familiarosMcpServer.testServer": "Test Server",
"integrations.familiarosMcpServer.testBusy": "Testing server…",
"integrations.familiarosMcpServer.testOk": "Server responded: {output}",
"integrations.familiarosMcpServer.testError": "Server test failed: {error}",
"integrations.familiarosMcpServer.copyMcpJson": "Copy MCP JSON",
"integrations.familiarosMcpServer.status": "Ready",
"integrations.curatedToolkit.name": "Curated MCP Toolkit",
"integrations.curatedToolkit.description": "Starter stack for filesystem, Git, browser use, memory, databases, Docker, and terminal-oriented MCP lanes.",
"integrations.linkToCentralPanel": "Configure in the OpenPets MCP Server panel above.",
"integrations.linkToCentralPanel": "Configure in the FamiliarOS MCP Server panel above.",
// --- Settings: Language section (renderer) ---
"settings.language.title": "語言",
"settings.language.description": "OpenPets 選單與視窗的顯示語言。",
"settings.language.description": "FamiliarOS 選單與視窗的顯示語言。",
"settings.language.system": "跟隨系統",
};

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// Español (Latinoamérica) pet speech-bubble pools.
export const es419: Record<OpenPetsReaction, readonly string[]> = {
// Español (Latinoamérica) familiar speech-bubble pools.
export const es419: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"Listo",
"En espera",

View file

@ -1,9 +1,9 @@
// Localized pet speech-bubble pools. English lives in ../../reaction-messages.ts
// Localized familiar speech-bubble pools. English lives in ../../reaction-messages.ts
// (`reactionMessagePools`); this registry holds the translated pools and is the
// fallback source for `pickReactionMessage`. A locale absent here, or a reaction
// absent from a locale, falls back to English.
import type { Locale } from "../catalog.js";
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
import { ja } from "./ja.js";
import { ko } from "./ko.js";
@ -12,7 +12,7 @@ import { zhHant } from "./zh-Hant.js";
import { ptBR } from "./pt-BR.js";
import { es419 } from "./es-419.js";
export type ReactionMessagePool = Record<OpenPetsReaction, readonly string[]>;
export type ReactionMessagePool = Record<FamiliarOSReaction, readonly string[]>;
export const localizedReactionMessagePools: Partial<Record<Locale, ReactionMessagePool>> = {
ja,

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// 日本語 (Japanese) pet speech-bubble pools.
export const ja: Record<OpenPetsReaction, readonly string[]> = {
// 日本語 (Japanese) familiar speech-bubble pools.
export const ja: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"準備OK",
"待機中",

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// 한국어 (Korean) pet speech-bubble pools.
export const ko: Record<OpenPetsReaction, readonly string[]> = {
// 한국어 (Korean) familiar speech-bubble pools.
export const ko: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"준비 완료",
"대기 중",

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// Português (Brasil) pet speech-bubble pools.
export const ptBR: Record<OpenPetsReaction, readonly string[]> = {
// Português (Brasil) familiar speech-bubble pools.
export const ptBR: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"Pronto",
"De prontidão",

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// 简体中文 (Simplified Chinese) pet speech-bubble pools.
export const zhHans: Record<OpenPetsReaction, readonly string[]> = {
// 简体中文 (Simplified Chinese) familiar speech-bubble pools.
export const zhHans: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"准备好了",
"随时待命",

View file

@ -1,7 +1,7 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
import type { FamiliarOSReaction } from "../../local-ipc-protocol.js";
// 繁體中文 (Traditional Chinese) pet speech-bubble pools.
export const zhHant: Record<OpenPetsReaction, readonly string[]> = {
// 繁體中文 (Traditional Chinese) familiar speech-bubble pools.
export const zhHant: Record<FamiliarOSReaction, readonly string[]> = {
idle: [
"準備好了",
"待命中",

View file

@ -8,19 +8,19 @@ import {
type StoreKnowledgeFileInput,
formatBytes,
} from "./knowledge-store-core.js";
import { type OpenPetsMemoryEntry, type OpenPetsMemorySearchHit, searchOpenPetsMemories, storeOpenPetsMemory } from "./openpets-memory.js";
import type { OpenPetsMemoryKind } from "./local-ipc-protocol.js";
import { type FamiliarOSMemoryEntry, type FamiliarOSMemorySearchHit, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js";
import type { FamiliarOSMemoryKind } from "./local-ipc-protocol.js";
export type { KnowledgeFileEntry, KnowledgeFileSearchHit, StoreKnowledgeFileInput };
export interface KnowledgeStoreSearchResult {
readonly files: readonly KnowledgeFileSearchHit[];
readonly memories: readonly OpenPetsMemorySearchHit[];
readonly memories: readonly FamiliarOSMemorySearchHit[];
}
export interface StoreKnowledgeMemoryInput {
readonly text: string;
readonly kind?: OpenPetsMemoryKind;
readonly kind?: FamiliarOSMemoryKind;
readonly tags?: readonly string[];
readonly importance?: number;
}
@ -47,7 +47,7 @@ export function searchKnowledgeStore(query: string, limit?: number): KnowledgeSt
const memoryLimit = clampLimit(typeof limit === "number" ? limit : 8, 8);
return {
files: getStore().searchFiles(query, fileLimit),
memories: searchOpenPetsMemories(query, memoryLimit),
memories: searchFamiliarOSMemories(query, memoryLimit),
};
}
@ -59,8 +59,8 @@ export async function deleteKnowledgeFile(id: string): Promise<boolean> {
return getStore().deleteFile(id);
}
export function addKnowledgeMemory(input: StoreKnowledgeMemoryInput): OpenPetsMemoryEntry {
return storeOpenPetsMemory({
export function addKnowledgeMemory(input: StoreKnowledgeMemoryInput): FamiliarOSMemoryEntry {
return storeFamiliarOSMemory({
text: input.text,
kind: input.kind,
tags: input.tags,

View file

@ -54,7 +54,7 @@ export class LeaseManager {
this.#ttlMs = options.ttlMs ?? 15_000;
this.#now = options.now ?? Date.now;
this.#resolveTarget = options.resolveTarget ?? (() => { throw new Error("Lease target resolver is not configured."); });
this.#getDefaultPetId = options.getDefaultPetId ?? (() => { throw new Error("Default pet resolver is not configured."); });
this.#getDefaultPetId = options.getDefaultPetId ?? (() => { throw new Error("Default familiar resolver is not configured."); });
this.#getPetDisplayName = options.getPetDisplayName ?? ((petId) => petId);
this.#onFirstExplicitLease = options.onFirstExplicitLease ?? (() => {});
this.#onLastExplicitLease = options.onLastExplicitLease ?? (() => {});

View file

@ -1,7 +1,7 @@
import { app } from "electron";
import { closeAllAgentPets } from "./agent-pet-controller.js";
import { destroyDefaultPet } from "./default-pet-controller.js";
import { closeAllAgentPets } from "./agent-familiar-controller.js";
import { destroyDefaultPet } from "./default-familiar-controller.js";
import { info } from "./logger.js";
import { destroyMcpChatClientManager } from "./mcp-chat-client.js";
import { stopLocalIpcServer } from "./local-ipc.js";
@ -14,20 +14,20 @@ let hardExitTimer: NodeJS.Timeout | null = null;
export function installAppLifecycle(): void {
app.on("second-instance", () => {
info("app", "second instance requested");
console.log("Second OpenPets launch requested; keeping existing instance.");
console.log("Second FamiliarOS launch requested; keeping existing instance.");
focusOpenTaskWindows();
});
app.on("window-all-closed", () => {
if (!intentionalQuit) {
info("app", "all task windows closed; tray app kept alive");
console.log("All OpenPets task windows closed; keeping tray app running.");
console.log("All FamiliarOS task windows closed; keeping tray app running.");
}
});
app.on("activate", () => {
info("app", "activate event");
console.log("OpenPets activate event received; not opening a dashboard window.");
console.log("FamiliarOS activate event received; not opening a dashboard window.");
});
app.on("before-quit", () => {
@ -42,7 +42,7 @@ export function installAppLifecycle(): void {
});
}
export function quitOpenPets(): void {
export function quitFamiliarOS(): void {
intentionalQuit = true;
info("app", "quit requested");
scheduleHardExitFallback("quit-requested");

View file

@ -7,9 +7,9 @@ import { app } from "electron";
import { openPetsIpcProtocol, openPetsIpcVersion } from "./local-ipc-protocol.js";
export interface OpenPetsDiscoveryFile {
export interface FamiliarOSDiscoveryFile {
readonly protocolVersion: 1;
readonly protocol: "openpets-ipc";
readonly protocol: "familiaros-ipc";
readonly endpoint: string;
readonly token: string;
readonly appVersion: string;
@ -22,24 +22,24 @@ export type IpcEndpoint =
| { readonly kind: "path"; readonly path: string };
export function getDiscoveryFilePath(): string {
if (process.env.OPENPETS_DISCOVERY_FILE) {
return process.env.OPENPETS_DISCOVERY_FILE;
if (process.env.FAMILIAROS_DISCOVERY_FILE) {
return process.env.FAMILIAROS_DISCOVERY_FILE;
}
if (process.platform === "darwin") {
return join(homedir(), "Library", "Application Support", "OpenPets", "runtime", "ipc.json");
return join(homedir(), "Library", "Application Support", "FamiliarOS", "runtime", "ipc.json");
}
if (process.platform === "win32") {
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "OpenPets", "runtime", "ipc.json");
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "FamiliarOS", "runtime", "ipc.json");
}
const xdg = getSecureXdgRuntimeDir();
if (xdg) {
return join(xdg, "openpets", "ipc.json");
return join(xdg, "familiaros", "ipc.json");
}
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "OpenPets", "runtime", "ipc.json");
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "FamiliarOS", "runtime", "ipc.json");
}
export interface IpcEndpointConfig {
@ -53,27 +53,27 @@ export function createIpcEndpoint(): string {
}
export function getIpcEndpointConfig(): IpcEndpointConfig {
const bindEnv = process.env.OPENPETS_IPC_BIND;
const endpointEnv = process.env.OPENPETS_IPC_ENDPOINT;
const bindEnv = process.env.FAMILIAROS_IPC_BIND;
const endpointEnv = process.env.FAMILIAROS_IPC_ENDPOINT;
// Case 1: Both BIND and ENDPOINT set - BIND for listening, ENDPOINT for advertising
if (bindEnv && endpointEnv) {
const bindParsed = parseIpcEndpoint(bindEnv, { allowPortZero: true, allowNonLoopback: true });
if (bindParsed.kind !== "tcp") {
throw new Error("OPENPETS_IPC_BIND only supports TCP endpoints (e.g., tcp://0.0.0.0:37645 or tcp://127.0.0.1:37645).");
throw new Error("FAMILIAROS_IPC_BIND only supports TCP endpoints (e.g., tcp://0.0.0.0:37645 or tcp://127.0.0.1:37645).");
}
validateBindHost(bindParsed.host);
const advertisedParsed = parseIpcEndpoint(endpointEnv, { allowPortZero: false, allowNonLoopback: true });
if (advertisedParsed.kind !== "tcp") {
throw new Error("OPENPETS_IPC_ENDPOINT must be a TCP endpoint when used with OPENPETS_IPC_BIND.");
throw new Error("FAMILIAROS_IPC_ENDPOINT must be a TCP endpoint when used with FAMILIAROS_IPC_BIND.");
}
// Advertised endpoint must have a concrete host (not 0.0.0.0)
if (advertisedParsed.host === "0.0.0.0") {
throw new Error("OPENPETS_IPC_ENDPOINT must specify a concrete IPv4 address (not 0.0.0.0) when used as the advertised endpoint.");
throw new Error("FAMILIAROS_IPC_ENDPOINT must specify a concrete IPv4 address (not 0.0.0.0) when used as the advertised endpoint.");
}
validateAdvertisedHost(advertisedParsed.host);
if (bindParsed.port !== 0 && advertisedParsed.port !== bindParsed.port) {
throw new Error("OPENPETS_IPC_ENDPOINT must use the same port as OPENPETS_IPC_BIND unless OPENPETS_IPC_BIND uses port 0.");
throw new Error("FAMILIAROS_IPC_ENDPOINT must use the same port as FAMILIAROS_IPC_BIND unless FAMILIAROS_IPC_BIND uses port 0.");
}
return { bindEndpoint: bindParsed, advertisedEndpoint: endpointEnv };
}
@ -82,12 +82,12 @@ export function getIpcEndpointConfig(): IpcEndpointConfig {
if (bindEnv) {
const bindParsed = parseIpcEndpoint(bindEnv, { allowPortZero: true, allowNonLoopback: true });
if (bindParsed.kind !== "tcp") {
throw new Error("OPENPETS_IPC_BIND only supports TCP endpoints (e.g., tcp://0.0.0.0:37645 or tcp://127.0.0.1:37645).");
throw new Error("FAMILIAROS_IPC_BIND only supports TCP endpoints (e.g., tcp://0.0.0.0:37645 or tcp://127.0.0.1:37645).");
}
validateBindHost(bindParsed.host);
// If binding to 0.0.0.0, we need a separate advertised endpoint
if (bindParsed.host === "0.0.0.0") {
throw new Error("When OPENPETS_IPC_BIND is set to 0.0.0.0, you must also set OPENPETS_IPC_ENDPOINT to specify the advertised endpoint (e.g., the WSL host IP).");
throw new Error("When FAMILIAROS_IPC_BIND is set to 0.0.0.0, you must also set FAMILIAROS_IPC_ENDPOINT to specify the advertised endpoint (e.g., the WSL host IP).");
}
validateAdvertisedHost(bindParsed.host);
return { bindEndpoint: bindParsed, advertisedEndpoint: bindEnv };
@ -95,51 +95,51 @@ export function getIpcEndpointConfig(): IpcEndpointConfig {
// Case 3: Only ENDPOINT set - invalid. ENDPOINT only advertises; BIND opts into TCP listening.
if (endpointEnv) {
throw new Error("OPENPETS_IPC_ENDPOINT only controls the advertised discovery endpoint. Set OPENPETS_IPC_BIND to opt into TCP IPC listening.");
throw new Error("FAMILIAROS_IPC_ENDPOINT only controls the advertised discovery endpoint. Set FAMILIAROS_IPC_BIND to opt into TCP IPC listening.");
}
// Case 4: No env vars - use platform default
if (process.platform === "win32") {
const pipePath = `\\\\.\\pipe\\openpets-${randomEndpointPart()}-${process.pid}`;
const pipePath = `\\\\.\\pipe\\familiaros-${randomEndpointPart()}-${process.pid}`;
return { bindEndpoint: { kind: "path", path: pipePath }, advertisedEndpoint: pipePath };
}
const runtimeDir = getSocketRuntimeDir();
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
ensurePrivateRuntimeDir(runtimeDir);
const socketPath = join(runtimeDir, `openpets-${process.pid}.sock`);
const socketPath = join(runtimeDir, `familiaros-${process.pid}.sock`);
return { bindEndpoint: { kind: "path", path: socketPath }, advertisedEndpoint: socketPath };
}
export function parseIpcEndpoint(endpoint: string, options: { readonly allowPortZero?: boolean; readonly allowNonLoopback?: boolean } = {}): IpcEndpoint {
if (endpoint.length < 1 || endpoint.length > 240) throw new Error("OpenPets IPC endpoint length is invalid.");
if (endpoint.includes("\0")) throw new Error("OpenPets IPC endpoint contains NUL.");
if (endpoint.length < 1 || endpoint.length > 240) throw new Error("FamiliarOS IPC endpoint length is invalid.");
if (endpoint.includes("\0")) throw new Error("FamiliarOS IPC endpoint contains NUL.");
if (endpoint.startsWith("tcp://")) {
let url: URL;
try {
url = new URL(endpoint);
} catch {
throw new Error("OpenPets TCP IPC endpoint is invalid.");
throw new Error("FamiliarOS TCP IPC endpoint is invalid.");
}
if (url.protocol !== "tcp:" || url.username || url.password || (url.pathname !== "" && url.pathname !== "/") || url.search || url.hash) {
throw new Error("OpenPets TCP IPC endpoint must be tcp://<host>:<port> with no credentials, path, query, or fragment.");
throw new Error("FamiliarOS TCP IPC endpoint must be tcp://<host>:<port> with no credentials, path, query, or fragment.");
}
const host = url.hostname;
if (!isValidIpv4(host)) {
throw new Error("OpenPets TCP IPC endpoint host must be a valid IPv4 address (127.0.0.1, 0.0.0.0, or a concrete address).");
throw new Error("FamiliarOS TCP IPC endpoint host must be a valid IPv4 address (127.0.0.1, 0.0.0.0, or a concrete address).");
}
if (!options.allowNonLoopback && host !== "127.0.0.1") {
throw new Error("OpenPets TCP IPC endpoint must bind to loopback host 127.0.0.1.");
throw new Error("FamiliarOS TCP IPC endpoint must bind to loopback host 127.0.0.1.");
}
const port = Number(url.port);
const minPort = options.allowPortZero ? 0 : 1;
if (!Number.isInteger(port) || port < minPort || port > 65_535 || String(port) !== url.port) {
throw new Error("OpenPets TCP IPC endpoint port is invalid.");
throw new Error("FamiliarOS TCP IPC endpoint port is invalid.");
}
return { kind: "tcp", host, port };
@ -177,7 +177,7 @@ function validateAdvertisedHost(host: string): void {
function validateBindHost(host: string): void {
if (host === "0.0.0.0") return;
if (!isPrivateOrLocalIpv4(host)) {
throw new Error(`OPENPETS_IPC_BIND host ${host} is not a private/local IPv4 address. Only 0.0.0.0, loopback (127.0.0.1), private (10.x.x.x, 172.16-31.x.x, 192.168.x.x), or link-local (169.254.x.x) addresses are allowed.`);
throw new Error(`FAMILIAROS_IPC_BIND host ${host} is not a private/local IPv4 address. Only 0.0.0.0, loopback (127.0.0.1), private (10.x.x.x, 172.16-31.x.x, 192.168.x.x), or link-local (169.254.x.x) addresses are allowed.`);
}
}
@ -203,8 +203,8 @@ function isPrivateOrLocalIpv4(host: string): boolean {
return false;
}
export function writeDiscoveryFile(endpoint: string, token: string): OpenPetsDiscoveryFile {
const discovery: OpenPetsDiscoveryFile = {
export function writeDiscoveryFile(endpoint: string, token: string): FamiliarOSDiscoveryFile {
const discovery: FamiliarOSDiscoveryFile = {
protocolVersion: openPetsIpcVersion,
protocol: openPetsIpcProtocol,
endpoint,
@ -225,11 +225,11 @@ export function writeDiscoveryFile(endpoint: string, token: string): OpenPetsDis
return discovery;
}
export function removeDiscoveryFile(discovery: OpenPetsDiscoveryFile | null): void {
export function removeDiscoveryFile(discovery: FamiliarOSDiscoveryFile | null): void {
if (!discovery) return;
const path = getDiscoveryFilePath();
try {
const current = JSON.parse(readFileSync(path, "utf8")) as Partial<OpenPetsDiscoveryFile>;
const current = JSON.parse(readFileSync(path, "utf8")) as Partial<FamiliarOSDiscoveryFile>;
if (current.pid !== discovery.pid || current.token !== discovery.token || current.endpoint !== discovery.endpoint) {
return;
}
@ -258,10 +258,10 @@ export function protectUnixSocket(endpoint: string): void {
function getSocketRuntimeDir(): string {
const xdg = process.platform === "linux" ? getSecureXdgRuntimeDir() : null;
if (xdg) {
return join(xdg, "openpets");
return join(xdg, "familiaros");
}
return join("/tmp", `openpets-${getUserIdForPath()}`);
return join("/tmp", `familiaros-${getUserIdForPath()}`);
}
function getSecureXdgRuntimeDir(): string | null {
@ -282,17 +282,17 @@ function getSecureXdgRuntimeDir(): string | null {
function ensurePrivateRuntimeDir(dir: string): void {
const stat = lstatSync(dir);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`OpenPets IPC runtime path is not a safe directory: ${dir}`);
throw new Error(`FamiliarOS IPC runtime path is not a safe directory: ${dir}`);
}
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
throw new Error(`OpenPets IPC runtime directory is not owned by the current user: ${dir}`);
throw new Error(`FamiliarOS IPC runtime directory is not owned by the current user: ${dir}`);
}
try { chmodSync(dir, 0o700); } catch { /* best effort */ }
const updated = lstatSync(dir);
if ((updated.mode & 0o777) !== 0o700) {
throw new Error(`OpenPets IPC runtime directory is not private: ${dir}`);
throw new Error(`FamiliarOS IPC runtime directory is not private: ${dir}`);
}
}

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
export const openPetsIpcProtocol = "openpets-ipc";
export const openPetsIpcProtocol = "familiaros-ipc";
export const openPetsIpcVersion = 1;
export const maxIpcMessageBytes = 16 * 1024;
export const transientDisplayMs = 4_000;
@ -26,19 +26,19 @@ export const allowedMemoryKinds = [
"note",
] as const;
export type OpenPetsReaction = typeof allowedReactions[number];
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 type FamiliarOSReaction = typeof allowedReactions[number];
export type FamiliarOSMemoryKind = typeof allowedMemoryKinds[number];
export type FamiliarOSIpcMethod = "hello" | "status" | "familiars.list" | "familiars.install" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "familiar.react" | "familiar.say" | "memory.list" | "memory.search" | "memory.store" | "memory.delete";
export interface OpenPetsIpcRequest {
export interface FamiliarOSIpcRequest {
readonly id: string;
readonly version: number;
readonly token: string;
readonly method: OpenPetsIpcMethod;
readonly method: FamiliarOSIpcMethod;
readonly params?: unknown;
}
export interface OpenPetsIpcResponse {
export interface FamiliarOSIpcResponse {
readonly id: string | null;
readonly ok: boolean;
readonly result?: unknown;
@ -52,7 +52,7 @@ export function createRequestId(): string {
return randomUUID();
}
export function parseIpcRequest(raw: string, expectedToken: string): OpenPetsIpcRequest {
export function parseIpcRequest(raw: string, expectedToken: string): FamiliarOSIpcRequest {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
@ -63,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" && parsed.method !== "memory.list" && parsed.method !== "memory.search" && parsed.method !== "memory.store" && parsed.method !== "memory.delete") {
if (parsed.method !== "hello" && parsed.method !== "status" && parsed.method !== "familiars.list" && parsed.method !== "familiars.install" && parsed.method !== "lease.acquire" && parsed.method !== "lease.heartbeat" && parsed.method !== "lease.release" && parsed.method !== "familiar.react" && parsed.method !== "familiar.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.");
}
@ -78,16 +78,16 @@ export function parseIpcRequest(raw: string, expectedToken: string): OpenPetsIpc
export function validateInstallPetId(value: unknown): string {
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value) || value === "builtin") {
throw new IpcProtocolError("invalid_params", "Invalid pet id.");
throw new IpcProtocolError("invalid_params", "Invalid familiar id.");
}
return value;
}
export function validateReaction(value: unknown): OpenPetsReaction {
if (typeof value !== "string" || !allowedReactions.includes(value as OpenPetsReaction)) {
throw new IpcProtocolError("invalid_params", "Invalid pet reaction.");
export function validateReaction(value: unknown): FamiliarOSReaction {
if (typeof value !== "string" || !allowedReactions.includes(value as FamiliarOSReaction)) {
throw new IpcProtocolError("invalid_params", "Invalid familiar reaction.");
}
return value as OpenPetsReaction;
return value as FamiliarOSReaction;
}
export function validateSayMessage(value: unknown): string {
@ -112,23 +112,23 @@ export function validateOptionalLeaseId(value: unknown): string | undefined {
export function validateRequestedPetId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Requested pet id must be a string.");
if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Requested familiar id must be a string.");
const trimmed = value.trim();
if (trimmed.length < 1) return undefined;
if (Buffer.byteLength(trimmed, "utf8") > 128 || /[\x00-\x1F\x7F/\\]/.test(trimmed)) {
throw new IpcProtocolError("invalid_params", "Requested pet id is outside CLI bounds.");
throw new IpcProtocolError("invalid_params", "Requested familiar id is outside CLI bounds.");
}
return trimmed;
}
export function validateMemoryKind(value: unknown): OpenPetsMemoryKind {
if (typeof value !== "string" || !allowedMemoryKinds.includes(value as OpenPetsMemoryKind)) {
export function validateMemoryKind(value: unknown): FamiliarOSMemoryKind {
if (typeof value !== "string" || !allowedMemoryKinds.includes(value as FamiliarOSMemoryKind)) {
throw new IpcProtocolError("invalid_params", "Invalid memory kind.");
}
return value as OpenPetsMemoryKind;
return value as FamiliarOSMemoryKind;
}
export function validateOptionalMemoryKind(value: unknown): OpenPetsMemoryKind | undefined {
export function validateOptionalMemoryKind(value: unknown): FamiliarOSMemoryKind | undefined {
if (value === undefined) return undefined;
return validateMemoryKind(value);
}
@ -198,11 +198,11 @@ export function validateMemoryLimit(value: unknown, fallback: number, max = 25):
return rounded;
}
export function okResponse(id: string | null, result: unknown): OpenPetsIpcResponse {
export function okResponse(id: string | null, result: unknown): FamiliarOSIpcResponse {
return { id, ok: true, result };
}
export function errorResponse(id: string | null, error: unknown): OpenPetsIpcResponse {
export function errorResponse(id: string | null, error: unknown): FamiliarOSIpcResponse {
if (error instanceof IpcProtocolError) {
return { id, ok: false, error: { code: error.code, message: error.message } };
}

View file

@ -1,19 +1,19 @@
import { randomBytes } from "node:crypto";
import net from "node:net";
import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-pet-controller.js";
import { getAppStateSnapshot, recordOpenPetsActivity } from "./app-state.js";
import { builtInPet } from "./built-in-pet.js";
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-pet-controller.js";
import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-familiar-controller.js";
import { getAppStateSnapshot, recordFamiliarOSActivity } from "./app-state.js";
import { builtInPet } from "./built-in-familiar.js";
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-familiar-controller.js";
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, 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";
import { cleanupUnixSocket, getDiscoveryFilePath, getIpcEndpointConfig, parseIpcEndpoint, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type IpcEndpoint, type IpcEndpointConfig, type FamiliarOSDiscoveryFile } from "./local-ipc-paths.js";
import { errorResponse, IpcProtocolError, isRecord, maxIpcMessageBytes, okResponse, parseIpcRequest, validateInstallPetId, validateMemoryId, validateMemoryImportance, validateMemoryLimit, validateMemoryQuery, validateMemoryTags, validateMemoryText, validateOptionalLeaseId, validateOptionalMemoryKind, validateReaction, validateRequestedPetId, validateSayMessage, type FamiliarOSIpcRequest } from "./local-ipc-protocol.js";
import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js";
import { installPet } from "./familiar-installation.js";
let ipcServer: net.Server | null = null;
let ipcDiscovery: OpenPetsDiscoveryFile | null = null;
let ipcDiscovery: FamiliarOSDiscoveryFile | null = null;
let leaseCleanupTimer: NodeJS.Timeout | null = null;
const leaseManager = new LeaseManager({
resolveTarget: resolveLeaseTarget,
@ -39,7 +39,7 @@ export async function startLocalIpcServer(): Promise<void> {
const server = net.createServer((socket) => handleSocket(socket, token, endpointConfig));
server.on("error", (error) => {
logError("ipc", "server error", error);
console.error("OpenPets local IPC server error.", error);
console.error("FamiliarOS local IPC server error.", error);
});
await new Promise<void>((resolve, reject) => {
@ -57,7 +57,7 @@ export async function startLocalIpcServer(): Promise<void> {
leaseCleanupTimer = setInterval(() => leaseManager.cleanupExpired(), 5_000);
leaseCleanupTimer.unref?.();
info("ipc", "server started", { endpointKind: endpointConfig.bindEndpoint.kind, bindEndpoint: formatEndpoint(endpointConfig.bindEndpoint), advertisedEndpoint: listeningEndpoint, discoveryPath: getDiscoveryFilePath() });
console.log(`OpenPets local IPC listening at ${listeningEndpoint}.`);
console.log(`FamiliarOS local IPC listening at ${listeningEndpoint}.`);
}
export function stopLocalIpcServer(): void {
@ -117,7 +117,7 @@ function handleSocket(socket: net.Socket, token: string, endpointConfig: IpcEndp
socket.on("error", (error) => {
if (isBenignSocketCloseError(error)) return;
logError("ipc", "client socket error", error);
console.error("OpenPets local IPC client socket error.", error);
console.error("FamiliarOS local IPC client socket error.", error);
});
}
@ -217,11 +217,11 @@ async function handleRawRequest(raw: string, token: string) {
}
}
async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
async function handleRequest(request: FamiliarOSIpcRequest): Promise<unknown> {
if (request.method === "hello") {
return {
ok: true,
protocol: "openpets-ipc",
protocol: "familiaros-ipc",
protocolVersion: 1,
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
};
@ -236,7 +236,7 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
return { ok: true, appRunning: true, ...lease };
}
const state = getAppStateSnapshot();
const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId) ?? builtInPet;
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId) ?? builtInPet;
return {
ok: true,
appRunning: true,
@ -255,26 +255,26 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
};
}
if (request.method === "pets.list") {
if (request.method === "familiars.list") {
const state = getAppStateSnapshot();
return {
ok: true,
pets: state.pets.installed.map((pet) => ({
id: pet.id,
displayName: pet.displayName,
builtIn: pet.builtIn,
broken: pet.broken === true,
familiars: state.familiars.installed.map((familiar) => ({
id: familiar.id,
displayName: familiar.displayName,
builtIn: familiar.builtIn,
broken: familiar.broken === true,
})),
defaultPetId: state.preferences.defaultPetId,
};
}
if (request.method === "pets.install") {
if (request.method === "familiars.install") {
const params = isRecord(request.params) ? request.params : {};
const petId = validateInstallPetId(params.petId);
const state = await installPet(petId);
const installed = state.pets.installed.find((pet) => pet.id === petId);
if (!installed) throw new IpcProtocolError("install_failed", "Pet install did not complete.");
const installed = state.familiars.installed.find((familiar) => familiar.id === petId);
if (!installed) throw new IpcProtocolError("install_failed", "Familiar install did not complete.");
return { ok: true, petId: installed.id, displayName: installed.displayName, installed: true };
}
@ -309,7 +309,7 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
debug("ipc", "memory list requested", { requestId: request.id, limit });
return {
ok: true,
memories: listOpenPetsMemories(limit),
memories: listFamiliarOSMemories(limit),
};
}
@ -321,7 +321,7 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
return {
ok: true,
query,
memories: searchOpenPetsMemories(query, limit),
memories: searchFamiliarOSMemories(query, limit),
};
}
@ -334,7 +334,7 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
debug("ipc", "memory store requested", { requestId: request.id, kind, hasTags: Array.isArray(tags) && tags.length > 0, importance });
return {
ok: true,
memory: storeOpenPetsMemory({
memory: storeFamiliarOSMemory({
text,
kind,
tags,
@ -350,25 +350,25 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
debug("ipc", "memory delete requested", { requestId: request.id, id });
return {
ok: true,
deleted: forgetOpenPetsMemory(id),
deleted: forgetFamiliarOSMemory(id),
id,
};
}
if (request.method === "pet.react") {
if (request.method === "familiar.react") {
const params = isRecord(request.params) ? request.params : {};
const reaction = validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
debug("ipc", "pet react requested", { requestId: request.id, reaction, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
debug("ipc", "familiar react requested", { requestId: request.id, reaction, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId };
const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction);
safeRecordOpenPetsActivity({ kind: "react", reaction, petId });
safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId };
}
const applied = applyExternalPetReaction(reaction);
safeRecordOpenPetsActivity({ kind: "react", reaction, petId });
safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
return { ok: true, reaction, shown: applied.shown, reason: applied.reason };
}
@ -377,21 +377,21 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
debug("ipc", "pet say requested", { requestId: request.id, reaction, messageLength: message.length, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
debug("ipc", "familiar say requested", { requestId: request.id, reaction, messageLength: message.length, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId };
const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction);
safeRecordOpenPetsActivity({ kind: "say", reaction, petId });
safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId };
}
const applied = applyExternalPetSay(message, reaction);
safeRecordOpenPetsActivity({ kind: "say", reaction, petId });
safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
return { ok: true, shown: applied.shown, reason: applied.reason, reaction };
}
function safeRecordOpenPetsActivity(activity: Parameters<typeof recordOpenPetsActivity>[0]): void {
function safeRecordFamiliarOSActivity(activity: Parameters<typeof recordFamiliarOSActivity>[0]): void {
try {
recordOpenPetsActivity(activity);
recordFamiliarOSActivity(activity);
} catch (error) {
debug("ipc", "activity record failed", { error: error instanceof Error ? error.message : String(error), kind: activity.kind, reaction: activity.reaction, petId: activity.petId });
}
@ -433,24 +433,24 @@ function resolveLeaseTarget(requestedPetId: string | undefined): { readonly targ
if (!safePetIdPattern.test(requestedPetId)) {
return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "invalid_pet_id" };
}
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === requestedPetId);
if (!pet) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_not_installed" };
if (pet.broken) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_broken" };
return { targetKind: "explicit", actualPetId: pet.id };
const familiar = getAppStateSnapshot().familiars.installed.find((candidate) => candidate.id === requestedPetId);
if (!familiar) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_not_installed" };
if (familiar.broken) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_broken" };
return { targetKind: "explicit", actualPetId: familiar.id };
}
function getCurrentDefaultPet(): { readonly id: string; readonly displayName: string } {
const pet = getCurrentDefaultPetWithFallback();
return { id: pet.id, displayName: pet.displayName };
const familiar = getCurrentDefaultPetWithFallback();
return { id: familiar.id, displayName: familiar.displayName };
}
function getCurrentDefaultPetWithFallback(): { readonly id: string; readonly displayName: string; readonly fallbackReason?: "default_broken_fallback_builtin" } {
const state = getAppStateSnapshot();
const configuredDefault = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
const configuredDefault = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
if (configuredDefault && !configuredDefault.broken) return configuredDefault;
return { ...builtInPet, fallbackReason: "default_broken_fallback_builtin" };
}
function getPetDisplayName(petId: string): string {
return getAppStateSnapshot().pets.installed.find((pet) => pet.id === petId)?.displayName ?? petId;
return getAppStateSnapshot().familiars.installed.find((familiar) => familiar.id === petId)?.displayName ?? petId;
}

View file

@ -4,24 +4,24 @@ import { appendFile } from "node:fs/promises";
import { dirname, join } from "node:path";
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogScope = "app" | "ipc" | "lease" | "pet.default" | "pet.agent" | "pet.window" | "plugin" | "state" | "tray" | "tts" | "ui";
export type LogScope = "app" | "ipc" | "lease" | "familiar.default" | "familiar.agent" | "familiar.window" | "plugin" | "state" | "tray" | "tts" | "ui";
type LogFields = Record<string, unknown>;
const levelPriority = { debug: 10, info: 20, warn: 30, error: 40 } as const satisfies Record<LogLevel, number>;
const maxLogBytes = 2 * 1024 * 1024;
let configuredLevel: LogLevel = normalizeLogLevel(process.env.OPENPETS_LOG_LEVEL) ?? "debug";
let configuredLevel: LogLevel = normalizeLogLevel(process.env.FAMILIAROS_LOG_LEVEL) ?? "debug";
let logFilePath: string | null = null;
let previousLogFilePath: string | null = null;
let mirrorToConsole = isDevRun() || process.env.OPENPETS_LOG_CONSOLE === "1";
let mirrorToConsole = isDevRun() || process.env.FAMILIAROS_LOG_CONSOLE === "1";
export function initializeLogger(): void {
try {
const logsDir = getLogsDir();
mkdirSync(logsDir, { recursive: true });
logFilePath = join(logsDir, "openpets.log");
previousLogFilePath = join(logsDir, "openpets.previous.log");
logFilePath = join(logsDir, "familiaros.log");
previousLogFilePath = join(logsDir, "familiaros.previous.log");
rotateCurrentLog(logFilePath, previousLogFilePath);
writeFileSync(logFilePath, "", { flag: "a" });
info("app", "logger initialized", { logFile: logFilePath, previousLogFile: previousLogFilePath, level: configuredLevel, console: mirrorToConsole });
@ -29,7 +29,7 @@ export function initializeLogger(): void {
const message = initError instanceof Error ? initError.message : String(initError);
logFilePath = null;
previousLogFilePath = null;
console.error("OpenPets file logger unavailable; continuing without log file.", message);
console.error("FamiliarOS file logger unavailable; continuing without log file.", message);
}
}
@ -87,7 +87,7 @@ function writeLog(level: LogLevel, scope: LogScope, message: string, fields: Log
if (!logFilePath) return;
void appendFile(logFilePath, line, "utf8").catch((appendError: unknown) => {
if (mirrorToConsole) console.error("Failed to write OpenPets log file.", appendError);
if (mirrorToConsole) console.error("Failed to write FamiliarOS log file.", appendError);
});
}
@ -96,10 +96,10 @@ function rotateCurrentLog(currentPath: string, previousPath: string): void {
try {
const current = statSync(currentPath);
if (current.size <= 0) return;
if (existsSync(previousPath)) renameSync(previousPath, join(dirname(previousPath), "openpets.previous.old.log"));
if (existsSync(previousPath)) renameSync(previousPath, join(dirname(previousPath), "familiaros.previous.old.log"));
renameSync(currentPath, previousPath);
} catch (rotationError: unknown) {
if (mirrorToConsole) console.error("Failed to rotate OpenPets log file.", rotationError);
if (mirrorToConsole) console.error("Failed to rotate FamiliarOS log file.", rotationError);
}
}
@ -109,7 +109,7 @@ function normalizeLogLevel(value: string | undefined): LogLevel | null {
}
function isDevRun(): boolean {
return process.env.OPENPETS_DEV === "1" || process.env.NODE_ENV === "development" || !app.isPackaged;
return process.env.FAMILIAROS_DEV === "1" || process.env.NODE_ENV === "development" || !app.isPackaged;
}
function formatFields(fields: LogFields | undefined): string {

View file

@ -5,13 +5,13 @@ import { delimiter, join, resolve } from "node:path";
import { getAppStateSnapshot, initializeAppState, releaseStartupInstallLock } from "./app-state.js";
import { createAppIcon } from "./assets.js";
import { setLocaleFromPreference } from "./i18n/index.js";
import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js";
import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-familiar-controller.js";
import { installAppLifecycle } from "./lifecycle.js";
import { debug, error as logError, getLogFilePath, info, initializeLogger, warn } from "./logger.js";
import { startLocalIpcServer } from "./local-ipc.js";
import { startDevPluginWatcher } from "./plugin-dev-watcher.js";
import { createElectronPluginHostCapabilities } from "./plugin-host-capabilities.js";
import { defaultPluginPetApi } from "./plugin-pet-api.js";
import { defaultPluginPetApi } from "./plugin-familiar-api.js";
import { initializePluginPlatformSettings } from "./plugin-platform-settings.js";
import { ElectronPluginJsHost } from "./plugin-js-host.js";
import { initializePluginService } from "./plugin-service.js";
@ -20,14 +20,14 @@ import { createAppTray, refreshTrayMenu } from "./tray.js";
import { checkForGitHubReleaseUpdate } from "./update-checker.js";
import { installInternalUiHandlers, installInternalUiProtocol } from "./windows.js";
// OpenPets does not store browser passwords, cookies, or encrypted app secrets.
// FamiliarOS does not store browser passwords, cookies, or encrypted app secrets.
// Keep Chromium/Electron from prompting for macOS Keychain or Linux keyring access
// during startup/profile initialization.
app.commandLine.appendSwitch("use-mock-keychain");
app.commandLine.appendSwitch("password-store", "basic");
// GNOME Wayland does not allow Electron apps to reliably control window
// z-order or absolute position, which breaks the desktop-pet contract: staying
// z-order or absolute position, which breaks the desktop-familiar contract: staying
// above normal windows and dragging to a user-chosen screen position. Prefer
// X11/Xwayland on Linux unless the user explicitly chooses another Ozone
// backend at launch.
@ -44,9 +44,9 @@ if (!gotSingleInstanceLock) {
app.whenReady().then(async () => {
initializeLogger();
app.setName("OpenPets");
app.setName("FamiliarOS");
if (process.platform === "win32") {
app.setAppUserModelId("dev.openpets.app");
app.setAppUserModelId("dev.familiaros.app");
}
info("app", "startup begin", { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, pid: process.pid, ozonePlatform: app.commandLine.getSwitchValue("ozone-platform") || null });
@ -65,12 +65,12 @@ if (!gotSingleInstanceLock) {
installDefaultPetDisplayHandlers({ onPromptRequested: openPromptWindow });
await startLocalIpcServer();
releaseStartupInstallLock();
const roots = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_ROOTS);
const paths = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_PATHS);
const roots = parseDevPluginEnv(process.env.FAMILIAROS_DEV_PLUGIN_ROOTS);
const paths = parseDevPluginEnv(process.env.FAMILIAROS_DEV_PLUGIN_PATHS);
const devPluginMode = roots.length > 0 || paths.length > 0;
initializePluginPlatformSettings(app.getPath("userData"));
const pluginCapabilities = createElectronPluginHostCapabilities(app.getPath("userData"));
const pluginService = initializePluginService(app.getPath("userData"), defaultPluginPetApi, app.getVersion(), new ElectronPluginJsHost(), writePluginRuntimeLog, process.env.OPENPETS_DISABLE_PLUGIN_CATALOG === "1" || devPluginMode, resolveBundledOfficialPluginRoots(), !devPluginMode, pluginCapabilities);
const pluginService = initializePluginService(app.getPath("userData"), defaultPluginPetApi, app.getVersion(), new ElectronPluginJsHost(), writePluginRuntimeLog, process.env.FAMILIAROS_DISABLE_PLUGIN_CATALOG === "1" || devPluginMode, resolveBundledOfficialPluginRoots(), !devPluginMode, pluginCapabilities);
// Wall-clock schedules (daily/cron/at) re-arm deterministically after sleep.
powerMonitor.on("resume", () => pluginService.runtime.resyncSchedules());
if (shouldOpenDefaultPetOnLaunch()) {
@ -92,11 +92,11 @@ if (!gotSingleInstanceLock) {
})().catch((error) => logError("app", "plugin service startup failed", error));
void checkForGitHubReleaseUpdate().then(() => refreshTrayMenu());
info("app", "startup complete", { logFile: getLogFilePath(), openDefaultPetOnLaunch: shouldOpenDefaultPetOnLaunch() });
console.log("OpenPets desktop shell ready.");
console.log("FamiliarOS desktop shell ready.");
}).catch((error: unknown) => {
releaseStartupInstallLock();
logError("app", "startup failed", error);
console.error("Failed to start OpenPets desktop shell.", error);
console.error("Failed to start FamiliarOS desktop shell.", error);
app.quit();
});
}

View file

@ -30,7 +30,7 @@ interface ServerSpawnConfig {
}
// Map of catalog entry IDs to spawn configurations for vanilla chat.
// These are the servers OpenPets can spawn and manage internally.
// These are the servers FamiliarOS can spawn and manage internally.
function getVanillaChatServerConfigs(homeDir: string): Record<string, ServerSpawnConfig> {
return {
filesystem: {
@ -126,7 +126,7 @@ class McpChatClientManager {
env: config.env,
});
const client = new Client({ name: "openpets-vanilla-chat", version: app.getVersion() });
const client = new Client({ name: "familiaros-vanilla-chat", version: app.getVersion() });
await client.connect(transport);
const toolsResult = await client.listTools();

View file

@ -144,7 +144,7 @@ function getSupportedPersistentServers(): readonly McpToolkitServerDefinition[]
const homeDir = app.getPath("home");
return [
{
id: "openpets-filesystem",
id: "familiaros-filesystem",
name: "Filesystem",
description: "Persistent filesystem access scoped to your home directory.",
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", homeDir],
@ -152,42 +152,42 @@ function getSupportedPersistentServers(): readonly McpToolkitServerDefinition[]
note: "Scoped to your home directory by default. Narrow it later if you want a tighter boundary.",
},
{
id: "openpets-playwright",
id: "familiaros-playwright",
name: "Playwright",
description: "Browser automation and browser validation tools.",
command: ["npx", "@playwright/mcp@latest"],
requiredRuntime: "npx",
},
{
id: "openpets-memory",
id: "familiaros-memory",
name: "Memory",
description: "Persistent project-memory tools for the host agent.",
command: ["npx", "-y", "@modelcontextprotocol/server-memory"],
requiredRuntime: "npx",
},
{
id: "openpets-context7",
id: "familiaros-context7",
name: "Context7 / Docs",
description: "Up-to-date library and framework docs lookup.",
command: ["npx", "-y", "@upstash/context7-mcp"],
requiredRuntime: "npx",
},
{
id: "openpets-fetch",
id: "familiaros-fetch",
name: "Fetch / Web",
description: "Lightweight web retrieval without full browser automation.",
command: ["uvx", "mcp-server-fetch"],
requiredRuntime: "uvx",
},
{
id: "openpets-sequential-thinking",
id: "familiaros-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",
id: "familiaros-browser-use",
name: "Browser Use",
description: "Higher-level browser task execution for agentic web work.",
command: ["uvx", "--from", "browser-use[cli]", "browser-use", "--mcp"],

View file

@ -3,11 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
import { dirname, join } from "node:path";
import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js";
import { applyExternalPetReaction, applyInternalPetMessage, showDefaultPet } from "./default-pet-controller.js";
import { applyExternalPetReaction, applyInternalPetMessage, showDefaultPet } from "./default-familiar-controller.js";
import { error, info, warn } from "./logger.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import { buildRelevantKnowledgeContext } from "./knowledge-store.js";
import { buildRelevantMemoryContext, capturePromptMemories } from "./openpets-memory.js";
import { buildRelevantMemoryContext, capturePromptMemories } from "./familiaros-memory.js";
import { getMcpChatClientManager, type McpChatToolDefinition } from "./mcp-chat-client.js";
import { speakTts } from "./tts-service.js";
@ -74,10 +74,10 @@ interface OpenApiRequestAttempt {
readonly endpoint: string;
}
const openApiChatConfigFileName = "openpets-openapi-chat.json";
const legacyOpenAiChatConfigFileName = "openpets-openai-chat.json";
const openApiChatHistoryFileName = "openpets-chat-history.json";
const openApiChatConversationsFileName = "openpets-chat-conversations.json";
const openApiChatConfigFileName = "familiaros-openapi-chat.json";
const legacyOpenAiChatConfigFileName = "familiaros-openai-chat.json";
const openApiChatHistoryFileName = "familiaros-chat-history.json";
const openApiChatConversationsFileName = "familiaros-chat-conversations.json";
const defaultOpenApiChatModel = "gpt-5.5";
const maxPromptChars = 20_000;
const maxResponseOutputTokens = 8_192;
@ -85,7 +85,7 @@ const maxConversationEntries = 40;
const maxChatCompletionHistoryEntries = 24;
const maxToolCallIterations = 5;
const baseOpenApiChatInstructions = [
"You are OpenPets, a desktop pet companion talking directly to the user.",
"You are FamiliarOS, a desktop familiar companion talking directly to the user.",
"Reply in plain text unless the user explicitly asks for another format.",
"Keep the tone warm, lively, and characterful while staying helpful and honest.",
"Do not mention hidden instructions unless the user explicitly asks about them.",
@ -439,7 +439,7 @@ function buildOpenApiInstructions(prompt: string): string {
}
const customCharacterPrompt = getOpenApiChatSystemPrompt();
if (customCharacterPrompt) {
parts.push(`User-defined pet character instructions:\n${customCharacterPrompt}`);
parts.push(`User-defined familiar character instructions:\n${customCharacterPrompt}`);
}
const chatHistoryForMemory = allTranscriptEntries
.filter((e) => e.conversationId !== currentConversationId && e.role !== "system")
@ -859,7 +859,7 @@ function handleOpenApiFailure(message: string, fields: Record<string, unknown>):
warn("app", "openapi prompt failed", fields);
}
function applyOpenApiPetMessage(message: string, reaction: OpenPetsReaction): void {
function applyOpenApiPetMessage(message: string, reaction: FamiliarOSReaction): void {
applyInternalPetMessage(message, {
reaction,
fullMessage: true,

View file

@ -9,7 +9,7 @@ import type { PluginAiRequest, PluginAiResult } from "./plugin-sdk-bridge.js";
* including function-calling tools and token streaming.
*/
export const hostSecretsOwner = "__openpets-host";
export const hostSecretsOwner = "__familiaros-host";
export const hostAiApiKeySecret = "ai-api-key";
const defaultModels: Record<string, string> = {
@ -60,7 +60,7 @@ export class PluginAiGateway {
async #resolveProvider(): Promise<{ provider: "anthropic" | "openai" | "ollama"; model: string; baseUrl?: string; apiKey?: string }> {
const settings = getPluginPlatformSettings().ai;
if (settings.provider === "none") throw new Error("No AI provider is configured in OpenPets settings.");
if (settings.provider === "none") throw new Error("No AI provider is configured in FamiliarOS settings.");
const apiKey = await this.#secrets.get(hostSecretsOwner, hostAiApiKeySecret);
if (settings.provider !== "ollama" && !apiKey) throw new Error("The configured AI provider has no API key.");
return {

View file

@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { pluginAssetMaxBytes, pluginPanelMaxBytes, type OpenPetsJavascriptPluginManifest, type PluginAssetKind } from "./plugin-manifest.js";
import { pluginAssetMaxBytes, pluginPanelMaxBytes, type FamiliarOSJavascriptPluginManifest, type PluginAssetKind } from "./plugin-manifest.js";
import { SUPPORTED_LOCALES } from "./i18n/catalog.js";
/** Per-locale size cap for bundled `locales/<locale>.json` catalogs. */
@ -16,7 +16,7 @@ export type DeclaredPluginFile = {
};
/** All bundled files a v3 plugin declares beyond manifest + entry. */
export function collectDeclaredPluginFiles(manifest: OpenPetsJavascriptPluginManifest): DeclaredPluginFile[] {
export function collectDeclaredPluginFiles(manifest: FamiliarOSJavascriptPluginManifest): DeclaredPluginFile[] {
if (manifest.manifestVersion !== 3) return [];
const files: DeclaredPluginFile[] = [];
const kinds: PluginAssetKind[] = ["icons", "images", "svgs", "sprites", "sounds"];
@ -34,7 +34,7 @@ export function collectDeclaredPluginFiles(manifest: OpenPetsJavascriptPluginMan
/**
* Install-time SVG sanitization (§6): strip script, foreignObject, event
* handler attributes, and external/javascript references. The output is the
* only SVG ever handed to a pet window.
* only SVG ever handed to a familiar window.
*/
export function sanitizeSvgText(svg: string): string {
let out = svg;
@ -83,7 +83,7 @@ export function preparePluginFileBytes(file: DeclaredPluginFile, bytes: Buffer):
}
/** Read all declared files from a plugin source folder, validated and prepared. */
export async function readDeclaredPluginFiles(manifest: OpenPetsJavascriptPluginManifest, realSourceFolder: string): Promise<Map<string, Buffer>> {
export async function readDeclaredPluginFiles(manifest: FamiliarOSJavascriptPluginManifest, realSourceFolder: string): Promise<Map<string, Buffer>> {
const out = new Map<string, Buffer>();
for (const file of collectDeclaredPluginFiles(manifest)) {
const filePath = join(realSourceFolder, file.relPath);
@ -120,14 +120,14 @@ export async function writeDeclaredPluginFiles(targetDir: string, files: Readonl
}
/** Resolve a declared asset name to its on-disk path inside an install dir. */
export function resolveDeclaredAssetPath(manifest: OpenPetsJavascriptPluginManifest, installPath: string, kind: PluginAssetKind, name: string): string {
export function resolveDeclaredAssetPath(manifest: FamiliarOSJavascriptPluginManifest, installPath: string, kind: PluginAssetKind, name: string): string {
const relPath = manifest.manifestVersion === 3 ? manifest.assets?.[kind]?.[name] : undefined;
if (!relPath) throw new Error(`Plugin asset is not declared: ${kind}/${name}`);
return join(installPath, relPath);
}
/** Resolve a declared panel name to its on-disk path inside an install dir. */
export function resolveDeclaredPanelPath(manifest: OpenPetsJavascriptPluginManifest, installPath: string, name: string): string {
export function resolveDeclaredPanelPath(manifest: FamiliarOSJavascriptPluginManifest, installPath: string, name: string): string {
const relPath = manifest.manifestVersion === 3 ? manifest.panels?.[name] : undefined;
if (!relPath) throw new Error(`Plugin panel is not declared: ${name}`);
return join(installPath, relPath);

View file

@ -2,7 +2,7 @@ import type { PluginBubbleCallbacks, PluginBubbleDescriptor, PluginBubbleDismiss
/**
* Bubble arbiter (§1.4/§1.5): with several enabled plugins all wanting the
* bubble, this layer decides who is showing. Two slots per pet:
* bubble, this layer decides who is showing. Two slots per familiar:
*
* - transient the rotating bubble; a priority queue with do-not-interrupt
* for sticky/urgent content and coalescing of identical back-to-back text.
@ -22,7 +22,7 @@ export type ActiveBubble = {
};
export interface PetBubbleSink {
/** Render (or clear with null) a slot's content on the pet surface. */
/** Render (or clear with null) a slot's content on the familiar surface. */
present(slot: ArbiterSlot, content: ActiveBubble | null): void;
}
@ -73,7 +73,7 @@ export class PetBubbleArbiter {
};
}
/** Routed from the pet window when a rendered bubble fires an action. */
/** Routed from the familiar window when a rendered bubble fires an action. */
handleAction(token: string, actionId: string): void {
const entry = this.#entries.get(token);
if (!entry || entry.dismissed) return;
@ -82,14 +82,14 @@ export class PetBubbleArbiter {
if (!action || action.dismissesBubble) this.#finish(entry, "click");
}
/** Routed from the pet window when a rendered bubble's input submits. */
/** Routed from the familiar window when a rendered bubble's input submits. */
handleSubmit(token: string, values: Record<string, string | number>): void {
const entry = this.#entries.get(token);
if (!entry || entry.dismissed) return;
entry.callbacks.onSubmit(values);
}
/** Routed from the pet window when the user click-dismisses a bubble. */
/** Routed from the familiar window when the user click-dismisses a bubble. */
handleDismissed(token: string): void {
const entry = this.#entries.get(token);
if (entry) this.#finish(entry, "click");

View file

@ -1,12 +1,12 @@
import { canonicalizePluginPermissions, type PluginIcon, type PluginPermission, type KnownPluginRuntime } from "./plugin-manifest.js";
export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly icon?: PluginIcon; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minOpenPetsVersion?: string };
export type PluginCatalogEntryV2 = Omit<PluginCatalogEntry, "runtime"> & { readonly runtime: KnownPluginRuntime; readonly sdkVersion?: string; readonly maxOpenPetsVersion?: string; readonly disabled?: boolean; readonly deprecated?: boolean; readonly statusReason?: string; readonly network?: { readonly hosts: readonly string[] } };
export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly icon?: PluginIcon; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minFamiliarOSVersion?: string };
export type PluginCatalogEntryV2 = Omit<PluginCatalogEntry, "runtime"> & { readonly runtime: KnownPluginRuntime; readonly sdkVersion?: string; readonly maxFamiliarOSVersion?: string; readonly disabled?: boolean; readonly deprecated?: boolean; readonly statusReason?: string; readonly network?: { readonly hosts: readonly string[] } };
export type PluginCatalog = { readonly version: 1; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntry[] } | { readonly version: 2; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntryV2[] };
const catalogFields = new Set(["version", "generatedAt", "plugins"]);
const entryFields = new Set(["id", "name", "version", "description", "runtime", "icon", "permissions", "downloadUrl", "sha256", "minOpenPetsVersion"]);
const entryFieldsV2 = new Set([...entryFields, "sdkVersion", "maxOpenPetsVersion", "disabled", "deprecated", "statusReason", "network"]);
const entryFields = new Set(["id", "name", "version", "description", "runtime", "icon", "permissions", "downloadUrl", "sha256", "minFamiliarOSVersion"]);
const entryFieldsV2 = new Set([...entryFields, "sdkVersion", "maxFamiliarOSVersion", "disabled", "deprecated", "statusReason", "network"]);
const idPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
const versionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const shaPattern = /^[0-9a-f]{64}$/;
@ -27,14 +27,14 @@ export function validatePluginCatalog(input: unknown): PluginCatalog {
if (seen.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
seen.add(id);
const permissions = canonicalizePluginPermissions(entry.permissions);
const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), icon: normalizeIcon(entry.icon), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minOpenPetsVersion: entry.minOpenPetsVersion === undefined ? undefined : requireString(entry.minOpenPetsVersion, "minOpenPetsVersion", 1, 80, versionPattern) };
const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), icon: normalizeIcon(entry.icon), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minFamiliarOSVersion: entry.minFamiliarOSVersion === undefined ? undefined : requireString(entry.minFamiliarOSVersion, "minFamiliarOSVersion", 1, 80, versionPattern) };
if (input.version === 1) return base;
const hasNetworkPermission = permissions.includes("network");
if (base.runtime === "javascript" && entry.sdkVersion === undefined) throw new Error("Invalid plugin catalog sdkVersion.");
if (base.runtime === "javascript" && permissions.includes("timer")) throw new Error("Invalid plugin catalog permissions.");
if (hasNetworkPermission && entry.network === undefined) throw new Error("Invalid plugin catalog network.hosts.");
if (!hasNetworkPermission && entry.network !== undefined) throw new Error("Invalid plugin catalog network.hosts.");
return { ...base, sdkVersion: entry.sdkVersion === undefined ? undefined : requireString(entry.sdkVersion, "sdkVersion", 1, 80, versionPattern), maxOpenPetsVersion: entry.maxOpenPetsVersion === undefined ? undefined : requireString(entry.maxOpenPetsVersion, "maxOpenPetsVersion", 1, 80, versionPattern), disabled: entry.disabled === undefined ? undefined : requireBoolean(entry.disabled, "disabled"), deprecated: entry.deprecated === undefined ? undefined : requireBoolean(entry.deprecated, "deprecated"), statusReason: entry.statusReason === undefined ? undefined : requireString(entry.statusReason, "statusReason", 1, 500), network: normalizeNetwork(entry.network) };
return { ...base, sdkVersion: entry.sdkVersion === undefined ? undefined : requireString(entry.sdkVersion, "sdkVersion", 1, 80, versionPattern), maxFamiliarOSVersion: entry.maxFamiliarOSVersion === undefined ? undefined : requireString(entry.maxFamiliarOSVersion, "maxFamiliarOSVersion", 1, 80, versionPattern), disabled: entry.disabled === undefined ? undefined : requireBoolean(entry.disabled, "disabled"), deprecated: entry.deprecated === undefined ? undefined : requireBoolean(entry.deprecated, "deprecated"), statusReason: entry.statusReason === undefined ? undefined : requireString(entry.statusReason, "statusReason", 1, 500), network: normalizeNetwork(entry.network) };
});
return { version: input.version, generatedAt: String(input.generatedAt), plugins } as PluginCatalog;
}

View file

@ -1,7 +1,7 @@
import { validatePluginCatalog, type PluginCatalog, type PluginCatalogEntry, type PluginCatalogEntryV2 } from "./plugin-catalog-validation.js";
export const pluginCatalogUrl = "https://openpets.dev/plugins/catalog.v2.json";
export const pluginCatalogV1Url = "https://openpets.dev/plugins/catalog.v1.json";
export const pluginCatalogUrl = "https://familiaros.dev/plugins/catalog.v2.json";
export const pluginCatalogV1Url = "https://familiaros.dev/plugins/catalog.v1.json";
export const pluginCatalogV2Url = pluginCatalogUrl;
const maxCatalogBytes = 2 * 1024 * 1024;
const catalogTimeoutMs = 15_000;

View file

@ -1,4 +1,4 @@
import type { OpenPetsPluginManifest, PluginConfigField } from "./plugin-manifest.js";
import type { FamiliarOSPluginManifest, PluginConfigField } from "./plugin-manifest.js";
import { isValidUserSoundId } from "./plugin-user-sound-store.js";
export type PluginConfigValue = string | number | boolean | string[] | Array<Record<string, unknown>> | { kind: "user-sound"; id: string; name?: string } | null;
@ -7,7 +7,7 @@ export type PluginConfig = Record<string, PluginConfigValue>;
export type PluginConfigValidationError = { path: string; code: string; message: string };
export type PluginConfigValidationResult = { ok: true; config: PluginConfig; errors: [] } | { ok: false; errors: PluginConfigValidationError[] };
export function getPluginDefaultConfig(manifest: OpenPetsPluginManifest): PluginConfig {
export function getPluginDefaultConfig(manifest: FamiliarOSPluginManifest): PluginConfig {
const config: PluginConfig = {};
for (const [key, field] of configSchemaEntries(manifest)) {
if (isValidDefault(field)) config[key] = field.default;
@ -15,15 +15,15 @@ export function getPluginDefaultConfig(manifest: OpenPetsPluginManifest): Plugin
return config;
}
export function validatePluginConfigReplacement(manifest: OpenPetsPluginManifest, value: unknown): PluginConfigValidationResult {
export function validatePluginConfigReplacement(manifest: FamiliarOSPluginManifest, value: unknown): PluginConfigValidationResult {
return validateConfigObject(manifest, value, { rejectUnknown: true, applyDefaults: false });
}
export function getEffectivePluginConfig(manifest: OpenPetsPluginManifest, persisted: unknown): PluginConfigValidationResult {
export function getEffectivePluginConfig(manifest: FamiliarOSPluginManifest, persisted: unknown): PluginConfigValidationResult {
return validateConfigObject(manifest, persisted, { rejectUnknown: false, applyDefaults: true });
}
export function resolvePluginNumericConfig(manifest: OpenPetsPluginManifest, persisted: unknown, fieldName: string, options: { min?: number } = {}): number {
export function resolvePluginNumericConfig(manifest: FamiliarOSPluginManifest, persisted: unknown, fieldName: string, options: { min?: number } = {}): number {
const schema = manifest.configSchema;
const field = schema && Object.prototype.hasOwnProperty.call(schema, fieldName) ? schema[fieldName] : undefined;
if (!field || field.type !== "number") throw new Error(`Plugin numeric config ${fieldName} must reference a number config field.`);
@ -36,7 +36,7 @@ export function resolvePluginNumericConfig(manifest: OpenPetsPluginManifest, per
return value;
}
export function resolvePluginStringConfig(manifest: OpenPetsPluginManifest, persisted: unknown, fieldName: string, allowedType: "text" | "select"): string {
export function resolvePluginStringConfig(manifest: FamiliarOSPluginManifest, persisted: unknown, fieldName: string, allowedType: "text" | "select"): string {
const schema = manifest.configSchema;
const field = schema && Object.prototype.hasOwnProperty.call(schema, fieldName) ? schema[fieldName] : undefined;
if (!field || field.type !== allowedType) throw new Error(`Plugin string config reference must point to a ${allowedType} config field.`);
@ -48,7 +48,7 @@ export function resolvePluginStringConfig(manifest: OpenPetsPluginManifest, pers
return value;
}
function validateConfigObject(manifest: OpenPetsPluginManifest, value: unknown, options: { rejectUnknown: boolean; applyDefaults: boolean }): PluginConfigValidationResult {
function validateConfigObject(manifest: FamiliarOSPluginManifest, value: unknown, options: { rejectUnknown: boolean; applyDefaults: boolean }): PluginConfigValidationResult {
const errors: PluginConfigValidationError[] = [];
if (!isPlainRecord(value)) return { ok: false, errors: [{ path: "$", code: "invalid_config", message: "Plugin config must be a plain object." }] };
const config: PluginConfig = options.applyDefaults ? getPluginDefaultConfig(manifest) : {};
@ -110,7 +110,7 @@ function looksLikeFilesystemPath(value: string): boolean {
return value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\") || value.includes("/") || value.startsWith("file:");
}
function configSchemaEntries(manifest: OpenPetsPluginManifest): Array<[string, PluginConfigField]> {
function configSchemaEntries(manifest: FamiliarOSPluginManifest): Array<[string, PluginConfigField]> {
return Object.entries(manifest.configSchema ?? {}).sort(([a], [b]) => a.localeCompare(b));
}

View file

@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
import { join } from "node:path";
import { debug, error as logError, info } from "./logger.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME } from "./plugin-manifest.js";
import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME } from "./plugin-manifest.js";
import type { PluginService } from "./plugin-service.js";
/**
@ -24,7 +24,7 @@ export function startDevPluginWatcher(service: PluginService, roots: readonly st
if (existing) clearTimeout(existing);
pending.set(sourceFolder, setTimeout(() => {
pending.delete(sourceFolder);
if (!existsSync(join(sourceFolder, OPENPETS_PLUGIN_MANIFEST_FILENAME))) return;
if (!existsSync(join(sourceFolder, FAMILIAROS_PLUGIN_MANIFEST_FILENAME))) return;
info("plugin", "dev hot reload", { sourceFolder });
void service.loadLocalPath(sourceFolder, { autoApprove: true }).then((result) => {
if (!result.ok) logError("plugin", "dev hot reload failed", new Error(result.error));
@ -55,7 +55,7 @@ export function startDevPluginWatcher(service: PluginService, roots: readonly st
const rootWatcher = watch(root, { persistent: false }, (_event, fileName) => {
if (!fileName) return;
const candidate = join(root, String(fileName));
if (existsSync(join(candidate, OPENPETS_PLUGIN_MANIFEST_FILENAME))) {
if (existsSync(join(candidate, FAMILIAROS_PLUGIN_MANIFEST_FILENAME))) {
watchPluginFolder(candidate);
scheduleReload(candidate);
}

View file

@ -6,7 +6,7 @@ import { debug } from "./logger.js";
/**
* The senses bus host source (§3): a curated, read-only event stream fed by
* the pet windows, powerMonitor, screen, and small pollers. Plugins subscribe
* the familiar windows, powerMonitor, screen, and small pollers. Plugins subscribe
* through the SDK bridge; the bridge enforces the permission and the bounded
* event-name set. Hard privacy line (§3.1): nothing here ever carries
* keystrokes, screen contents, other apps' window titles, or clipboard data.
@ -40,7 +40,7 @@ export function emitPluginEvent(event: string, payload: Record<string, unknown>)
try { emitter.emit(event, payload); } catch { /* subscriber errors are isolated upstream */ }
}
const petWindowEventNames = new Set(["pet:clicked", "pet:doubleClicked", "pet:dragStart", "pet:dragEnd", "pet:hover", "pet:drop"]);
const petWindowEventNames = new Set(["familiar:clicked", "familiar:doubleClicked", "familiar:dragStart", "familiar:dragEnd", "familiar:hover", "familiar:drop"]);
let nextDropFileId = 0;
const droppedFileTexts = new Map<string, { text: string; expiresAt: number }>();
@ -52,15 +52,15 @@ export function readDroppedFileText(fileId: string): string | undefined {
return entry.text;
}
/** Pet windows publish their senses through this (clicks, hovers, drops). */
/** Familiar windows publish their senses through this (clicks, hovers, drops). */
export function publishPluginPetEvent(petId: string, name: string, payload: Record<string, unknown>): void {
if (!petWindowEventNames.has(name)) return;
if (name === "pet:drop") {
if (name === "familiar:drop") {
const kind = payload.kind === "files" ? "files" : "text";
if (kind === "text") {
const text = typeof payload.text === "string" ? payload.text.slice(0, 256 * 1024) : "";
if (!text) return;
emitPluginEvent("pet:drop", { kind: "text", text, petId });
emitPluginEvent("familiar:drop", { kind: "text", text, petId });
return;
}
const dropped = Array.isArray(payload.droppedFiles) ? payload.droppedFiles.slice(0, 4) : [];
@ -73,7 +73,7 @@ export function publishPluginPetEvent(petId: string, name: string, payload: Reco
return [{ fileId, name: typeof record.name === "string" ? record.name.slice(0, 200) : "file", sizeBytes: Number(record.sizeBytes) || text.length }];
});
if (files.length === 0) return;
emitPluginEvent("pet:drop", { kind: "files", files, petId });
emitPluginEvent("familiar:drop", { kind: "files", files, petId });
return;
}
emitPluginEvent(name, { petId });

View file

@ -1,11 +1,11 @@
import { getAppStateSnapshot, recordOpenPetsActivity } from "./app-state.js";
import { applyExternalPetMoveBy, applyExternalPetMoveToHome, applyExternalPetReaction, applyExternalPetSay, applyExternalPetWander, type PetMoveOptions, type PetReactionOptions, type PetWanderOptions } from "./default-pet-controller.js";
import { getAppStateSnapshot, recordFamiliarOSActivity } from "./app-state.js";
import { applyExternalPetMoveBy, applyExternalPetMoveToHome, applyExternalPetReaction, applyExternalPetSay, applyExternalPetWander, type PetMoveOptions, type PetReactionOptions, type PetWanderOptions } from "./default-familiar-controller.js";
import { debug } from "./logger.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
export interface PluginPetApi {
speak(message: string): void | Promise<void>;
react(reaction: OpenPetsReaction, options?: PetReactionOptions): void | Promise<void>;
react(reaction: FamiliarOSReaction, options?: PetReactionOptions): void | Promise<void>;
moveBy(options: PetMoveOptions): void | Promise<void>;
wander(options: PetWanderOptions): void | Promise<void>;
moveToHome(): void | Promise<void>;
@ -25,10 +25,10 @@ export const defaultPluginPetApi: PluginPetApi = {
moveToHome() { applyExternalPetMoveToHome(); },
};
function recordPluginPetActivity(activity: { readonly kind: "say"; readonly reaction?: undefined } | { readonly kind: "react"; readonly reaction: OpenPetsReaction }): void {
function recordPluginPetActivity(activity: { readonly kind: "say"; readonly reaction?: undefined } | { readonly kind: "react"; readonly reaction: FamiliarOSReaction }): void {
try {
const state = getAppStateSnapshot();
recordOpenPetsActivity({ ...activity, petId: state.preferences.defaultPetId });
recordFamiliarOSActivity({ ...activity, petId: state.preferences.defaultPetId });
} catch (error) {
debug("plugin", "activity record failed", { error: error instanceof Error ? error.message : String(error), kind: activity.kind, reaction: activity.reaction });
}

View file

@ -1,21 +1,21 @@
import { BrowserWindow } from "electron";
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
import { applyExternalPetReaction, applyExternalPetStatusReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-pet-controller.js";
import { applyExternalPetReaction, applyExternalPetStatusReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-familiar-controller.js";
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { builtInPet } from "./built-in-familiar.js";
import { debug, info } from "./logger.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type WindowAccessor } from "./pet-motion-engine.js";
import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./pet-window.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type WindowAccessor } from "./familiar-motion-engine.js";
import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./familiar-window.js";
import { PetBubbleArbiter, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
import { resolveReactionSpriteState } from "./reaction-animation-mapping.js";
import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-bridge.js";
/**
* Multi-pet registry (§4): addressable pet handles for plugins. "default" is
* the user's default pet; plugin-spawned pets get their own windows, bubble
* Multi-familiar registry (§4): addressable familiar handles for plugins. "default" is
* the user's default familiar; plugin-spawned familiars get their own windows, bubble
* arbiters, and liveness loops, and are torn down with their owning plugin.
*/
@ -35,7 +35,7 @@ type SpawnedPet = {
};
const spawnedPets = new Map<string, SpawnedPet>();
const changeListeners = new Set<(pets: PluginPetInfo[]) => void>();
const changeListeners = new Set<(familiars: PluginPetInfo[]) => void>();
const tickSubscribers = new Map<string, Set<(dtMs: number) => void>>();
const tickLoops = new Map<string, { timer: NodeJS.Timeout; lastAt: number }>();
const tickIntervalMs = 100;
@ -45,54 +45,54 @@ let defaultAnimation = "idle";
function windowAccessor(petHandleId: string): WindowAccessor {
if (petHandleId === "default") return getDefaultPetWindowForPlugins;
return () => {
const pet = spawnedPets.get(petHandleId);
return pet?.window && !pet.window.isDestroyed() ? pet.window : null;
const familiar = spawnedPets.get(petHandleId);
return familiar?.window && !familiar.window.isDestroyed() ? familiar.window : null;
};
}
function requireWindow(petHandleId: string): BrowserWindow {
const window = windowAccessor(petHandleId)();
if (!window) throw new Error(`Pet is not available: ${petHandleId}`);
if (!window) throw new Error(`Familiar is not available: ${petHandleId}`);
return window;
}
function notifyChange(): void {
const pets = listPluginPets();
for (const listener of changeListeners) { try { listener(pets); } catch { /* listener errors are isolated */ } }
const familiars = listPluginPets();
for (const listener of changeListeners) { try { listener(familiars); } catch { /* listener errors are isolated */ } }
}
export function listPluginPets(): PluginPetInfo[] {
const state = getAppStateSnapshot();
const defaultName = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId)?.displayName ?? "Default pet";
const defaultName = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId)?.displayName ?? "Default familiar";
const out: PluginPetInfo[] = [{ id: "default", name: defaultName, kind: "default", visible: getDefaultPetWindowForPlugins()?.isVisible() ?? false }];
for (const pet of spawnedPets.values()) {
out.push({ id: pet.handleId, name: pet.name, kind: "plugin", visible: pet.window !== null && !pet.window.isDestroyed() && pet.window.isVisible() });
for (const familiar of spawnedPets.values()) {
out.push({ id: familiar.handleId, name: familiar.name, kind: "plugin", visible: familiar.window !== null && !familiar.window.isDestroyed() && familiar.window.isVisible() });
}
return out;
}
export function onPluginPetsChange(listener: (pets: PluginPetInfo[]) => void): () => void {
export function onPluginPetsChange(listener: (familiars: PluginPetInfo[]) => void): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
function refreshSpawnedPet(pet: SpawnedPet): void {
if (!pet.window || pet.window.isDestroyed()) return;
void loadExplicitPetContent(pet.window, pet.petId, null, pet.statusReaction, undefined, pet.scale, pet.bubbles.transient || pet.bubbles.pinned ? pet.bubbles : null).then(() => {
if (pet.window && !pet.window.isDestroyed() && pet.spriteOverride) setPetSpriteOverride(pet.window, pet.spriteOverride);
function refreshSpawnedPet(familiar: SpawnedPet): void {
if (!familiar.window || familiar.window.isDestroyed()) return;
void loadExplicitPetContent(familiar.window, familiar.petId, null, familiar.statusReaction, undefined, familiar.scale, familiar.bubbles.transient || familiar.bubbles.pinned ? familiar.bubbles : null).then(() => {
if (familiar.window && !familiar.window.isDestroyed() && familiar.spriteOverride) setPetSpriteOverride(familiar.window, familiar.spriteOverride);
});
}
export async function spawnPluginPet(opts: { pluginId: string; petId: string; name?: string; position?: Point }): Promise<string> {
const state = getAppStateSnapshot();
const installed = state.pets.installed.find((pet) => pet.id === opts.petId);
if (!installed || installed.broken || installed.id === builtInPet.id) throw new Error(`Pet is not installed or cannot be spawned: ${opts.petId}`);
const handleId = `plugin-pet-${++nextSpawnId}`;
const installed = state.familiars.installed.find((familiar) => familiar.id === opts.petId);
if (!installed || installed.broken || installed.id === builtInPet.id) throw new Error(`Familiar is not installed or cannot be spawned: ${opts.petId}`);
const handleId = `plugin-familiar-${++nextSpawnId}`;
const base = getDefaultPetInitialPosition(defaultPetWindowSize);
const offset = (spawnedPets.size + 1) * 60;
const position = clampToVisibleWorkArea(opts.position ?? { x: base.x - offset, y: base.y }, defaultPetWindowSize);
const pet: SpawnedPet = {
const familiar: SpawnedPet = {
handleId,
ownerPluginId: opts.pluginId,
petId: opts.petId,
@ -108,73 +108,73 @@ export async function spawnPluginPet(opts: { pluginId: string; petId: string; na
};
const sink: PetBubbleSink = {
present(slot, content) {
pet.bubbles = slot === "pinned" ? { ...pet.bubbles, pinned: content } : { ...pet.bubbles, transient: content };
refreshSpawnedPet(pet);
familiar.bubbles = slot === "pinned" ? { ...familiar.bubbles, pinned: content } : { ...familiar.bubbles, transient: content };
refreshSpawnedPet(familiar);
},
};
(pet as { arbiter: PetBubbleArbiter }).arbiter = new PetBubbleArbiter(sink);
(familiar as { arbiter: PetBubbleArbiter }).arbiter = new PetBubbleArbiter(sink);
const window = createAgentPetWindow({
petId: opts.petId,
displayName: pet.name,
scale: pet.scale,
displayName: familiar.name,
scale: familiar.scale,
position,
display: null,
badge: null,
onCloseRequested: () => closePluginPet(opts.pluginId, handleId, true),
onBubbleDismissed: (token) => { if (PetBubbleArbiter.isArbiterToken(token)) pet.arbiter.handleDismissed(token); },
onBubbleAction: (token, actionId) => pet.arbiter.handleAction(token, actionId),
onBubbleSubmit: (token, values) => pet.arbiter.handleSubmit(token, values),
onBubbleDismissed: (token) => { if (PetBubbleArbiter.isArbiterToken(token)) familiar.arbiter.handleDismissed(token); },
onBubbleAction: (token, actionId) => familiar.arbiter.handleAction(token, actionId),
onBubbleSubmit: (token, values) => familiar.arbiter.handleSubmit(token, values),
onPetEvent: (name, payload) => publishPluginPetEvent(handleId, name, payload),
});
pet.window = window;
window.once("closed", () => { pet.window = null; });
familiar.window = window;
window.once("closed", () => { familiar.window = null; });
window.showInactive();
spawnedPets.set(handleId, pet);
info("plugin", "plugin pet spawned", { handleId, petId: opts.petId, pluginId: opts.pluginId });
spawnedPets.set(handleId, familiar);
info("plugin", "plugin familiar spawned", { handleId, petId: opts.petId, pluginId: opts.pluginId });
notifyChange();
return handleId;
}
export async function closePluginPet(pluginId: string, petHandleId: string, force = false): Promise<void> {
const pet = spawnedPets.get(petHandleId);
if (!pet) return;
if (!force && pet.ownerPluginId !== pluginId) throw new Error("Plugins may only close pets they spawned.");
const familiar = spawnedPets.get(petHandleId);
if (!familiar) return;
if (!force && familiar.ownerPluginId !== pluginId) throw new Error("Plugins may only close familiars they spawned.");
spawnedPets.delete(petHandleId);
motionStop(petHandleId);
stopTicker(petHandleId);
if (pet.reactionRevert) clearTimeout(pet.reactionRevert);
pet.arbiter.clearPlugin(pet.ownerPluginId);
if (pet.window && !pet.window.isDestroyed()) pet.window.destroy();
info("plugin", "plugin pet closed", { petHandleId, pluginId });
if (familiar.reactionRevert) clearTimeout(familiar.reactionRevert);
familiar.arbiter.clearPlugin(familiar.ownerPluginId);
if (familiar.window && !familiar.window.isDestroyed()) familiar.window.destroy();
info("plugin", "plugin familiar closed", { petHandleId, pluginId });
notifyChange();
}
/** Tear down everything a plugin spawned or animated (plugin stop/reload). */
export function clearPluginPetsForPlugin(pluginId: string): void {
for (const pet of [...spawnedPets.values()]) {
if (pet.ownerPluginId === pluginId) void closePluginPet(pluginId, pet.handleId, true);
for (const familiar of [...spawnedPets.values()]) {
if (familiar.ownerPluginId === pluginId) void closePluginPet(pluginId, familiar.handleId, true);
}
defaultPetBubbleArbiter.clearPlugin(pluginId);
}
export function showPluginPet(petHandleId: string): void {
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ showDefaultPet }) => showDefaultPet()).catch(() => undefined); notifyChange(); return; }
if (petHandleId === "default") { import("./default-familiar-controller.js").then(({ showDefaultPet }) => showDefaultPet()).catch(() => undefined); notifyChange(); return; }
requireWindow(petHandleId).showInactive();
notifyChange();
}
export function hidePluginPet(petHandleId: string): void {
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ hideDefaultPet }) => hideDefaultPet()).catch(() => undefined); notifyChange(); return; }
if (petHandleId === "default") { import("./default-familiar-controller.js").then(({ hideDefaultPet }) => hideDefaultPet()).catch(() => undefined); notifyChange(); return; }
requireWindow(petHandleId).hide();
notifyChange();
}
export function reactPluginPet(petHandleId: string, reaction: OpenPetsReaction, options: { readonly showMessage?: boolean } = {}): void {
export function reactPluginPet(petHandleId: string, reaction: FamiliarOSReaction, options: { readonly showMessage?: boolean } = {}): void {
if (petHandleId === "default") { applyExternalPetReaction(reaction, options); defaultAnimation = String(reaction); return; }
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
applySpawnedPetAnimation(pet, { kind: "reaction", reaction });
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimation(familiar, { kind: "reaction", reaction });
}
export function setPluginPetAnimation(petHandleId: string, spec: PluginAnimationSpec): void {
@ -190,48 +190,48 @@ export function setPluginPetAnimation(petHandleId: string, spec: PluginAnimation
}
return;
}
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
applySpawnedPetAnimation(pet, spec);
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimation(familiar, spec);
}
function applySpawnedPetAnimation(pet: SpawnedPet, spec: PluginAnimationSpec): void {
if (!pet.window || pet.window.isDestroyed()) throw new Error(`Pet is not available: ${pet.handleId}`);
if (pet.reactionRevert) { clearTimeout(pet.reactionRevert); pet.reactionRevert = null; }
function applySpawnedPetAnimation(familiar: SpawnedPet, spec: PluginAnimationSpec): void {
if (!familiar.window || familiar.window.isDestroyed()) throw new Error(`Familiar is not available: ${familiar.handleId}`);
if (familiar.reactionRevert) { clearTimeout(familiar.reactionRevert); familiar.reactionRevert = null; }
if (spec.kind === "reaction") {
pet.spriteOverride = null;
setPetSpriteOverride(pet.window, null);
familiar.spriteOverride = null;
setPetSpriteOverride(familiar.window, null);
const spriteState = resolveReactionSpriteState(spec.reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
setPetReactionState(pet.window, spriteState);
pet.currentAnimation = String(spec.reaction);
pet.reactionRevert = setTimeout(() => {
pet.reactionRevert = null;
if (pet.window && !pet.window.isDestroyed()) setPetReactionState(pet.window, "idle");
pet.currentAnimation = "idle";
setPetReactionState(familiar.window, spriteState);
familiar.currentAnimation = String(spec.reaction);
familiar.reactionRevert = setTimeout(() => {
familiar.reactionRevert = null;
if (familiar.window && !familiar.window.isDestroyed()) setPetReactionState(familiar.window, "idle");
familiar.currentAnimation = "idle";
}, 4_000);
pet.reactionRevert.unref?.();
familiar.reactionRevert.unref?.();
} else {
pet.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
setPetSpriteOverride(pet.window, pet.spriteOverride);
pet.currentAnimation = "sprite";
familiar.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
setPetSpriteOverride(familiar.window, familiar.spriteOverride);
familiar.currentAnimation = "sprite";
}
}
export function setPluginPetScale(petHandleId: string, scale: number): void {
setPetWindowScale(requireWindow(petHandleId), scale);
const pet = spawnedPets.get(petHandleId);
if (pet) pet.scale = scale as PetScaleValue;
const familiar = spawnedPets.get(petHandleId);
if (familiar) familiar.scale = scale as PetScaleValue;
}
export function setPluginPetStatusReaction(petHandleId: string, reaction: OpenPetsReaction | null): void {
export function setPluginPetStatusReaction(petHandleId: string, reaction: FamiliarOSReaction | null): void {
if (petHandleId === "default") {
applyExternalPetStatusReaction(reaction);
return;
}
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
pet.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
refreshSpawnedPet(pet);
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
familiar.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
refreshSpawnedPet(familiar);
}
export async function movePluginPetBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise<void> {
@ -272,11 +272,11 @@ export function getPluginPetState(petHandleId: string): PluginPetState {
const window = requireWindow(petHandleId);
const [x, y] = window.getPosition();
const [width, height] = window.getSize();
const pet = spawnedPets.get(petHandleId);
const familiar = spawnedPets.get(petHandleId);
return {
position: { x, y },
bounds: { x, y, width, height },
currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : pet?.currentAnimation ?? "idle",
currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : familiar?.currentAnimation ?? "idle",
visible: window.isVisible(),
dragging: isPetWindowDragging(window),
};
@ -315,15 +315,15 @@ function stopTicker(petHandleId: string): void {
tickSubscribers.delete(petHandleId);
}
/** Show a plugin bubble on a pet surface (the bubbles capability entry point). */
/** Show a plugin bubble on a familiar surface (the bubbles capability entry point). */
export function getPluginPetArbiter(petHandleId: string): PetBubbleArbiter {
if (petHandleId === "default") return defaultPetBubbleArbiter;
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
return pet.arbiter;
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
return familiar.arbiter;
}
export function closeAllPluginPets(): void {
for (const pet of [...spawnedPets.values()]) void closePluginPet(pet.ownerPluginId, pet.handleId, true);
debug("plugin", "all plugin pets closed");
for (const familiar of [...spawnedPets.values()]) void closePluginPet(familiar.ownerPluginId, familiar.handleId, true);
debug("plugin", "all plugin familiars closed");
}

View file

@ -4,10 +4,10 @@ import { basename, extname, join } from "node:path";
import { app, clipboard, dialog, nativeTheme, net, Notification, shell } from "electron";
import { getDefaultPetWindowForPlugins } from "./default-pet-controller.js";
import { getDefaultPetWindowForPlugins } from "./default-familiar-controller.js";
import { getActiveLocaleLang } from "./i18n/index.js";
import { debug, warn } from "./logger.js";
import { playPetWindowAudio, stopPetWindowAudio } from "./pet-window.js";
import { playPetWindowAudio, stopPetWindowAudio } from "./familiar-window.js";
import { PluginAiGateway } from "./plugin-ai-gateway.js";
import { readDroppedFileText, startPluginEventSources, subscribePluginEvent } from "./plugin-events-source.js";
import { PluginOauthBroker } from "./plugin-oauth.js";
@ -17,8 +17,8 @@ import {
setPluginPetStatusReaction, clearPluginPetsForPlugin, closeAllPluginPets, closePluginPet, getPluginPetArbiter, getPluginPetState, hidePluginPet, listPluginPets,
movePluginPetBy, movePluginPetTo, movePluginPetToHome, onPluginPetTick, onPluginPetsChange, reactPluginPet,
setPluginPetAnimation, setPluginPetFollowCursor, setPluginPetPhysics, setPluginPetScale, showPluginPet, spawnPluginPet, wanderPluginPet,
} from "./plugin-pet-registry.js";
import { motionStop } from "./pet-motion-engine.js";
} from "./plugin-familiar-registry.js";
import { motionStop } from "./familiar-motion-engine.js";
import { PluginSecretsStore } from "./plugin-secrets.js";
import { showPluginToast } from "./plugin-toast.js";
import { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";
@ -95,7 +95,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
const baseFields = { kind: spec.kind, pluginId: "pluginId" in spec ? spec.pluginId : undefined, soundId: "id" in spec ? spec.id : undefined, name: "name" in spec ? spec.name : undefined };
debug("plugin", "audio play requested", baseFields);
const window = getDefaultPetWindowForPlugins();
if (!window) { debug("plugin", "audio play skipped", { ...baseFields, reason: "no-pet-window" }); return; }
if (!window) { debug("plugin", "audio play skipped", { ...baseFields, reason: "no-familiar-window" }); return; }
if (spec.kind === "named") {
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
debug("plugin", "audio play started", baseFields);
@ -142,7 +142,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
return subscribePluginEvent(event, handler);
},
},
pets: {
familiars: {
list: () => listPluginPets(),
spawn: (opts) => spawnPluginPet(opts),
close: (pluginId, petHandleId) => closePluginPet(pluginId, petHandleId),
@ -270,7 +270,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
try {
clearPluginPetsForPlugin(pluginId);
} catch (error) {
warn("plugin", "plugin pet teardown failed", { pluginId, error: error instanceof Error ? error.message : String(error) });
warn("plugin", "plugin familiar teardown failed", { pluginId, error: error instanceof Error ? error.message : String(error) });
}
// Runtime teardown must not delete persistent plugin data. User-imported
// sounds are cleared only by PluginService uninstall/prune paths.

View file

@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
import { app, BrowserWindow, ipcMain, session, type Event, type IpcMainEvent, type IpcMainInvokeEvent, type RenderProcessGoneDetails, type Session, type WebContents } from "electron";
import type { OpenPetsJavascriptPluginManifest } from "./plugin-manifest.js";
import type { FamiliarOSJavascriptPluginManifest } from "./plugin-manifest.js";
import { classifyPluginError, logPluginDiagnostic, truncatePluginConsoleMessage } from "./plugin-diagnostics.js";
import type { PluginRuntimeLogger, PluginSdkApi } from "./plugin-sdk-bridge.js";
import { isPluginSdkRoute, pluginSdkSyncRoutes, type PluginSdkRoute } from "./plugin-sdk-routes.js";
@ -11,7 +11,7 @@ import type { PluginStateRecord } from "./plugin-state.js";
export type PluginJsHostStartOptions = {
readonly record: PluginStateRecord;
readonly manifest: OpenPetsJavascriptPluginManifest;
readonly manifest: FamiliarOSJavascriptPluginManifest;
readonly entryPath: string;
readonly sdk?: PluginSdkApi;
readonly startupTimeoutMs?: number;
@ -32,7 +32,7 @@ export class ElectronPluginJsHost implements PluginJsHost {
}
async startPlugin(options: PluginJsHostStartOptions): Promise<PluginJsHostInstance> {
const partition = `openpets-plugin:${encodeURIComponent(options.record.id)}:${Date.now()}`;
const partition = `familiaros-plugin:${encodeURIComponent(options.record.id)}:${Date.now()}`;
const logger = (options.sdk as SdkWithLogger | undefined)?.__logger;
logPluginDiagnostic(logger, "debug", "plugin js host start", { pluginId: options.record.id, runtime: "javascript", phase: "begin" });
const pluginSession = session.fromPartition(partition, { cache: false });
@ -42,7 +42,7 @@ export class ElectronPluginJsHost implements PluginJsHost {
const moduleUrl = buildPluginModuleUrl(await readFile(options.entryPath, "utf8"), entryUrl);
const htmlUrl = buildPluginHtmlUrl(moduleUrl);
const token = `${options.record.id}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sdkChannel = `openpets:plugin-sdk:${token}`;
const sdkChannel = `familiaros:plugin-sdk:${token}`;
hardenSession(pluginSession, { entryUrl: moduleUrl, htmlUrl });
const window = new BrowserWindow({
@ -57,7 +57,7 @@ export class ElectronPluginJsHost implements PluginJsHost {
allowRunningInsecureContent: false,
partition,
preload: getPluginSdkPreloadPath(),
additionalArguments: [`--openpets-plugin-token=${token}`],
additionalArguments: [`--familiaros-plugin-token=${token}`],
},
});
@ -132,28 +132,28 @@ const noop = (): void => undefined;
const callbackOf = (runCallback: RunCallback, id: unknown): ((...callbackArgs: unknown[]) => unknown) => runCallback(id) ?? noop;
export const sdkCallHandlers: Record<PluginSdkRoute, SdkCallHandler> = {
// Pet handles (first arg is the pet handle id; "default" targets the default pet).
"pet.speak": (sdk, args) => sdk.pets.forPet(args[0]).speak(args[1]),
"pet.react": (sdk, args) => sdk.pets.forPet(args[0]).react(args[1] as never, args[2]),
"pet.setAnimation": (sdk, args) => sdk.pets.forPet(args[0]).setAnimation(args[1]),
"pet.setScale": (sdk, args) => sdk.pets.forPet(args[0]).setScale(args[1]),
"pet.setStatusReaction": (sdk, args) => sdk.pets.forPet(args[0]).setStatusReaction(args[1]),
"pet.moveBy": (sdk, args) => sdk.pets.forPet(args[0]).moveBy(args[1]),
"pet.wander": (sdk, args) => sdk.pets.forPet(args[0]).wander(args[1]),
"pet.moveToHome": (sdk, args) => sdk.pets.forPet(args[0]).moveToHome(),
"pet.moveTo": (sdk, args) => sdk.pets.forPet(args[0]).moveTo(args[1], args[2]),
"pet.followCursor": (sdk, args) => sdk.pets.forPet(args[0]).followCursor(args[1]),
"pet.physics": (sdk, args) => sdk.pets.forPet(args[0]).physics(args[1]),
"pet.onTick": (sdk, args, runCallback) => sdk.pets.forPet(args[0]).onTick(callbackOf(runCallback, args[1])),
"pet.offTick": (sdk, args) => sdk.pets.forPet("default").offTick(args[0]),
"pet.getState": (sdk, args) => sdk.pets.forPet(args[0]).getState(),
"pet.show": (sdk, args) => sdk.pets.forPet(args[0]).show(),
"pet.hide": (sdk, args) => sdk.pets.forPet(args[0]).hide(),
"pet.close": (sdk, args) => sdk.pets.forPet(args[0]).close(),
"pets.list": (sdk) => sdk.pets.list(),
"pets.spawn": (sdk, args) => sdk.pets.spawn(args[0]),
"pets.onChange": (sdk, args, runCallback) => sdk.pets.onChange(callbackOf(runCallback, args[0])),
"pets.offChange": (sdk, args) => sdk.pets.offChange(args[0]),
// Familiar handles (first arg is the familiar handle id; "default" targets the default familiar).
"familiar.speak": (sdk, args) => sdk.familiars.forPet(args[0]).speak(args[1]),
"familiar.react": (sdk, args) => sdk.familiars.forPet(args[0]).react(args[1] as never, args[2]),
"familiar.setAnimation": (sdk, args) => sdk.familiars.forPet(args[0]).setAnimation(args[1]),
"familiar.setScale": (sdk, args) => sdk.familiars.forPet(args[0]).setScale(args[1]),
"familiar.setStatusReaction": (sdk, args) => sdk.familiars.forPet(args[0]).setStatusReaction(args[1]),
"familiar.moveBy": (sdk, args) => sdk.familiars.forPet(args[0]).moveBy(args[1]),
"familiar.wander": (sdk, args) => sdk.familiars.forPet(args[0]).wander(args[1]),
"familiar.moveToHome": (sdk, args) => sdk.familiars.forPet(args[0]).moveToHome(),
"familiar.moveTo": (sdk, args) => sdk.familiars.forPet(args[0]).moveTo(args[1], args[2]),
"familiar.followCursor": (sdk, args) => sdk.familiars.forPet(args[0]).followCursor(args[1]),
"familiar.physics": (sdk, args) => sdk.familiars.forPet(args[0]).physics(args[1]),
"familiar.onTick": (sdk, args, runCallback) => sdk.familiars.forPet(args[0]).onTick(callbackOf(runCallback, args[1])),
"familiar.offTick": (sdk, args) => sdk.familiars.forPet("default").offTick(args[0]),
"familiar.getState": (sdk, args) => sdk.familiars.forPet(args[0]).getState(),
"familiar.show": (sdk, args) => sdk.familiars.forPet(args[0]).show(),
"familiar.hide": (sdk, args) => sdk.familiars.forPet(args[0]).hide(),
"familiar.close": (sdk, args) => sdk.familiars.forPet(args[0]).close(),
"familiars.list": (sdk) => sdk.familiars.list(),
"familiars.spawn": (sdk, args) => sdk.familiars.spawn(args[0]),
"familiars.onChange": (sdk, args, runCallback) => sdk.familiars.onChange(callbackOf(runCallback, args[0])),
"familiars.offChange": (sdk, args) => sdk.familiars.offChange(args[0]),
// UI: bubbles, toasts, panels, menus.
"ui.bubble": (sdk, args) => sdk.ui.bubble(args[0]),
"ui.alert": (sdk, args) => sdk.ui.alert(args[0]),
@ -318,10 +318,10 @@ export function buildPluginRegistrationHandshakeCode(entryUrl: string): string {
let done = false;
const sdk = globalThis.__openPetsSdk;
const finish = (value) => { if (done) return; done = true; globalThis.__openPetsRegisteredPlugin = value; Promise.resolve(value && typeof value.start === "function" ? value.start(sdk) : undefined).then(() => resolve(true), reject); };
Object.defineProperty(globalThis, "OpenPetsPlugin", { configurable: false, enumerable: false, writable: false, value: Object.freeze({ register: finish }) });
Object.defineProperty(globalThis, "FamiliarOSPlugin", { configurable: false, enumerable: false, writable: false, value: Object.freeze({ register: finish }) });
import(${JSON.stringify(entryUrl)}).then((mod) => {
if (mod && typeof mod.register === "function") Promise.resolve(mod.register(globalThis.OpenPetsPlugin)).then(finish, reject);
else if (mod && typeof mod.default === "function") Promise.resolve(mod.default(globalThis.OpenPetsPlugin)).then(finish, reject);
if (mod && typeof mod.register === "function") Promise.resolve(mod.register(globalThis.FamiliarOSPlugin)).then(finish, reject);
else if (mod && typeof mod.default === "function") Promise.resolve(mod.default(globalThis.FamiliarOSPlugin)).then(finish, reject);
else setTimeout(() => { if (!done) reject(new Error("JavaScript plugin did not register.")); }, 0);
}, reject);
}))();`;

View file

@ -4,10 +4,10 @@ import { basename, dirname, join } from "node:path";
import { readDeclaredPluginFiles } from "./plugin-assets.js";
import { defaultMaxPluginManifestBytes, isUnderPath, readSafePluginManifest } from "./plugin-manifest-reader.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type OpenPetsPluginManifest } from "./plugin-manifest.js";
import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type FamiliarOSPluginManifest } from "./plugin-manifest.js";
export type LocalPluginLoadResult = { readonly manifest: OpenPetsPluginManifest; readonly installPath: string; readonly manifestPath: string };
export type LocalPluginSourceManifest = { readonly manifest: OpenPetsPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer> };
export type LocalPluginLoadResult = { readonly manifest: FamiliarOSPluginManifest; readonly installPath: string; readonly manifestPath: string };
export type LocalPluginSourceManifest = { readonly manifest: FamiliarOSPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer> };
const maxPluginEntryBytes = 1024 * 1024;
export async function loadLocalPluginSnapshot(options: { readonly sourceFolder: string; readonly userDataPath: string; readonly maxManifestBytes?: number }): Promise<LocalPluginLoadResult> {
@ -21,12 +21,12 @@ export async function readLocalPluginSourceManifest(options: { readonly sourceFo
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("Selected plugin folder is invalid.");
const realSourceFolder = await fs.realpath(options.sourceFolder);
const sourceManifestPath = join(realSourceFolder, OPENPETS_PLUGIN_MANIFEST_FILENAME);
const sourceManifestPath = join(realSourceFolder, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
const manifestStat = await fs.lstat(sourceManifestPath);
if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) throw new Error("Selected plugin manifest is invalid.");
if (manifestStat.size > maxBytes) throw new Error("Plugin manifest is too large.");
const realSourceManifestPath = await fs.realpath(sourceManifestPath);
if (dirname(realSourceManifestPath) !== realSourceFolder || basename(realSourceManifestPath) !== OPENPETS_PLUGIN_MANIFEST_FILENAME) throw new Error("Selected plugin manifest is invalid.");
if (dirname(realSourceManifestPath) !== realSourceFolder || basename(realSourceManifestPath) !== FAMILIAROS_PLUGIN_MANIFEST_FILENAME) throw new Error("Selected plugin manifest is invalid.");
let handle: FileHandle | undefined;
try {
@ -57,15 +57,15 @@ export async function readLocalPluginSourceManifest(options: { readonly sourceFo
}
}
export async function publishLocalPluginSnapshot(options: { readonly manifest: OpenPetsPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer>; readonly userDataPath: string; readonly maxManifestBytes?: number }): Promise<LocalPluginLoadResult> {
export async function publishLocalPluginSnapshot(options: { readonly manifest: FamiliarOSPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer>; readonly userDataPath: string; readonly maxManifestBytes?: number }): Promise<LocalPluginLoadResult> {
const maxBytes = options.maxManifestBytes ?? defaultMaxPluginManifestBytes;
const devRoot = join(options.userDataPath, "plugins-dev");
await fs.mkdir(devRoot, { recursive: true });
await assertRealDirectory(devRoot, "Plugin development directory is invalid.");
const installPath = join(devRoot, options.manifest.id);
const manifestPath = join(installPath, OPENPETS_PLUGIN_MANIFEST_FILENAME);
const manifestPath = join(installPath, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
const tempPath = join(devRoot, `.tmp-${options.manifest.id}-${process.pid}-${Date.now()}`);
const tempManifestPath = join(tempPath, OPENPETS_PLUGIN_MANIFEST_FILENAME);
const tempManifestPath = join(tempPath, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
await fs.rm(tempPath, { recursive: true, force: true });
await fs.mkdir(tempPath, { recursive: true });
try {

View file

@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type OpenPetsPluginManifest } from "./plugin-manifest.js";
import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type FamiliarOSPluginManifest } from "./plugin-manifest.js";
export const defaultMaxPluginManifestBytes = 64 * 1024;
@ -15,7 +15,7 @@ export type ReadPluginManifestOptions = {
readonly expectedVersion?: string;
};
export async function readSafePluginManifest(options: ReadPluginManifestOptions): Promise<OpenPetsPluginManifest> {
export async function readSafePluginManifest(options: ReadPluginManifestOptions): Promise<FamiliarOSPluginManifest> {
const maxBytes = options.maxManifestBytes ?? defaultMaxPluginManifestBytes;
const realInstallPath = await fs.realpath(options.installPath);
const allowedRoots = await Promise.all(options.allowedPluginRoots.map((root) => fs.realpath(root)));
@ -23,7 +23,7 @@ export async function readSafePluginManifest(options: ReadPluginManifestOptions)
const realManifestPath = await fs.realpath(options.manifestPath);
if (!isUnderPath(realManifestPath, realInstallPath)) throw new Error("Plugin manifest path is outside install path.");
if (dirname(realManifestPath) !== realInstallPath || basename(realManifestPath) !== OPENPETS_PLUGIN_MANIFEST_FILENAME) throw new Error("Plugin manifest path is invalid.");
if (dirname(realManifestPath) !== realInstallPath || basename(realManifestPath) !== FAMILIAROS_PLUGIN_MANIFEST_FILENAME) throw new Error("Plugin manifest path is invalid.");
const stat = await fs.stat(realManifestPath);
if (!stat.isFile()) throw new Error("Plugin manifest path is not a file.");

View file

@ -1,14 +1,14 @@
import { allowedReactions } from "./local-ipc-protocol.js";
export const OPENPETS_PLUGIN_MANIFEST_FILENAME = "openpets.plugin.json";
export const openPetsPluginManifestFilename = OPENPETS_PLUGIN_MANIFEST_FILENAME;
export const FAMILIAROS_PLUGIN_MANIFEST_FILENAME = "familiaros.plugin.json";
export const openPetsPluginManifestFilename = FAMILIAROS_PLUGIN_MANIFEST_FILENAME;
export type PluginRuntime = "declarative";
export type KnownPluginRuntime = PluginRuntime | "javascript";
export type PluginPermission =
| "pet:speak"
| "pet:reaction"
| "pet:move"
| "familiar:speak"
| "familiar:reaction"
| "familiar:move"
| "timer"
| "schedule"
| "storage"
@ -16,13 +16,13 @@ export type PluginPermission =
| "commands"
| "network"
// SDK v3 permissions (manifestVersion 3 only)
| "pet:interact"
| "pet:pin"
| "pet:animate"
| "pet:speak:dynamic"
| "pet:drop"
| "pets:read"
| "pets:manage"
| "familiar:interact"
| "familiar:pin"
| "familiar:animate"
| "familiar:speak:dynamic"
| "familiar:drop"
| "familiars:read"
| "familiars:manage"
| "audio"
| "events"
| "ui:toast"
@ -41,7 +41,7 @@ export type PluginPermission =
| "network:write";
export type PluginJavascriptPermission = Exclude<PluginPermission, "timer">;
/** Permissions flagged sensitive in the UI (louder consent, global toggles). */
export const sensitivePluginPermissions: ReadonlySet<PluginPermission> = new Set(["voice:listen", "clipboard", "pet:speak:dynamic"]);
export const sensitivePluginPermissions: ReadonlySet<PluginPermission> = new Set(["voice:listen", "clipboard", "familiar:speak:dynamic"]);
export type PluginIcon = "plugin" | "bell" | "timer" | "github" | "heart" | "sparkles" | "coffee" | "focus" | "droplet";
export type PluginConfigFieldType = "text" | "textarea" | "number" | "boolean" | "select" | "time" | "date" | "multiSelect" | "list" | "secret" | "sound";
@ -65,11 +65,11 @@ export type PluginConfigField = {
};
export type PluginStringConfigRef = { config: string };
export type PluginAction = { type: "pet.speak"; message: string | PluginStringConfigRef } | { type: "pet.react"; reaction: string | PluginStringConfigRef };
export type PluginAction = { type: "familiar.speak"; message: string | PluginStringConfigRef } | { type: "familiar.react"; reaction: string | PluginStringConfigRef };
export type PluginTimerEveryMinutes = number | { config: string };
export type PluginTrigger = { on: "timer"; everyMinutes: PluginTimerEveryMinutes; actions: PluginAction[] };
export type OpenPetsDeclarativePluginManifest = {
export type FamiliarOSDeclarativePluginManifest = {
manifestVersion: 1;
id: string;
name: string;
@ -82,7 +82,7 @@ export type OpenPetsDeclarativePluginManifest = {
triggers: PluginTrigger[];
};
export type OpenPetsJavascriptPluginManifest = {
export type FamiliarOSJavascriptPluginManifest = {
manifestVersion: 2 | 3;
id: string;
name: string;
@ -101,7 +101,7 @@ export type OpenPetsJavascriptPluginManifest = {
panels?: Record<string, string>;
};
export type OpenPetsPluginManifest = OpenPetsDeclarativePluginManifest | OpenPetsJavascriptPluginManifest;
export type FamiliarOSPluginManifest = FamiliarOSDeclarativePluginManifest | FamiliarOSJavascriptPluginManifest;
export type PluginManifestValidationError = {
path: string;
@ -110,7 +110,7 @@ export type PluginManifestValidationError = {
};
export type PluginManifestValidationResult =
| { ok: true; manifest: OpenPetsPluginManifest; errors: [] }
| { ok: true; manifest: FamiliarOSPluginManifest; errors: [] }
| { ok: false; errors: PluginManifestValidationError[] };
// "$schema" is tolerated everywhere so manifests can opt into editor validation.
@ -127,13 +127,13 @@ const deferredConfigTypes = new Set(["multi-select", "schedule", "connection"]);
const deferredConfigFeatures = new Set(["dynamicOptions"]);
const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github", "heart", "sparkles", "coffee", "focus", "droplet"]);
export const pluginV3Permissions = [
"pet:interact",
"pet:pin",
"pet:animate",
"pet:speak:dynamic",
"pet:drop",
"pets:read",
"pets:manage",
"familiar:interact",
"familiar:pin",
"familiar:animate",
"familiar:speak:dynamic",
"familiar:drop",
"familiars:read",
"familiars:manage",
"audio",
"events",
"ui:toast",
@ -151,11 +151,11 @@ export const pluginV3Permissions = [
"clipboard",
"network:write",
] as const satisfies readonly PluginPermission[];
export const pluginPermissions = ["pet:speak", "pet:reaction", "pet:move", "timer", "schedule", "storage", "status", "commands", "network", ...pluginV3Permissions] as const satisfies readonly PluginPermission[];
const javascriptPluginPermissionsV2 = ["pet:speak", "pet:reaction", "pet:move", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginJavascriptPermission[];
export const pluginPermissions = ["familiar:speak", "familiar:reaction", "familiar:move", "timer", "schedule", "storage", "status", "commands", "network", ...pluginV3Permissions] as const satisfies readonly PluginPermission[];
const javascriptPluginPermissionsV2 = ["familiar:speak", "familiar:reaction", "familiar:move", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginJavascriptPermission[];
const javascriptPluginPermissionsV3 = [...javascriptPluginPermissionsV2, ...pluginV3Permissions] as const satisfies readonly PluginJavascriptPermission[];
export const pluginPermissionSet: ReadonlySet<string> = new Set(pluginPermissions);
const declarativePluginPermissionSet: ReadonlySet<string> = new Set(["pet:speak", "pet:reaction", "timer"]);
const declarativePluginPermissionSet: ReadonlySet<string> = new Set(["familiar:speak", "familiar:reaction", "timer"]);
const pluginAssetKinds = ["icons", "images", "svgs", "sprites", "sounds"] as const satisfies readonly PluginAssetKind[];
const pluginAssetExtensions: Record<PluginAssetKind, readonly string[]> = {
icons: [".png", ".webp", ".svg"],
@ -213,7 +213,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateTriggers(input.triggers, permissions, configFields, errors);
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: input as OpenPetsPluginManifest, errors: [] };
return { ok: true, manifest: input as FamiliarOSPluginManifest, errors: [] };
}
function validateJavascriptPluginManifest(input: Record<string, unknown>, manifestVersion: 2 | 3): PluginManifestValidationResult {
@ -238,7 +238,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
validatePanels(input.panels, errors);
}
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: input as OpenPetsPluginManifest, errors: [] };
return { ok: true, manifest: input as FamiliarOSPluginManifest, errors: [] };
}
function validateJavascriptPermissions(value: unknown, manifestVersion: 2 | 3, errors: PluginManifestValidationError[]): Set<string> {
@ -460,28 +460,28 @@ function validateReactionSelectReference(schema: Record<string, unknown>, fieldN
if (!isRecord(field) || field.type !== "select") return;
const reactions = new Set<string>(allowedReactions);
if (!Object.prototype.hasOwnProperty.call(field, "default") || typeof field.default !== "string" || !reactions.has(field.default)) {
addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select config must have a valid OpenPets reaction default.");
addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select config must have a valid FamiliarOS reaction default.");
}
if (Array.isArray(field.options)) {
for (const option of field.options) {
if (isRecord(option) && typeof option.value === "string" && !reactions.has(option.value)) addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select options must be valid OpenPets reactions.");
if (isRecord(option) && typeof option.value === "string" && !reactions.has(option.value)) addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select options must be valid FamiliarOS reactions.");
}
}
}
function validateAction(value: unknown, permissions: Set<string>, configFields: ConfigFieldSets, path: string, errors: PluginManifestValidationError[]): void {
if (!isRecord(value)) return addError(errors, path, "invalid_action", "Action must be an object.");
if (value.type === "pet.speak") {
if (value.type === "familiar.speak") {
rejectUnknownFields(value, speakActionFields, path, errors);
validateStringOrConfigRef(value.message, configFields.text, `${path}.message`, "message", "text", errors);
requirePermission(permissions, "pet:speak", path, errors);
} else if (value.type === "pet.react") {
requirePermission(permissions, "familiar:speak", path, errors);
} else if (value.type === "familiar.react") {
rejectUnknownFields(value, reactActionFields, path, errors);
validateStringOrConfigRef(value.reaction, configFields.select, `${path}.reaction`, "reaction", "select", errors);
if (isRecord(value.reaction) && typeof value.reaction.config === "string" && configFields.select.has(value.reaction.config)) validateReactionSelectReference(configFields.schema, value.reaction.config, `${path}.reaction`, errors);
requirePermission(permissions, "pet:reaction", path, errors);
requirePermission(permissions, "familiar:reaction", path, errors);
} else {
addError(errors, `${path}.type`, "invalid_action", "Action type must be pet.speak or pet.react.");
addError(errors, `${path}.type`, "invalid_action", "Action type must be familiar.speak or familiar.react.");
}
}

View file

@ -115,11 +115,11 @@ async function startLoopbackListener(stateToken: string): Promise<{ server: Serv
const state = url.searchParams.get("state");
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
if (error || !code || state !== stateToken) {
response.end("<html><body><p>OpenPets sign-in failed. You can close this tab.</p></body></html>");
response.end("<html><body><p>FamiliarOS sign-in failed. You can close this tab.</p></body></html>");
rejectCode(new Error(error ? `OAuth authorization failed: ${error}` : "OAuth authorization response was invalid."));
return;
}
response.end("<html><body><p>OpenPets is connected. You can close this tab.</p></body></html>");
response.end("<html><body><p>FamiliarOS is connected. You can close this tab.</p></body></html>");
clearTimeout(timeout);
resolveCode(code);
} catch {

View file

@ -6,14 +6,14 @@ import type { Entry, ZipFile } from "yauzl";
import { collectDeclaredPluginFiles, preparePluginFileBytes } from "./plugin-assets.js";
import { type PluginCatalogEntry, type PluginCatalogEntryV2 } from "./plugin-catalog-validation.js";
import { defaultMaxPluginManifestBytes, readSafePluginManifest, isUnderPath } from "./plugin-manifest-reader.js";
import { canonicalizePluginPermissions, OPENPETS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type OpenPetsPluginManifest } from "./plugin-manifest.js";
import { canonicalizePluginPermissions, FAMILIAROS_PLUGIN_MANIFEST_FILENAME, validatePluginManifest, type FamiliarOSPluginManifest } from "./plugin-manifest.js";
const maxZipBytes = 16 * 1024 * 1024;
const maxPluginEntryBytes = 1024 * 1024;
const maxPluginZipEntries = 2 + 32 * 5 + 8;
const maxPluginZipUncompressedBytes = 32 * 1024 * 1024;
const maxPluginZipFileBytes = 5 * 1024 * 1024;
export type PluginPackageInstall = { readonly manifest: OpenPetsPluginManifest; readonly installPath: string; readonly manifestPath: string; readonly entryPath?: string };
export type PluginPackageInstall = { readonly manifest: FamiliarOSPluginManifest; readonly installPath: string; readonly manifestPath: string; readonly entryPath?: string };
type AnyPluginCatalogEntry = PluginCatalogEntry | PluginCatalogEntryV2;
@ -24,12 +24,12 @@ export async function downloadCatalogPluginZip(entry: AnyPluginCatalogEntry, fet
finally { clearTimeout(timeout); }
}
export function validatePluginZipUrl(value: string): void { const url = new URL(value); if (url.protocol !== "https:" || url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/plugins/") || url.username || url.password || url.port) throw new Error("Plugin ZIP URL is not allowed."); }
export function validatePluginZipUrl(value: string): void { const url = new URL(value); if (url.protocol !== "https:" || url.hostname !== "zip.familiaros.dev" || !url.pathname.startsWith("/plugins/") || url.username || url.password || url.port) throw new Error("Plugin ZIP URL is not allowed."); }
export async function readCatalogPluginManifestFromZip(options: { readonly catalogEntry: AnyPluginCatalogEntry; readonly zip: Buffer; readonly maxManifestBytes?: number; readonly maxEntryBytes?: number }): Promise<{ readonly manifest: OpenPetsPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer> }> {
export async function readCatalogPluginManifestFromZip(options: { readonly catalogEntry: AnyPluginCatalogEntry; readonly zip: Buffer; readonly maxManifestBytes?: number; readonly maxEntryBytes?: number }): Promise<{ readonly manifest: FamiliarOSPluginManifest; readonly manifestText: string; readonly entryText?: string; readonly declaredFiles?: ReadonlyMap<string, Buffer> }> {
const files = await readPluginZipFiles(options.zip, options.maxManifestBytes ?? defaultMaxPluginManifestBytes, options.maxEntryBytes ?? maxPluginEntryBytes);
const text = files.get(OPENPETS_PLUGIN_MANIFEST_FILENAME)?.toString("utf8");
if (text === undefined) throw new Error("Plugin ZIP must contain openpets.plugin.json.");
const text = files.get(FAMILIAROS_PLUGIN_MANIFEST_FILENAME)?.toString("utf8");
if (text === undefined) throw new Error("Plugin ZIP must contain familiaros.plugin.json.");
const parsed = JSON.parse(text) as unknown; const result = validatePluginManifest(parsed); if (!result.ok) throw new Error("Plugin manifest validation failed.");
const manifest = result.manifest; assertCatalogConsistency(options.catalogEntry, manifest);
let entryText: string | undefined;
@ -46,7 +46,7 @@ export async function readCatalogPluginManifestFromZip(options: { readonly catal
if (bytes === undefined) throw new Error(`Plugin ZIP is missing declared file: ${file.relPath}`);
declaredFiles.set(file.relPath, preparePluginFileBytes(file, bytes));
}
const allowed = new Set([OPENPETS_PLUGIN_MANIFEST_FILENAME, manifest.entry, ...declared.map((file) => file.relPath)]);
const allowed = new Set([FAMILIAROS_PLUGIN_MANIFEST_FILENAME, manifest.entry, ...declared.map((file) => file.relPath)]);
for (const name of files.keys()) if (!allowed.has(name)) throw new Error(`Plugin ZIP contains an undeclared file: ${name}`);
return { manifest, manifestText: text, entryText, declaredFiles };
} else if (files.size !== 1) throw new Error("Plugin ZIP must contain exactly one root manifest file.");
@ -56,13 +56,13 @@ export async function readCatalogPluginManifestFromZip(options: { readonly catal
export async function installCatalogPluginPackage(options: { readonly userDataPath: string; readonly catalogEntry: AnyPluginCatalogEntry; readonly zip: Buffer; readonly maxManifestBytes?: number }): Promise<PluginPackageInstall> {
const { manifest, manifestText: text, entryText, declaredFiles } = await readCatalogPluginManifestFromZip(options);
const root = join(options.userDataPath, "plugins"); await fs.mkdir(root, { recursive: true }); await assertDir(root);
const installPath = join(root, manifest.id); const manifestPath = join(installPath, OPENPETS_PLUGIN_MANIFEST_FILENAME); const tempPath = join(root, `.tmp-${manifest.id}-${process.pid}-${Date.now()}`);
const installPath = join(root, manifest.id); const manifestPath = join(installPath, FAMILIAROS_PLUGIN_MANIFEST_FILENAME); const tempPath = join(root, `.tmp-${manifest.id}-${process.pid}-${Date.now()}`);
const entryPath = manifest.manifestVersion === 2 || manifest.manifestVersion === 3 ? join(installPath, manifest.entry) : undefined;
try {
await fs.rm(tempPath, { recursive: true, force: true });
await fs.mkdir(tempPath, { recursive: false, mode: 0o700 });
await assertDir(tempPath);
await fs.writeFile(join(tempPath, OPENPETS_PLUGIN_MANIFEST_FILENAME), text, { mode: 0o600 });
await fs.writeFile(join(tempPath, FAMILIAROS_PLUGIN_MANIFEST_FILENAME), text, { mode: 0o600 });
if ((manifest.manifestVersion === 2 || manifest.manifestVersion === 3) && entryPath && entryText !== undefined) {
if (Buffer.byteLength(entryText) > maxPluginEntryBytes) throw new Error("Plugin entry file is too large.");
const tempEntryPath = join(tempPath, manifest.entry);
@ -74,7 +74,7 @@ export async function installCatalogPluginPackage(options: { readonly userDataPa
await fs.writeFile(filePath, bytes, { mode: 0o600 });
}
}
await readSafePluginManifest({ installPath: tempPath, manifestPath: join(tempPath, OPENPETS_PLUGIN_MANIFEST_FILENAME), allowedPluginRoots: [root], maxManifestBytes: options.maxManifestBytes, expectedId: manifest.id, expectedVersion: manifest.version });
await readSafePluginManifest({ installPath: tempPath, manifestPath: join(tempPath, FAMILIAROS_PLUGIN_MANIFEST_FILENAME), allowedPluginRoots: [root], maxManifestBytes: options.maxManifestBytes, expectedId: manifest.id, expectedVersion: manifest.version });
await replaceInstallDirectory(root, installPath, tempPath);
} catch (e) { await fs.rm(tempPath, { recursive: true, force: true }); throw e; }
const copied = await readSafePluginManifest({ installPath, manifestPath, allowedPluginRoots: [root], maxManifestBytes: options.maxManifestBytes, expectedId: manifest.id, expectedVersion: manifest.version });
@ -85,8 +85,8 @@ export async function resolveSafePluginInstallDir(userDataPath: string, id: stri
export async function safeDeletePluginInstallDir(userDataPath: string, id: string, installPath: string, source: "catalog" | "local"): Promise<void> { const realInstall = await resolveSafePluginInstallDir(userDataPath, id, installPath, source); await fs.rm(realInstall, { recursive: true, force: true }); }
function assertCatalogConsistency(entry: AnyPluginCatalogEntry, manifest: OpenPetsPluginManifest): void { const entryPermissions = canonicalizePluginPermissions(entry.permissions); const manifestPermissions = canonicalizePluginPermissions(manifest.permissions); const entrySdk = "sdkVersion" in entry ? entry.sdkVersion : undefined; const manifestSdk = "sdkVersion" in manifest ? manifest.sdkVersion : undefined; const entryHosts = "network" in entry ? entry.network?.hosts ?? [] : []; const manifestHosts = "network" in manifest ? manifest.network?.hosts ?? [] : []; if (entry.id !== manifest.id || entry.version !== manifest.version || entry.name !== manifest.name || entry.runtime !== manifest.runtime || entryPermissions.join("\0") !== manifestPermissions.join("\0") || entrySdk !== manifestSdk || entryHosts.join("\0") !== manifestHosts.join("\0")) throw new Error("Plugin manifest does not match catalog entry."); }
async function readPluginZipFiles(zip: Buffer, maxManifestBytes: number, maxEntryBytes: number): Promise<Map<string, Buffer>> { const zipFile = await new Promise<ZipFile>((res, rej) => yauzl.fromBuffer(zip, { lazyEntries: true, validateEntrySizes: true }, (e, z) => e || !z ? rej(e ?? new Error("Unable to open plugin ZIP.")) : res(z))); const files = new Map<string, Buffer>(); let entryCount = 0; let totalUncompressed = 0; await new Promise<void>((resolve, reject) => { zipFile.on("error", reject); zipFile.on("end", resolve); zipFile.on("entry", (entry: Entry) => { try { entryCount += 1; if (entryCount > maxPluginZipEntries) throw new Error("Plugin ZIP contains too many entries."); totalUncompressed += entry.uncompressedSize; if (totalUncompressed > maxPluginZipUncompressedBytes) throw new Error("Plugin ZIP uncompressed size is too large."); const name = validateEntry(entry); if (files.has(name)) throw new Error("Plugin ZIP contains duplicate entry path."); const limit = name === OPENPETS_PLUGIN_MANIFEST_FILENAME ? maxManifestBytes : name.endsWith(".js") || name.endsWith(".mjs") ? maxEntryBytes : maxPluginZipFileBytes; if (entry.uncompressedSize > limit) throw new Error(name === OPENPETS_PLUGIN_MANIFEST_FILENAME ? "Plugin manifest is too large." : "Plugin entry file is too large."); zipFile.openReadStream(entry, (err, stream) => { if (err || !stream) return reject(err ?? new Error("Unable to read plugin ZIP entry.")); const chunks: Buffer[] = []; let total = 0; stream.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) { stream.destroy(new Error("Plugin ZIP entry is too large.")); return; } chunks.push(chunk); }); stream.on("error", reject); stream.on("end", () => { files.set(name, Buffer.concat(chunks)); zipFile.readEntry(); }); }); } catch (e) { reject(e); } }); zipFile.readEntry(); }).finally(() => zipFile.close()); return files; }
function assertCatalogConsistency(entry: AnyPluginCatalogEntry, manifest: FamiliarOSPluginManifest): void { const entryPermissions = canonicalizePluginPermissions(entry.permissions); const manifestPermissions = canonicalizePluginPermissions(manifest.permissions); const entrySdk = "sdkVersion" in entry ? entry.sdkVersion : undefined; const manifestSdk = "sdkVersion" in manifest ? manifest.sdkVersion : undefined; const entryHosts = "network" in entry ? entry.network?.hosts ?? [] : []; const manifestHosts = "network" in manifest ? manifest.network?.hosts ?? [] : []; if (entry.id !== manifest.id || entry.version !== manifest.version || entry.name !== manifest.name || entry.runtime !== manifest.runtime || entryPermissions.join("\0") !== manifestPermissions.join("\0") || entrySdk !== manifestSdk || entryHosts.join("\0") !== manifestHosts.join("\0")) throw new Error("Plugin manifest does not match catalog entry."); }
async function readPluginZipFiles(zip: Buffer, maxManifestBytes: number, maxEntryBytes: number): Promise<Map<string, Buffer>> { const zipFile = await new Promise<ZipFile>((res, rej) => yauzl.fromBuffer(zip, { lazyEntries: true, validateEntrySizes: true }, (e, z) => e || !z ? rej(e ?? new Error("Unable to open plugin ZIP.")) : res(z))); const files = new Map<string, Buffer>(); let entryCount = 0; let totalUncompressed = 0; await new Promise<void>((resolve, reject) => { zipFile.on("error", reject); zipFile.on("end", resolve); zipFile.on("entry", (entry: Entry) => { try { entryCount += 1; if (entryCount > maxPluginZipEntries) throw new Error("Plugin ZIP contains too many entries."); totalUncompressed += entry.uncompressedSize; if (totalUncompressed > maxPluginZipUncompressedBytes) throw new Error("Plugin ZIP uncompressed size is too large."); const name = validateEntry(entry); if (files.has(name)) throw new Error("Plugin ZIP contains duplicate entry path."); const limit = name === FAMILIAROS_PLUGIN_MANIFEST_FILENAME ? maxManifestBytes : name.endsWith(".js") || name.endsWith(".mjs") ? maxEntryBytes : maxPluginZipFileBytes; if (entry.uncompressedSize > limit) throw new Error(name === FAMILIAROS_PLUGIN_MANIFEST_FILENAME ? "Plugin manifest is too large." : "Plugin entry file is too large."); zipFile.openReadStream(entry, (err, stream) => { if (err || !stream) return reject(err ?? new Error("Unable to read plugin ZIP entry.")); const chunks: Buffer[] = []; let total = 0; stream.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) { stream.destroy(new Error("Plugin ZIP entry is too large.")); return; } chunks.push(chunk); }); stream.on("error", reject); stream.on("end", () => { files.set(name, Buffer.concat(chunks)); zipFile.readEntry(); }); }); } catch (e) { reject(e); } }); zipFile.readEntry(); }).finally(() => zipFile.close()); return files; }
function validateEntry(entry: Entry): string { const normalized = posix.normalize(entry.fileName); if (entry.fileName.includes("\\") || entry.fileName.includes("\0") || entry.fileName.startsWith("/") || /^[A-Za-z]:/.test(entry.fileName) || normalized !== entry.fileName || normalized.startsWith("../") || normalized === "." || normalized.includes("/../")) throw new Error("Plugin ZIP entry path is invalid."); if (entry.fileName.endsWith("/")) throw new Error("Plugin ZIP directory entries are unsupported."); if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) throw new Error("Unsupported plugin ZIP entry compression method."); const type = (entry.externalFileAttributes >>> 16) & 0o170000; if (type === 0o120000 || (type !== 0 && type !== 0o100000)) throw new Error("Plugin ZIP entry type is unsupported."); if (entry.generalPurposeBitFlag & 0x1) throw new Error("Encrypted plugin ZIP entries are unsupported."); return normalized; }
async function assertDir(path: string): Promise<void> { const stat = await fs.lstat(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Plugin directory is invalid."); }
async function replaceInstallDirectory(root: string, installPath: string, tempPath: string): Promise<void> { const backupPath = join(root, `.bak-${Date.now()}-${process.pid}`); let hadExisting = false; try { await assertDir(tempPath); try { await assertDir(installPath); hadExisting = true; } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (code !== "ENOENT") throw error; } if (hadExisting) await fs.rename(installPath, backupPath); await fs.rename(tempPath, installPath); await fs.rm(backupPath, { recursive: true, force: true }); } catch (error) { await fs.rm(installPath, { recursive: true, force: true }).catch(() => undefined); if (hadExisting) await fs.rename(backupPath, installPath).catch(() => undefined); throw error; } }

Some files were not shown because too many files have changed in this diff Show more