Compare commits
195 commits
pr/single-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d1007ffa | ||
|
|
80b968465f | ||
|
|
b3d915c00e | ||
|
|
ad35e7d619 | ||
|
|
db1e506128 | ||
|
|
17179cc4f2 | ||
|
|
55d9f0cd35 | ||
|
|
c707b90923 | ||
|
|
c0455c9746 | ||
|
|
a49b0111fc | ||
|
|
e0e3439b9e | ||
|
|
797c793c36 | ||
|
|
7eacd4dc9c | ||
|
|
932fed5357 | ||
|
|
c42e1abe6f | ||
|
|
b913978a3c | ||
|
|
116d312cca | ||
|
|
a42efdeea9 | ||
|
|
ffb58e6712 | ||
|
|
fa21d29fcd | ||
|
|
ef4d4bcd92 | ||
|
|
9c6677564c | ||
|
|
8ff7ae1625 | ||
|
|
161ec56bf0 | ||
|
|
70336c6e30 | ||
|
|
0fd70c2faf | ||
|
|
5ab4c5cd0f | ||
|
|
19a4a8988c | ||
|
|
111d0d9fef | ||
|
|
764ba3e962 | ||
|
|
871f426224 | ||
|
|
6a372c55e8 | ||
|
|
655a7b2da1 | ||
|
|
4949f5f2e8 | ||
|
|
af8521801f | ||
|
|
df0f98a1b7 | ||
|
|
a714b2accc | ||
|
|
a5a37a48f2 | ||
|
|
f44a0967e9 | ||
|
|
e8b344b6a5 | ||
|
|
172c95d7f2 | ||
|
|
6618e0021c | ||
|
|
430ccdb661 | ||
|
|
5caf9b1c39 | ||
|
|
13e23c60c9 | ||
|
|
2b1da0c5ea | ||
|
|
9e75151e56 | ||
|
|
29bf11afae | ||
|
|
1d19ae9ca1 | ||
|
|
394ba13f82 | ||
|
|
794430ae1b | ||
|
|
cb591aca0f | ||
|
|
2b1a4e80e2 | ||
|
|
f8575ea012 | ||
|
|
d5c907016f | ||
|
|
6bb7d4ddf7 | ||
|
|
91e22b216d | ||
|
|
99a2de2885 | ||
|
|
bb5aa4fc9e | ||
|
|
d7a5306317 | ||
|
|
7cc2a1976e | ||
|
|
1c33c1022c | ||
|
|
1255770ad9 | ||
|
|
354199655f | ||
|
|
5064fdecf7 | ||
|
|
0c890b1f8f | ||
|
|
2d8b830dec | ||
|
|
d15ccabefa | ||
|
|
50686268c1 | ||
|
|
1f65538f75 | ||
|
|
ec49296516 | ||
|
|
2ab4f61175 | ||
|
|
80d901a917 | ||
|
|
6057065ee7 | ||
|
|
9b6b7ad310 | ||
|
|
f272ec678a | ||
|
|
db535f6c98 | ||
|
|
9790f240ff | ||
|
|
8993f477c5 | ||
|
|
74c8feda39 | ||
|
|
922481b18d | ||
|
|
1496a2ebf4 | ||
|
|
35df50ed8a | ||
|
|
eb075267b1 | ||
|
|
c37065e983 | ||
|
|
7df8b3e72b | ||
|
|
f0fd49ef7a | ||
|
|
54ba75b74c | ||
|
|
3eded9d264 | ||
|
|
adc5f6ca1b | ||
|
|
add3654fce | ||
|
|
dc78637d42 | ||
|
|
4504319d5f | ||
|
|
af3bd09496 | ||
|
|
385d14184f | ||
|
|
8edbecc8ca | ||
|
|
e43cfe01f5 | ||
|
|
6629c3e3e8 | ||
|
|
06fc5f74f5 | ||
|
|
da30b28551 | ||
|
|
34c409ee1f | ||
|
|
bf546d121c | ||
|
|
aa6e78d984 | ||
|
|
2b1a01c9f9 | ||
|
|
fd1dc932ad | ||
|
|
8a6b2a4904 | ||
|
|
7758efad69 | ||
|
|
1ff1fec94e | ||
|
|
142c4f4412 | ||
|
|
dbb6407774 | ||
|
|
0166745ab2 | ||
|
|
26aba534fe | ||
|
|
846de24fb0 | ||
|
|
d89171c4be | ||
|
|
1d7d2e2c6a | ||
|
|
a03c3df410 | ||
|
|
edfad055a0 | ||
|
|
bb915ddc55 | ||
|
|
7290d6c26a | ||
|
|
32f48f571f | ||
|
|
64ab80c7a7 | ||
|
|
6d14da2984 | ||
|
|
e2577b05c5 | ||
|
|
63449efbfe | ||
|
|
07b8959afc | ||
|
|
1e12a9a54b | ||
|
|
65ac83849e | ||
|
|
966aea480f | ||
|
|
8d3849caea | ||
|
|
48471ba2bc | ||
|
|
fae8a6d403 | ||
|
|
92a85cf77a | ||
|
|
cb17a7aad8 | ||
|
|
a2bf68ae77 | ||
|
|
a6a51872d6 | ||
|
|
4db3beb032 | ||
|
|
648aac86c2 | ||
|
|
8c4f5a65f1 | ||
|
|
01e96c5a82 | ||
|
|
aaf4fc5e13 | ||
|
|
1e3fa676d5 | ||
|
|
4d99cd32fe | ||
|
|
4d29efb89b | ||
|
|
39e282639b | ||
|
|
5f2fcea5bf | ||
|
|
726fc23301 | ||
|
|
0cb3bcace1 | ||
|
|
e263cd62e6 | ||
|
|
99867039a2 | ||
|
|
f9c74b6176 | ||
|
|
9bf7ca2210 | ||
|
|
5d956b7d0e | ||
|
|
69d3046ad7 | ||
|
|
a030240467 | ||
|
|
6ab3bb64d8 | ||
|
|
eae6e5074a | ||
|
|
178931a619 | ||
|
|
bb758e9d0f | ||
|
|
32f109f90b | ||
|
|
a78b012075 | ||
|
|
880bfb4eb4 | ||
|
|
16bfab1e34 | ||
|
|
0f294016aa | ||
|
|
f514960aed | ||
|
|
ed5903aaf8 | ||
|
|
de400f2228 | ||
|
|
d8650a9a70 | ||
|
|
4aff369021 | ||
|
|
1c849bfc4a | ||
|
|
61da20d728 | ||
|
|
236a61502e | ||
|
|
5f7a4ea4bb | ||
|
|
9246fedfc5 | ||
|
|
2445f60c3b | ||
|
|
73500015a2 | ||
|
|
85a5017952 | ||
|
|
b6c1c59195 | ||
|
|
a52fa994e9 | ||
|
|
3ad0dd1928 | ||
|
|
c72df93538 | ||
|
|
26d446ff0a | ||
|
|
bda17d7afb | ||
|
|
3c195bb8b3 | ||
|
|
14dfaf2eb3 | ||
|
|
799ac9ccd2 | ||
|
|
d026cf08ba | ||
|
|
8e87b73251 | ||
|
|
d9eca14f61 | ||
|
|
e4d6536f81 | ||
|
|
66c8bc6cd6 | ||
|
|
7a4d287fe3 | ||
|
|
727cce07d6 | ||
|
|
41920b0f4b | ||
|
|
03ae162716 | ||
|
|
82abe08658 |
18
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
.github/ISSUE_TEMPLATE/submit-pet.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
44
.github/workflows/deploy-website.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: Deploy FamiliarOS Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy on VPS
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /home/dev/src/FamiliarOS
|
||||
|
||||
# Ensure we are on the latest main branch
|
||||
git fetch origin
|
||||
git checkout main || git checkout -b main origin/main
|
||||
git reset --hard origin/main
|
||||
|
||||
cd website
|
||||
|
||||
# Production build env
|
||||
export VITE_SUPERTOKENS_API_DOMAIN="${{ vars.VITE_SUPERTOKENS_API_DOMAIN || '' }}"
|
||||
export VITE_SUPERTOKENS_WEBSITE_DOMAIN="${{ vars.VITE_SUPERTOKENS_WEBSITE_DOMAIN || '' }}"
|
||||
export VITE_SUPERTOKENS_API_BASE_PATH="${{ vars.VITE_SUPERTOKENS_API_BASE_PATH || '/auth' }}"
|
||||
export VITE_SUPERTOKENS_WEBSITE_BASE_PATH="${{ vars.VITE_SUPERTOKENS_WEBSITE_BASE_PATH || '/auth' }}"
|
||||
export VITE_AUTH_API_BASE_URL="${{ vars.VITE_AUTH_API_BASE_URL || '' }}"
|
||||
export VITE_GITHUB_OAUTH_ENABLED="${{ vars.VITE_GITHUB_OAUTH_ENABLED || 'false' }}"
|
||||
export VITE_AUTH_API_TIMEOUT_MS="${{ vars.VITE_AUTH_API_TIMEOUT_MS || '8000' }}"
|
||||
export VITE_AUTH_HEALTH_TIMEOUT_MS="${{ vars.VITE_AUTH_HEALTH_TIMEOUT_MS || '6000' }}"
|
||||
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Deploy via the existing VPS deploy script (requires passwordless sudo or manual password entry)
|
||||
sudo /home/dev/familiaros-deploy/deploy-website.sh
|
||||
3
.gitignore
vendored
|
|
@ -9,10 +9,13 @@ apps/desktop/dist-electron
|
|||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
local/
|
||||
local-tools/
|
||||
.slim/deepwork/
|
||||
docs
|
||||
!website/docs
|
||||
*.diff
|
||||
MERGE_NOTES.md
|
||||
NEW_FILES.txt
|
||||
PULL_REQUEST.md
|
||||
README_OUR_CHANGES.md
|
||||
.gitnexus
|
||||
|
|
|
|||
1
.gitnexus/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*
|
||||
12
.sentrux/baseline.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"timestamp": 1781761968.167321,
|
||||
"quality_signal": 0.438779684727969,
|
||||
"coupling_score": 0.35494021397105097,
|
||||
"cycle_count": 3,
|
||||
"god_file_count": 0,
|
||||
"hotspot_count": 1,
|
||||
"complex_fn_count": 143,
|
||||
"max_depth": 17,
|
||||
"total_import_edges": 1589,
|
||||
"cross_module_edges": 824
|
||||
}
|
||||
78
.sentrux/rules.toml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
[constraints]
|
||||
no_god_files = true
|
||||
|
||||
[scan]
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"dist/**",
|
||||
"**/dist/**",
|
||||
"build",
|
||||
"build/**",
|
||||
"**/build/**",
|
||||
"coverage",
|
||||
"coverage/**",
|
||||
"**/coverage/**",
|
||||
".test-dist",
|
||||
".test-dist/**",
|
||||
"**/.test-dist/**",
|
||||
"dist-electron",
|
||||
"dist-electron/**",
|
||||
"dist-electron-win",
|
||||
"dist-electron-win/**",
|
||||
"website/dist",
|
||||
"website/dist/**",
|
||||
"website/coverage",
|
||||
"website/coverage/**"
|
||||
]
|
||||
|
||||
[[layers]]
|
||||
name = "shared-packages"
|
||||
paths = [
|
||||
"packages/*/src/*"
|
||||
]
|
||||
order = 0
|
||||
|
||||
[[layers]]
|
||||
name = "desktop-runtime"
|
||||
paths = [
|
||||
"apps/desktop/src/*",
|
||||
"apps/desktop/contracts/*",
|
||||
"apps/desktop/tests/*"
|
||||
]
|
||||
order = 1
|
||||
|
||||
[[layers]]
|
||||
name = "plugin-runtime"
|
||||
paths = [
|
||||
"plugins/official/*/*"
|
||||
]
|
||||
order = 2
|
||||
|
||||
[[layers]]
|
||||
name = "website-runtime"
|
||||
paths = [
|
||||
"website/src/*",
|
||||
"website/server/*"
|
||||
]
|
||||
order = 3
|
||||
|
||||
[[boundaries]]
|
||||
from = "website/src/*"
|
||||
to = "apps/desktop/src/*"
|
||||
reason = "The public site must not directly import Electron desktop runtime code. Shared logic belongs in packages or explicit API contracts."
|
||||
|
||||
[[boundaries]]
|
||||
from = "website/src/*"
|
||||
to = "plugins/official/*"
|
||||
reason = "The public site must not depend on packaged plugin runtime internals."
|
||||
|
||||
[[boundaries]]
|
||||
from = "packages/*/src/*"
|
||||
to = "apps/desktop/src/*"
|
||||
reason = "Reusable packages should remain host-agnostic and must not import desktop app internals."
|
||||
|
||||
[[boundaries]]
|
||||
from = "plugins/official/*"
|
||||
to = "apps/desktop/src/*"
|
||||
reason = "Official plugins must bind through the plugin SDK and host contracts, not desktop private modules."
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
140
AGENTS.md
|
|
@ -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,138 @@ 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.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **FamiliarOS** (11102 symbols, 28390 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/FamiliarOS/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/FamiliarOS/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/FamiliarOS/clusters` | All functional areas |
|
||||
| `gitnexus://repo/FamiliarOS/processes` | All execution flows |
|
||||
| `gitnexus://repo/FamiliarOS/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
## Overnight Mode agentic continuation and safe self-batching
|
||||
|
||||
Canonical authorities:
|
||||
|
||||
- `/home/dev/src/FamiliarOS/docs/ops/FAMILIAROS_OVERNIGHT_MODE_AGENTIC_CONTINUATION_SELF_BATCHING_AND_GOAL_CREATION_DOCTRINE_2026-06-19.md`
|
||||
- `/home/dev/src/FamiliarOS/docs/ops/FAMILIAROS_OVERNIGHT_MODE_CLOSEOUT_CONTINUATION_AND_BOUNDARY_SHAPE_2026-06-19.md`
|
||||
|
||||
Operational rule:
|
||||
|
||||
- the code word for this operating posture is `Overnight Mode`
|
||||
- `Continuity Mode` and `Disciplined Agentic Mode` are accepted synonyms for the same behavior, but `Overnight Mode` remains the canonical repo term
|
||||
- once `Overnight Mode` has been adopted for the repo workflow, it remains the default continuity posture unless the user gives newer explicit contrary direction; ordinary daytime interaction does not deactivate it
|
||||
- stacked `continue` prompts must be interpreted as permission to keep draining the already-declared active lane or already-declared next adjacent slice, not as permission to reinterpret the roadmap from scratch
|
||||
- future instances must continue to the natural close of the active lane rather than stopping at the first barely-acceptable packet boundary when one or two more adjacent sensible seams would materially complete the lane
|
||||
- the correct bias is surplus-with-discipline: carry the extra adjacent seam when it is real, bounded, source-backed, and closure-improving; never invent fluff or decorative widening
|
||||
- future instances must not drift sideways into a different donor family, different roadmap pillar, or unrelated code lane merely because the previous slice ended; this is the hard fresh-family boundary rule
|
||||
- if the next family is not already present-tense authority-backed, stop at the boundary, state it explicitly, and do not let stacked `continue` prompts coerce a speculative opening
|
||||
- if a real stop condition is active, stacked `continue` prompts do not override it; future instances must withstand and disobey those prompts until the blocker is actually resolved
|
||||
- FamiliarOS-specific GitNexus and refactoring safeguards remain higher-specificity guards and are not weakened by `Overnight Mode`
|
||||
|
|
|
|||
26
DESIGN.md
|
|
@ -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.
|
||||
|
|
|
|||
183
FEATURES.md
|
|
@ -1,11 +1,11 @@
|
|||
# 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`
|
||||
- the chat, memory, and prompt-window work from this implementation thread
|
||||
- the curated MCP toolkit surface added in the desktop app
|
||||
- the MCP Toolkit Guide surface added in the desktop app
|
||||
|
||||
> Looking for an isolated view of only our additions? See:
|
||||
> - [`FEATURES_OUR_CHANGES.md`](FEATURES_OUR_CHANGES.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,18 +51,42 @@ 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
|
||||
|
||||
## Virtual Pet Care
|
||||
## Familiar Text-to-Speech (Phase 2)
|
||||
|
||||
- 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 HUD appears only when summoned, so it never blocks the pet or chat
|
||||
- Triple-click the pet to show the status HUD for 6 seconds
|
||||
- Actions (feed, play, pet, nap) briefly display the HUD after updating stats
|
||||
- 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.
|
||||
- Provider credentials are stored with Electron `safeStorage` and fall back to plain local storage when encryption is unavailable (`apps/desktop/src/tts-credentials.ts`).
|
||||
- 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 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 familiar to speak text through the same TTS pipeline.
|
||||
|
||||
## Virtual Familiar Care
|
||||
|
||||
- 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
|
||||
|
||||
## Custom Familiar Name
|
||||
|
||||
- Settings → General includes a **Familiar name** field for the default Familiar
|
||||
- The name persists in `familiaros-state.json` and is applied through `validatePreferencePatch`
|
||||
- The chosen name appears in the tray tooltip and the tray context menu
|
||||
- The menu falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set
|
||||
|
||||
## 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 2–3 lines of text
|
||||
|
|
@ -86,9 +110,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
|
||||
|
|
@ -99,7 +123,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
|
||||
|
|
@ -124,12 +148,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
|
||||
|
|
@ -138,7 +162,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
|
||||
|
|
@ -151,21 +175,47 @@ 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`)
|
||||
|
||||
## OpenPets MCP Server
|
||||
## 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 `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 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
|
||||
|
||||
## FamiliarOS MCP Server (External-Agent Bridge)
|
||||
|
||||
This MCP server exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host. It is a small, controlled bridge: external agents can make your Familiar react, speak a safe bubble, or read/write memory, but they do not get unrestricted access to your desktop.
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
- Familiar routing selector in the central panel
|
||||
- One-click **Test Server** health check
|
||||
- One-click **Copy MCP JSON** for external hosts
|
||||
|
||||
## Agent Integrations
|
||||
|
||||
|
|
@ -182,21 +232,26 @@ It combines:
|
|||
- OpenCode plugin support
|
||||
- Cursor MCP config preview
|
||||
- Cursor rules preview
|
||||
- Pet routing per supported integration
|
||||
- Published package / bundled / local command modes for supported integrations
|
||||
- Command path overrides for relevant integrations
|
||||
- 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 FamiliarOS MCP Server panel
|
||||
|
||||
## Vanilla Chat MCP Tools
|
||||
## MCP Tool Servers (Built-in Chat Tools)
|
||||
|
||||
- Multi-select MCP tool activation inside the floating chat window
|
||||
The MCP Tool Servers panel gives the built-in floating chat the ability to do real work on your local machine. It is separate from the external-agent bridge and works without any external agent installed.
|
||||
|
||||
- Top-level **MCP Tool Servers** panel in Control Center → Integrations
|
||||
- Multi-select MCP tool activation for the built-in floating chat
|
||||
- Works without Claude, Cursor, OpenCode, or Pi installed
|
||||
- Tiered tool browser (Starter, Terminal & Systems, Advanced)
|
||||
- Save & Activate / Deactivate All controls
|
||||
- Tools run through an internal stdio MCP client in the main process
|
||||
- Supported tools: filesystem, terminal, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite
|
||||
|
||||
## Curated MCP Toolkit Surface
|
||||
## MCP Toolkit Guide Surface
|
||||
|
||||
The control-center Integrations page now also includes a curated **MCP Toolkit** panel. This does not pretend to be a full unsafe one-click installer for the whole MCP ecosystem. Instead, it provides a practical, permission-aware reference surface with copyable snippets and docs links.
|
||||
The control-center Integrations page includes an **MCP Toolkit Guide** panel. It is not an automatic installer; it is a hand-picked reference that explains what popular MCP tools do, what permissions they need, and how to set them up manually or as a persistent bundle in supported hosts. Each entry includes copy-paste snippets and guidance so you can adopt the MCP ecosystem deliberately.
|
||||
|
||||
### Starter Stack In The Toolkit
|
||||
|
||||
|
|
@ -259,14 +314,14 @@ The control-center Integrations page now also includes a curated **MCP Toolkit**
|
|||
## 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
|
||||
|
|
@ -279,7 +334,7 @@ The control-center Integrations page now also includes a curated **MCP Toolkit**
|
|||
- 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
|
||||
|
|
@ -288,15 +343,15 @@ The control-center Integrations page now also includes a curated **MCP Toolkit**
|
|||
- 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
|
||||
|
||||
|
|
@ -304,6 +359,6 @@ The control-center Integrations page now also includes a curated **MCP Toolkit**
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 2–3 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,35 @@ 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.
|
||||
|
||||
## Custom Familiar Name
|
||||
|
||||
- Settings → General includes a user-editable **Familiar name** field for the default Familiar.
|
||||
- The name persists in app state (`familiaros-state.json`) and is applied through `validatePreferencePatch`.
|
||||
- The chosen name appears in the tray tooltip and tray context menu.
|
||||
- Falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set.
|
||||
|
||||
## Familiar Text-to-Speech (Phase 2)
|
||||
|
||||
- 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.
|
||||
- Credentials stored with Electron `safeStorage` (plain fallback).
|
||||
- Per-provider model, voice, speed, endpoint preset, and custom endpoint.
|
||||
- Dynamic voice list fetching for ElevenLabs; static lists for OpenAI.
|
||||
- Test voice + Stop buttons in Settings.
|
||||
- Speech skips quiet hours, validates returned audio, and enforces HTTPS / localhost rules for endpoints.
|
||||
- Plugin SDK `voice:speak` permission lets plugins speak through the same pipeline.
|
||||
|
||||
## 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.
|
||||
|
|
@ -46,40 +66,58 @@ 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`).
|
||||
|
||||
## Vanilla Chat MCP Tools
|
||||
## MCP Tool Servers (Built-in Chat)
|
||||
|
||||
- Internal stdio MCP client in the desktop main process (`mcp-chat-client.ts`).
|
||||
- Multi-select tool activation in the floating chat window.
|
||||
- Top-level **MCP Tool Servers** panel in Control Center → Integrations.
|
||||
- Multi-select tool activation for the built-in floating chat, independent of external agents.
|
||||
- Tiered tool browser: Starter, Terminal & Systems, Advanced.
|
||||
- Save & Activate / Deactivate All controls.
|
||||
- ReAct-style tool-calling loop (max 5 iterations) with responses/chat-completions integration.
|
||||
- Supported tools: filesystem, terminal/shell, memory, fetch-web, sequential-thinking, playwright, git, github, docker, sqlite.
|
||||
|
||||
## Curated MCP Toolkit Integration
|
||||
## FamiliarOS MCP Server (External-Agent Bridge)
|
||||
|
||||
- Curated MCP Toolkit panel inside 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.
|
||||
- Familiar routing selector in the central panel.
|
||||
- One-click **Test Server** health check.
|
||||
- One-click **Copy MCP JSON** for external hosts.
|
||||
|
||||
## MCP Toolkit Guide Integration
|
||||
|
||||
- MCP Toolkit Guide panel inside Control Center → Integrations.
|
||||
- Hand-picked reference surface, not an automatic installer.
|
||||
- Each entry explains what the tool does, what permissions it needs, and how to set it up manually or as a persistent bundle in supported hosts.
|
||||
- Multi-select chips by tier (Starter, Terminal & Systems, Advanced).
|
||||
- Command Source labels clarify where tools come from:
|
||||
- Stable release (npm)
|
||||
- Shipped with this app
|
||||
- Local source build
|
||||
- Install Choice descriptions:
|
||||
- Manual Setup = copy-paste commands
|
||||
- Persistent Full Access = auto-register into Claude/Codex
|
||||
- Copy-paste snippets and docs links for deliberate adoption.
|
||||
- Removed redundant Curated Stack section in favor of direct tiered selection.
|
||||
|
||||
## Pet Scale & Sizing
|
||||
## Knowledge Store
|
||||
|
||||
- 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.
|
||||
- Local-first file + memory knowledge repository added to the desktop app.
|
||||
- Store files from Settings > Knowledge Store or directly from floating-chat attachments.
|
||||
- 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 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`).
|
||||
|
||||
## Familiar Scale & Sizing
|
||||
|
||||
- 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
|
||||
|
||||
|
|
@ -89,21 +127,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.
|
||||
- Vanilla Chat tool activation section in Integrations.
|
||||
- MCP Tool Servers panel in Integrations for built-in chat tool activation.
|
||||
- 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 HUD is hidden by default and never blocks pet clicks or double-click chat.
|
||||
- Triple-click the pet to show the HUD for 6 seconds.
|
||||
- Feed / play / pet / nap commands briefly show the HUD after updating stats.
|
||||
- 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
|
||||
|
|
@ -111,3 +150,8 @@ This document lists only the features, UI surfaces, and capabilities added by ou
|
|||
- Sharp Windows native binary packaging resolved via `asarUnpack` + post-build copy.
|
||||
- `prompt-window-preload.cjs` added for the floating chat renderer bridge.
|
||||
- Additional preload scripts and assets packaged for the new surfaces.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Floating-chat memory capture/retrieval is now more resilient: capture is logged and errors are caught instead of breaking the send path.
|
||||
- Long speech-bubble text no longer clips; bubble bodies scroll when content exceeds the bubble max-height.
|
||||
|
|
|
|||
203
FEATURE_REGISTRY.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# FamiliarOS Feature Registry
|
||||
|
||||
This is the canonical feature registry for the FamiliarOS working repo. 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:** `feat/familiaros-rebrand`
|
||||
- **Current HEAD:** `69d3046`
|
||||
- **Version:** `3.1.0`
|
||||
- **Status key:** `shipped` = in the signed Windows installer; `merged` = in `v3.1.0-integrated`; `in-review` = has a PR branch; `experimental` = behind a flag or not yet packaged.
|
||||
|
||||
---
|
||||
|
||||
## 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 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.16x–10x | 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/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. |
|
||||
| FamiliarOS rebrand | **merged** | `feat/familiaros-rebrand` | current | repo-wide | App identity, package scopes, plugin IDs, state filenames, preload names, and user-facing strings updated from OpenPets to FamiliarOS. |
|
||||
| Custom Familiar name | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/app-state.ts`, `apps/desktop/src/tray.ts`, `apps/desktop/src/renderer/src/main.tsx` | User-defined name for the default Familiar appears in tray tooltip and context menu. |
|
||||
| Curated familiar catalog safety | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/catalog.ts`, `apps/desktop/src/catalog-validation.ts`, `apps/desktop/catalog.v2.fixture.json`, `apps/desktop/contracts/catalog-fixture.contract.ts` | Curated catalog metadata now preserves `original` / `featured` / category fields end-to-end, uses IP-safer FamiliarOS fixture familiars, and keeps surfaceable pagination totals aligned with the real searchable set. |
|
||||
| Control Center bridge hardening | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/tests/plugin-ui-static.test.ts` | The Control Center API bridge now resolves to a required `ControlCenterApi` once at startup instead of leaking optionality through the whole renderer, and the static validation was updated to the stricter bridge shape. |
|
||||
| Control Center route modularization | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/control-center/*.tsx`, `apps/desktop/src/check-packaging-contract.ts`, `apps/desktop/tests/plugin-ui-static.test.ts` | The renderer shell now routes into dedicated dashboard, familiars, settings, integrations, and plugins modules, with shared bridge/types/UI extracted into `control-center/shared.tsx` so future Control Center work no longer has to funnel through one giant file. |
|
||||
|
||||
## 2. Floating chat / prompt window
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| Floating prompt window | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Frameless, always-on-top, compact/expanded modes. |
|
||||
| Chat history list UI | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts` | Styled per-entry cards with role badges/timestamps. |
|
||||
| Multi-conversation support | shipped | main PR | `08eff41` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/src/openapi-chat.ts` | Create/switch/delete conversations. |
|
||||
| Attachments in prompt window | shipped | `op6/chat-file-attachments` + `fix/prompt-window-hardening` | `ccb3224`, `c226280` | `apps/desktop/src/prompt-window.ts` | File picker, attachment chips, base64 data-URL reading. |
|
||||
| Auto-growing textarea | shipped | `fix/prompt-window-hardening` | `c226280` | `apps/desktop/src/prompt-window.ts` | Textarea fills resized window. |
|
||||
| Enter sends / Ctrl+Enter newline | shipped | `fix/prompt-window-hardening` | `c226280` | `apps/desktop/src/prompt-window.ts` | Keyboard behavior. |
|
||||
| Packaged-build inline-script newline fix | shipped | `fix/prompt-window` chain | `61da20d` | `apps/desktop/src/prompt-window.ts` | Escapes `\n` in generated inline script. |
|
||||
| Store attachments in Knowledge Store | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Toolbar button stores each attachment into Knowledge Store. |
|
||||
|
||||
## 3. OpenAPI BYOK chat
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| 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. |
|
||||
| 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/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/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
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| File + memory knowledge repository | shipped | `pr/knowledge-store-hardening` | `4aff369`, `f514960` | `apps/desktop/src/knowledge-store-core.ts`, `apps/desktop/src/knowledge-store.ts` | Electron-free core + wrapper. |
|
||||
| Text extraction + MIME inference | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | Text-like files only; non-text placeholder. |
|
||||
| Relevance search + scoring | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | Token/phrase overlap, recency, size penalty; false-positive guard added. |
|
||||
| Settings Knowledge Store tab | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/control-center-preload.cjs` | Upload, list, search, delete, add memory. |
|
||||
| Prompt-window store button | shipped | `pr/knowledge-store-hardening` | `f514960` | `apps/desktop/src/prompt-window.ts`, `apps/desktop/prompt-window-preload.cjs` | Store attachments from chat composer. |
|
||||
| Unit tests | shipped | `pr/knowledge-store-hardening` | `4aff369`, `d8650a9` | `apps/desktop/tests/knowledge-store.test.ts` | Deterministic Node tests. |
|
||||
| UTF-8 fallback | shipped | `pr/knowledge-store-hardening` | `4aff369` | `apps/desktop/src/knowledge-store-core.ts` | `TextDecoder` fallback when `Buffer.isUtf8` missing. |
|
||||
|
||||
## 6. MCP edition
|
||||
|
||||
### 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` | `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-familiar fallback | shipped | upstream | — | `packages/mcp/src/server.ts` | Baseline. |
|
||||
|
||||
### 6b. MCP Tool Servers (built-in chat)
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| Internal stdio MCP client | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-chat-client.ts` | Spawns tool servers inside floating chat. |
|
||||
| Top-level "MCP Tool Servers" panel | shipped | main PR | current | `apps/desktop/src/renderer/src/main.tsx` | Central place to activate tool servers for built-in chat, independent of external agents. |
|
||||
| Tool activation UI | shipped | main PR | `08eff41` / current | `apps/desktop/src/renderer/src/main.tsx` | Multi-select chips by tier. |
|
||||
| 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. FamiliarOS MCP Server (external-agent bridge)
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| 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.familiaros` entry. |
|
||||
|
||||
### 6d. MCP Toolkit Guide
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| Toolkit panel in Integrations | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Curated, permission-aware reference surface. |
|
||||
| Tiered family browser | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Starter / Terminal & Systems / Advanced. |
|
||||
| Install orchestration | shipped | main PR | `08eff41` | `apps/desktop/src/mcp-toolkit-installer.ts` | Persistent bundles for Claude/Codex baselines. |
|
||||
| Command-source labels | shipped | main PR | `08eff41` | `apps/desktop/src/renderer/src/mcp-toolkit-catalog.ts` | Stable release / shipped with app / local build. |
|
||||
|
||||
## 7. Agent integrations
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| Claude Code integration | shipped | upstream / main PR | — | `packages/claude/` | Baseline. |
|
||||
| OpenCode integration | shipped | upstream / main PR | — | `packages/opencode/` | Baseline. |
|
||||
| Cursor integration | shipped | upstream / main PR | — | `packages/cursor/` | Baseline. |
|
||||
| Pi guidance | shipped | upstream / main PR | — | `packages/pi/` | Baseline. |
|
||||
|
||||
## 8. Plugins / modding
|
||||
|
||||
| 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-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/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` | `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. |
|
||||
|
||||
## 10. Packaging / release
|
||||
|
||||
| Feature | Status | PR / Branch | Key commits | Key files | Notes |
|
||||
|---------|--------|-------------|-------------|-----------|-------|
|
||||
| Electron Builder setup | shipped | upstream / `pr/packaging-and-docs` | `743163d` | `apps/desktop/electron-builder.yml` | Windows NSIS + portable, Linux AppImage/deb/rpm/tar.gz, macOS dmg/zip. |
|
||||
| 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:\familiaros-build\apps\desktop\dist-electron\FamiliarOS-3.1.0-win-x64-setup.exe` | SHA256: `79b0c01b08d6121f54675c176a5d7c22d1f8e3591b191d760aae6d6300d4b5bb`. |
|
||||
|
||||
---
|
||||
|
||||
## Removed / replaced capabilities
|
||||
|
||||
| Capability | What happened | Replacement | Commits |
|
||||
|------------|---------------|-------------|---------|
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
## Documentation files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `FEATURES.md` | Full feature inventory for the fork. |
|
||||
| `FEATURES_OUR_CHANGES.md` | Isolated list of only our additions. |
|
||||
| `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/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` | OP1–OP6 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-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. |
|
||||
| `docs/pull-requests/PR_PACKAGING_AND_DOCS.md` | Packaging/docs PR. |
|
||||
| `docs/pull-requests/PROMPT_WINDOW_PACKAGED_BUILD_FIX_2026-06-15.md` | Prompt-window packaged-build fix root cause. |
|
||||
| `docs/pull-requests/PR_TTS_PHASE_2.md` | Text-to-Speech Phase 2 PR description. |
|
||||
| `docs/pr-chat-memory-mcp-toolkit.md` | Floating chat + memory + MCP toolkit explainer. |
|
||||
|
||||
---
|
||||
|
||||
## Branches on this repo
|
||||
|
||||
- `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/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`).
|
||||
2
LICENSE
|
|
@ -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
|
||||
|
|
|
|||
121
PULL_REQUEST.md
|
|
@ -1,6 +1,6 @@
|
|||
# Pull Request: Floating Chat, Memory, MCP Toolkit & Pet Sizing
|
||||
# Pull Request: FamiliarOS Rebrand, Custom Familiar Naming, Floating Chat, Memory, Knowledge Store, MCP Servers, MCP Toolkit Guide, TTS Phase 2 & Familiar Sizing
|
||||
|
||||
**Title:** feat(desktop): add floating chat, local memory, vanilla MCP tools, expanded pet sizing, and multi-screen support
|
||||
**Title:** feat(desktop): rebrand to FamiliarOS, add custom Familiar naming, floating chat, local memory, Knowledge Store, MCP Tool Servers, FamiliarOS MCP Server panel, MCP Toolkit Guide, Text-to-Speech Phase 2, expanded familiar sizing, and multi-screen support
|
||||
|
||||
**Author:** VectorShell working fork
|
||||
**Base:** upstream `main` at `cba81b7`
|
||||
|
|
@ -10,68 +10,114 @@
|
|||
|
||||
## 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 the FamiliarOS rebrand from OpenPets, a user-editable custom Familiar name, 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
|
||||
|
||||
### FamiliarOS rebrand
|
||||
|
||||
- Renamed the app, packages, plugin IDs, state files, preload files, and user-facing strings from OpenPets / pets to FamiliarOS / Familiars.
|
||||
- Updated `README.md`, `FEATURES.md`, `README_OUR_CHANGES.md`, `FEATURES_OUR_CHANGES.md`, and `PULL_REQUEST.md` to reflect the new brand and voice.
|
||||
|
||||
### Custom Familiar name
|
||||
|
||||
- Added a **Familiar name** field in Settings → General.
|
||||
- The name is persisted in `familiaros-state.json`, validated through `validatePreferencePatch`, and shown in the tray tooltip and context menu.
|
||||
- Falls back to the Familiar's display name or "Built-in Familiar" when no custom name is set.
|
||||
|
||||
### 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.
|
||||
|
||||
- **Vanilla Chat MCP Tool Activation**
|
||||
- New Integrations UI to activate MCP tools for use inside floating chat.
|
||||
- **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).
|
||||
|
||||
- **FamiliarOS MCP Server Panel**
|
||||
- New central Integrations card for the external-agent bridge.
|
||||
- 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 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.
|
||||
|
||||
### New core modules
|
||||
|
||||
- `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 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.
|
||||
- `apps/desktop/src/mcp-toolkit-installer.ts` — Toolkit install orchestration.
|
||||
- `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 HUD (`openpets.virtual-pet`) now appears only on triple-click and auto-hides, so it does not block double-click chat.
|
||||
- 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.
|
||||
- **Familiar name** field for the default Familiar.
|
||||
- Memory viewer with search/edit/delete.
|
||||
- **Knowledge Store** tab: upload files, search stored knowledge, delete files, and add manual memory entries.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Memory capture/retrieval is now more robust in floating chat: capture is logged and errors are caught instead of breaking the send path.
|
||||
- Speech bubble text no longer clips for long messages; bubble bodies scroll when content exceeds the bubble max-height.
|
||||
- Fixed a packaged-build `SyntaxError` in the floating prompt window's inline script: newline literals in attachment separator strings are now correctly escaped so the script parses in the signed Windows installer.
|
||||
- Verified the packaged prompt window with a Windows-hosted E2E smoke test over the reverse tunnel (CDP-driven: bridge present, input/send/attach elements render, typing enables send, no renderer exceptions).
|
||||
|
||||
### Knowledge Store integration
|
||||
|
||||
- Files stored in Settings > Knowledge Store are indexed and can be searched from the same panel.
|
||||
- Stored files are automatically considered when building OpenAPI chat instructions: relevant files are appended to the system prompt.
|
||||
- Attachments in the floating prompt window can be stored directly into the Knowledge Store via the toolbar button.
|
||||
|
||||
### Updated files
|
||||
|
||||
- `README.md` — added floating chat, memory, and vanilla chat tool bullets.
|
||||
- `README.md` — added floating chat, memory, MCP Tool Servers, FamiliarOS MCP Server, and MCP Toolkit Guide 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` — triple-click detection (`pet:tripleClicked`) and double-click chat handling.
|
||||
- `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.
|
||||
|
|
@ -88,35 +134,50 @@ 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
|
||||
apps/desktop/src/knowledge-store.ts
|
||||
apps/desktop/tests/knowledge-store.test.ts
|
||||
apps/desktop/src/tts-service.ts
|
||||
apps/desktop/src/tts-engine.ts
|
||||
apps/desktop/src/tts-catalog.ts
|
||||
apps/desktop/src/tts-credentials.ts
|
||||
apps/desktop/tests/tts-engine.test.ts
|
||||
```
|
||||
|
||||
## How to test
|
||||
|
||||
```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 Toolkit — activate Filesystem + Fetch/Web, then chat "list my home directory files" — tools should run.
|
||||
7. Drag the pet's bottom-right handle — scale should change smoothly.
|
||||
8. Change display — pet should stay on the display it is currently near.
|
||||
6. Open Integrations > MCP Tool Servers — activate Filesystem + Fetch/Web, then chat "list my home directory files" — tools should run.
|
||||
7. Open Integrations > FamiliarOS MCP Server — choose a command mode, click Test Server, and verify the server responds.
|
||||
8. Open Integrations > MCP Toolkit Guide to browse copy-paste snippets and optional persistent bundles.
|
||||
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 familiar → choose Virtual Familiar ▸ Feed/Play/Familiar/Nap; stats update and the context menu reflects the new values.
|
||||
13. Change the **Familiar name** in Settings → General; the tray tooltip and tray menu should immediately show the new name.
|
||||
14. Change display — familiar should stay on the display it is currently near.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- Existing MCP server behavior is unchanged.
|
||||
- Existing agent integrations (Claude, OpenCode, Cursor, Pi) are unchanged.
|
||||
- Existing plugin system is unchanged.
|
||||
- TTS defaults to the system voice; no API key is required until a cloud provider is chosen.
|
||||
- Preferences default safely: users without API keys see a clear "Add credential" state.
|
||||
|
||||
## Notes for upstream
|
||||
|
|
@ -128,5 +189,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
|
||||
|
|
|
|||
422
README.md
|
|
@ -1,21 +1,25 @@
|
|||
<p align="center">
|
||||
<img src="assets/openpets.png" alt="OpenPets - pixel art desktop companion" width="100%" />
|
||||
<img src="assets/familiaros.png" alt="FamiliarOS" width="100%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A tiny desktop companion that keeps you company.</strong>
|
||||
<strong>Create your Familiar.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A playful pet that lives on your desktop, keeps you company, and can grow with bundled abilities and developer integrations.
|
||||
Your Familiar remembers, speaks, and acts across your system.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>This branch also includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>vanilla MCP tools</strong>, and <strong>expanded pet sizing</strong>. See <code>FEATURES.md</code> and <code>PULL_REQUEST.md</code> for details.</sub>
|
||||
<sub>A local-first AI companion with memory, voice, knowledge, and tools.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/intro.png" alt="OpenPets reacting across multiple coding agent sessions" width="100%" />
|
||||
<sub>This fork includes a <strong>floating chat surface</strong>, <strong>local memory</strong>, <strong>Knowledge Store</strong>, <strong>MCP Tool Servers</strong>, a <strong>FamiliarOS MCP Server</strong> panel, an <strong>MCP Toolkit Guide</strong>, <strong>Text-to-Speech</strong>, <strong>Familiar naming</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="FamiliarOS reacting across multiple coding agent sessions" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
|
@ -27,380 +31,174 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What makes FamiliarOS different
|
||||
|
||||
FamiliarOS is a **local-first AI companion platform**.
|
||||
|
||||
Create your own Familiar: a persistent desktop companion with memory, voice, knowledge, and powerful agent capabilities.
|
||||
|
||||
> **Not just an assistant. A Familiar.**
|
||||
|
||||
Your Familiar can:
|
||||
|
||||
- 🧠 **Remember** preferences, facts, notes, identities, and important details across sessions.
|
||||
- 🗣 **Speak and listen** with system voices, OpenAI, ElevenLabs, Piper, or OpenAI-compatible TTS providers.
|
||||
- 📚 **Learn from your files** through the searchable Knowledge Store.
|
||||
- 🔧 **Use MCP tools** such as filesystem access, terminal commands, web retrieval, Git, GitHub, Docker, Playwright, SQLite, memory systems, and reasoning tools.
|
||||
- 💬 **Chat** through an always-on-top floating window.
|
||||
- 🐾 **Interact** as a virtual companion with reactions, animations, and care actions.
|
||||
|
||||
Part virtual companion. Part AI assistant. Part autonomous agent. Entirely yours.
|
||||
|
||||
> **The best AI isn't just a tool you open. It's a companion that stays.**
|
||||
|
||||
---
|
||||
|
||||
## 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/verticaltension/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 verticaltension/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
|
||||
---
|
||||
|
||||
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.
|
||||
## Create your Familiar
|
||||
|
||||
A Familiar is more than a chatbot. More than a virtual companion. More than an assistant.
|
||||
|
||||
A Familiar is a persistent AI companion that lives on your desktop, remembers what matters, learns how you work, and grows alongside you.
|
||||
|
||||
- **Give it a personality.** Set a character prompt in Settings.
|
||||
- **Name it.** Your Familiar appears by name in the tray and menus.
|
||||
- **Let it remember.** Memory is captured automatically from chat and can be edited or searched in Settings.
|
||||
- **Upload knowledge.** Drop files into the Knowledge Store and your Familiar can retrieve relevant information during future conversations.
|
||||
- **Talk to it.** Text or voice — your Familiar can listen and speak back.
|
||||
|
||||
You stop thinking about models, providers, APIs, prompts, and infrastructure. Instead you think:
|
||||
|
||||
> "My Familiar reminded me."
|
||||
> "My Familiar found that file."
|
||||
> "My Familiar summarized that document."
|
||||
> "My Familiar remembers how I like things done."
|
||||
|
||||
The best software creates habits. The best companions create relationships.
|
||||
|
||||
---
|
||||
|
||||
## What FamiliarOS can do
|
||||
|
||||
- **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 add ambient check-ins, break nudges, playful Familiar actions, focus timers, safe little walks, and optional developer notifications.
|
||||
- **Floating chat** — double-click your Familiar to open an always-on-top chat window with BYOK OpenAPI-compatible providers, conversation history, and optional MCP Tool Server activation.
|
||||
- **Local memory** — the Familiar remembers facts, preferences, and notes across sessions.
|
||||
- **Knowledge Store** — upload documents, notes, code, and reference material; your Familiar retrieves relevant context automatically.
|
||||
- **Voice** — system voices, OpenAI TTS, ElevenLabs, Piper, or OpenAI-compatible endpoints for speech input and output.
|
||||
- **MCP Tool Servers** — activate filesystem, terminal, web, Git, GitHub, Docker, Playwright, SQLite, memory, and reasoning tools directly from chat.
|
||||
- **FamiliarOS MCP Server** — external agents (Claude Code, Cursor, OpenCode, Codex CLI) can control your Familiar through a safe, limited tool surface.
|
||||
- **MCP Toolkit Guide** — a hand-picked guide to popular MCP tools: what each does, what permissions it needs, and how to add it yourself. Nothing installs automatically.
|
||||
- **Virtual-Familiar status** — hunger, energy, happiness, bond, mood, and level live in the right-click context menu, never blocking chat clicks.
|
||||
- **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 Familiars
|
||||
|
||||
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/star-repo.gif" alt="Starring the OpenPets repository" width="100%" />
|
||||
<img src="assets/manage-familiars.png" alt="Managing Familiars in the FamiliarOS desktop app" width="100%" />
|
||||
</p>
|
||||
|
||||
## What is OpenPets?
|
||||
|
||||
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.
|
||||
|
||||
- **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 activation.
|
||||
- **Virtual-pet status** - triple-click the pet to briefly show hunger, energy, happiness, and bond bars; the HUD auto-hides so it never interferes with chat.
|
||||
- **Local memory** - the pet remembers facts, preferences, and notes across sessions.
|
||||
- **Pet-pack friendly** - loads installed animated pet packs and can route a selected agent/project to its own pet window.
|
||||
- **Privacy-conscious by design** - automatic hook speech is static and local; prompts, code, logs, command output, URLs, paths, and secrets are not shown in bubbles.
|
||||
|
||||
## Manage your pets
|
||||
|
||||
Browse installed pets, preview their animations, and choose which companion should follow each coding agent from the OpenPets desktop app.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/manage-pets.png" alt="Managing pets in the OpenPets 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/verticaltension/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:
|
||||
FamiliarOS can be driven by Claude Code, OpenCode, Cursor, Pi, or any MCP-capable agent. The desktop app has a central **FamiliarOS MCP Server** panel under **Integrations** where you can choose the command source, override the Node path, pick which Familiar the agent controls, test the server, and copy the MCP JSON.
|
||||
|
||||
- **Claude Code** - installs OpenPets MCP, Claude memory instructions, and optional Claude hooks.
|
||||
- **OpenCode** - installs OpenPets MCP, an OpenCode instruction file, and the `@open-pets/opencode` plugin.
|
||||
- **MCP Toolkit** - curated setup guidance for Filesystem, Git, GitHub, Playwright, Browser Use, Memory, Fetch, Docker, and adjacent terminal/system MCP layers.
|
||||
For detailed setup, see [`docs/claude-integration.md`](docs/claude-integration.md), [`docs/opencode.md`](docs/opencode.md), and [`docs/cursor.md`](docs/cursor.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/integrations.png" alt="OpenPets desktop integrations screen" width="100%" />
|
||||
</p>
|
||||
---
|
||||
|
||||
For project-local setup, run the CLI from the project you want to configure:
|
||||
## MCP in plain English
|
||||
|
||||
```bash
|
||||
npx -y @open-pets/cli@latest configure --agent claude --pet <petId>
|
||||
npx -y @open-pets/cli@latest configure --agent opencode --pet <petId>
|
||||
```
|
||||
FamiliarOS includes three MCP-related surfaces. They are separate on purpose:
|
||||
|
||||
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`.
|
||||
### MCP Tool Servers — tools *for* your Familiar's chat
|
||||
|
||||
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.
|
||||
The **MCP Tool Servers** panel lets the built-in floating chat do real work. Turn on tools such as filesystem, terminal, web fetch, GitHub, SQLite, Docker, or Playwright, then ask your Familiar things like *"List the files in my home directory"* or *"Summarize that PDF in my Knowledge Store."* The tools run inside the desktop app; no external agent is required.
|
||||
|
||||
## Advanced: agent integrations
|
||||
### FamiliarOS MCP Server — your Familiar as a tool *for* external agents
|
||||
|
||||
OpenPets integrations have three layers:
|
||||
The **FamiliarOS MCP Server** panel exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host. Those agents can then make your Familiar react, speak a safe bubble, or read/write memory. This is a small, controlled bridge — external agents do not get unrestricted access to your desktop.
|
||||
|
||||
1. **MCP tools** for explicit agent actions.
|
||||
2. **Agent instructions** so agents know when to use those tools.
|
||||
3. **Hooks/plugins** for automatic decorative reactions during normal agent work.
|
||||
### MCP Toolkit Guide — a safe manual for the wider MCP world
|
||||
|
||||
### Claude Code
|
||||
The **MCP Toolkit Guide** is not an automatic installer. It is a hand-picked reference that explains what popular MCP tools do, what permissions they need, and how to set them up manually or as a persistent bundle in supported hosts. It is designed so you can adopt the MCP ecosystem deliberately instead of clicking "install everything."
|
||||
|
||||
Claude Code integration supports:
|
||||
|
||||
- `openpets` MCP setup via Claude Code.
|
||||
- Managed Claude memory instructions in `~/.claude/CLAUDE.md` and `~/.claude/openpets.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.
|
||||
|
||||
Typical global MCP command shape:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest
|
||||
```
|
||||
|
||||
With a selected pet:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user openpets -- npx -y @open-pets/mcp@latest --pet <petId>
|
||||
```
|
||||
|
||||
See [`docs/claude-integration.md`](docs/claude-integration.md) for the full file layout, hook mapping, project-local behavior, and safety rules.
|
||||
|
||||
### OpenCode
|
||||
|
||||
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.
|
||||
- Global desktop setup and project-local `.opencode` setup.
|
||||
|
||||
Project-local setup:
|
||||
|
||||
```bash
|
||||
npx -y @open-pets/cli@latest configure --agent opencode --pet <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.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openpets": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@open-pets/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To target a specific installed non-default pet:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openpets": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@open-pets/mcp@latest", "--pet", "<petId>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available MCP tools:
|
||||
|
||||
- `openpets_status` - check whether OpenPets is reachable and which pet is targeted.
|
||||
- `openpets_react` - set a short reaction on the target pet.
|
||||
- `openpets_say` - show a short safe speech bubble, optionally with a reaction.
|
||||
- `openpets_memory_list` - list recent long-term memories stored by OpenPets.
|
||||
- `openpets_memory_search` - search pet memory for relevant facts, preferences, or notes.
|
||||
- `openpets_memory_store` - store a durable memory for the pet.
|
||||
- `openpets_memory_forget` - delete a stored memory by id.
|
||||
|
||||
`openpets_say` messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths.
|
||||
|
||||
### Curated MCP toolkit
|
||||
|
||||
The desktop app also includes a manual-but-curated **MCP Toolkit** panel under **Integrations**. It is there to help you build a practical broader stack without stuffing OpenPets full of unsafe one-click third-party installs.
|
||||
|
||||
The toolkit now explicitly offers two paths:
|
||||
|
||||
- `Manual Setup` - per-tool guidance, docs links, and copyable snippets
|
||||
- `Persistent Full Access` - `Install Now` for the supported persistent baseline in hosts such as Claude Code or Codex CLI, plus a matching copyable bundle
|
||||
- `Vanilla Chat Tools` - activate MCP tools directly inside the floating chat window for autonomous filesystem, terminal, web, and browser tasks
|
||||
|
||||
The current supported `Install Now` baseline covers:
|
||||
|
||||
- Filesystem
|
||||
- Playwright
|
||||
- Memory
|
||||
- Context7 / Docs
|
||||
- Fetch / Web when `uvx` is available
|
||||
- Sequential Thinking
|
||||
- Browser Use when `uvx` is available, with its own runtime credential still required
|
||||
|
||||
The current recommended starter set is:
|
||||
|
||||
- Filesystem
|
||||
- Git
|
||||
- GitHub
|
||||
- Playwright
|
||||
- Browser Use
|
||||
- SQLite / PostgreSQL
|
||||
- Memory
|
||||
- Fetch / Web
|
||||
- Sequential Thinking
|
||||
- Docker MCP Toolkit
|
||||
|
||||
It also maps the next terminal-oriented layers that usually matter once your workflow grows up a bit:
|
||||
|
||||
- Shell / Terminal
|
||||
- Process & Logs
|
||||
- System Info
|
||||
- SSH
|
||||
- Package Manager
|
||||
- Tmux
|
||||
- Kubernetes / Cloud
|
||||
- CI / CD
|
||||
- Ghidra
|
||||
- Binary Analysis
|
||||
- Network Analysis
|
||||
|
||||
## How it works
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Reactions and speech
|
||||
|
||||
Automatic hooks are decorative and best-effort. They do not approve, deny, block, or change agent behavior.
|
||||
|
||||
Common reaction mapping:
|
||||
|
||||
| Agent activity | Reaction |
|
||||
| --- | --- |
|
||||
| Prompt/chat starts | `thinking` |
|
||||
| File edit/write/patch | `editing` |
|
||||
| Test-like shell command | `testing` |
|
||||
| Permission request | `waiting` |
|
||||
| Successful idle/stop | `success` |
|
||||
| Session/error stop | `error` |
|
||||
|
||||
Generic shell activity is intentionally quiet by default. Hook/plugin speech is throttled and selected from local static message pools such as `Approval needed` or `Something failed`.
|
||||
|
||||
### 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`.
|
||||
|
||||
```bash
|
||||
pi install npm:@open-pets/pi
|
||||
pi install -l npm:@open-pets/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.
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 11+
|
||||
- TypeScript
|
||||
|
||||
No Bun runtime is required for development.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @familiaros/desktop build
|
||||
pnpm --filter @familiaros/desktop test
|
||||
```
|
||||
|
||||
### Run the desktop app
|
||||
See `FEATURES.md` and `FEATURE_REGISTRY.md` for a full capability map.
|
||||
|
||||
```bash
|
||||
pnpm dev:desktop
|
||||
```
|
||||
---
|
||||
|
||||
Equivalent package command:
|
||||
## License
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-pets/desktop dev
|
||||
```
|
||||
|
||||
### Checks
|
||||
|
||||
```bash
|
||||
pnpm check
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
OpenPets currently uses lightweight Node contract checks instead of a full test framework. See [`docs/testing.md`](docs/testing.md).
|
||||
|
||||
### Package desktop builds
|
||||
|
||||
```bash
|
||||
pnpm package:desktop:dir
|
||||
pnpm package:desktop
|
||||
```
|
||||
|
||||
Release process details live in [`docs/release.md`](docs/release.md).
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```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/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
|
||||
plugins/official First-party plugin source (bundled + catalog)
|
||||
docs/ Documentation
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`docs/plugins.md`](docs/plugins.md) - plugin platform (SDK v3): manifest, permissions, dev workflow, test kit.
|
||||
- [`docs/superplugins.md`](docs/superplugins.md) - SDK v3 design rationale and feature spec.
|
||||
- [`docs/claude-integration.md`](docs/claude-integration.md) - Claude Code setup, MCP, memory, hooks, and safety.
|
||||
- [`docs/opencode.md`](docs/opencode.md) - OpenCode global/project setup, plugin behavior, and safety.
|
||||
- [`docs/wsl-ipc.md`](docs/wsl-ipc.md) - Windows desktop + WSL MCP transport setup.
|
||||
- [`docs/testing.md`](docs/testing.md) - test/check strategy.
|
||||
- [`docs/release.md`](docs/release.md) - desktop release process.
|
||||
- [`docs/workflow.md`](docs/workflow.md) - project workflow notes.
|
||||
|
||||
## Safety and privacy notes
|
||||
|
||||
- OpenPets 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.
|
||||
- Speech validation rejects code-like, secret-like, URL-like, path-like, or multiline messages.
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -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,48 +16,98 @@ 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: Vanilla Chat MCP Tools
|
||||
## New: Knowledge Store
|
||||
|
||||
Activate MCP tools directly inside the floating chat window for autonomous task execution.
|
||||
Store files and add manual knowledge entries that the assistant can reference during chat.
|
||||
|
||||
- Filesystem, Terminal, Memory, Fetch/Web, Sequential Thinking, Playwright, Git, GitHub, Docker, SQLite.
|
||||
- Tiered browser in Settings/Integrations: Starter, Terminal & Systems, Advanced.
|
||||
- Tools run through an internal stdio MCP client in the main process.
|
||||
- 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 FamiliarOS memories.
|
||||
|
||||
## New: Pet Scale
|
||||
## New: MCP Tool Servers — Tools for Your Familiar's Chat
|
||||
|
||||
The **MCP Tool Servers** panel gives the built-in floating chat the ability to do real work on your local machine. It is separate from the external-agent bridge.
|
||||
|
||||
- Activate tools such as filesystem, terminal, web fetch, sequential thinking, Playwright, Git, GitHub, Docker, and SQLite.
|
||||
- The tools run through an internal stdio MCP client in the main process.
|
||||
- Once enabled, you can ask your Familiar things like *"List the files in my home directory,"* *"Summarize that PDF,"* or *"Run this test script."*
|
||||
- Tools can be turned on or off individually, so you only grant the access you actually want.
|
||||
|
||||
## New: FamiliarOS MCP Server Panel — Your Familiar as a Tool for External Agents
|
||||
|
||||
The **FamiliarOS MCP Server** panel exposes your Familiar to Claude Code, Cursor, OpenCode, Codex CLI, or any other MCP-capable host.
|
||||
|
||||
- Command mode selector (published / bundled / local) in one place.
|
||||
- Node path override and Familiar routing in one place.
|
||||
- One-click **Test Server** health check.
|
||||
- One-click **Copy MCP JSON** for pasting into your agent's MCP configuration.
|
||||
- This is a small, controlled bridge: external agents can make your Familiar react, speak a safe bubble, or read/write memory, but they do not get unrestricted access to your desktop.
|
||||
|
||||
## New: MCP Toolkit Guide — A Safe Manual for the Wider MCP World
|
||||
|
||||
The **MCP Toolkit Guide** is a hand-picked reference surface for adopting the broader MCP ecosystem deliberately.
|
||||
|
||||
- It is **not** an automatic installer; it explains what popular MCP tools do, what permissions they need, and how to set them up manually.
|
||||
- Each entry includes copy-paste snippets and guidance for supported hosts.
|
||||
- Use it to build a persistent tool bundle or to understand a tool before you enable it.
|
||||
|
||||
## 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.
|
||||
- Triple-click the pet to briefly show the status HUD; it auto-hides so double-click chat stays unobstructed.
|
||||
- Feed, play, pet, and nap commands are available from the pet context menu and briefly display the HUD.
|
||||
- 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: Custom Familiar Name
|
||||
|
||||
- Name your default Familiar in Settings → General.
|
||||
- The chosen name appears in the tray tooltip, tray context menu, and anywhere the default Familiar is referenced.
|
||||
- If no name is set, the menu falls back to the Familiar's display name or "Built-in Familiar."
|
||||
|
||||
|
||||
## New: Text-to-Speech (Phase 2)
|
||||
|
||||
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 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:
|
||||
|
||||
- Double-click the pet → floating chat.
|
||||
- Triple-click the pet → virtual-pet status HUD.
|
||||
- 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.
|
||||
- Integrations → MCP Toolkit for tool activation.
|
||||
- Settings → Knowledge Store for file-based knowledge.
|
||||
- Integrations → MCP Tool Servers for built-in chat tool activation.
|
||||
- Integrations → FamiliarOS MCP Server for external-agent bridge configuration.
|
||||
- Integrations → MCP Toolkit Guide for optional persistent bundles and copy-paste snippets.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 397 KiB |
|
|
@ -1,41 +1,51 @@
|
|||
{
|
||||
"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"
|
||||
"id": "lantern",
|
||||
"displayName": "Lantern",
|
||||
"description": "A calm desk companion with a soft glow and rounded paper-cut silhouette.",
|
||||
"preview": "https://familiaros.dev/familiars/lantern-23e05847/spritesheet.webp",
|
||||
"zip": "https://zip.familiaros.dev/familiars/lantern-23e05847/lantern.zip",
|
||||
"category": "western",
|
||||
"original": true
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"id": "moss",
|
||||
"displayName": "Moss",
|
||||
"description": "A tiny green studio spirit with leaf-soft idle motion and quiet focus energy.",
|
||||
"preview": "https://familiaros.dev/familiars/moss-904b393f/spritesheet.webp",
|
||||
"zip": "https://zip.familiaros.dev/familiars/moss-904b393f/moss.zip",
|
||||
"category": "western",
|
||||
"original": true
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"id": "orbit",
|
||||
"displayName": "Orbit",
|
||||
"description": "A compact star-tracker familiar with a gentle scan loop and bright focus ring.",
|
||||
"preview": "https://familiaros.dev/familiars/orbit-de2f300f/spritesheet.webp",
|
||||
"zip": "https://zip.familiaros.dev/familiars/orbit-de2f300f/orbit.zip",
|
||||
"category": "western",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"id": "ember",
|
||||
"displayName": "Ember",
|
||||
"description": "A warm little spark familiar with crisp motion beats for active work states.",
|
||||
"preview": "https://familiaros.dev/familiars/ember-779d5202/spritesheet.webp",
|
||||
"zip": "https://zip.familiaros.dev/familiars/ember-779d5202/ember.zip",
|
||||
"category": "asian",
|
||||
"original": true
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"id": "rivulet",
|
||||
"displayName": "Rivulet",
|
||||
"description": "A flowing ink-drop companion built for softer waiting, review, and memory moments.",
|
||||
"preview": "https://familiaros.dev/familiars/rivulet-3f6746e0/spritesheet.webp",
|
||||
"zip": "https://zip.familiaros.dev/familiars/rivulet-3f6746e0/rivulet.zip",
|
||||
"category": "asian",
|
||||
"featured": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -3,16 +3,25 @@ import { readFile } from "node:fs/promises";
|
|||
import { validateCatalogV2 } from "../src/catalog-validation.js";
|
||||
|
||||
const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown;
|
||||
validateCatalogV2(fixture);
|
||||
const validated = validateCatalogV2(fixture);
|
||||
if (!validated.familiars.some((familiar) => familiar.original === true)) {
|
||||
throw new Error("Catalog fixture lost original familiar metadata.");
|
||||
}
|
||||
if (!validated.familiars.some((familiar) => familiar.featured === true)) {
|
||||
throw new Error("Catalog fixture lost featured familiar metadata.");
|
||||
}
|
||||
if (!validated.familiars.every((familiar) => familiar.category === "western" || familiar.category === "asian")) {
|
||||
throw new Error("Catalog fixture lost category metadata.");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,69 +1,83 @@
|
|||
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),
|
||||
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),
|
||||
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("familiarOSControlCenter", api);
|
||||
contextBridge.exposeInMainWorld("openPetsControlCenter", api);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -26,6 +26,12 @@ extraResources:
|
|||
asar: true
|
||||
asarUnpack:
|
||||
- node_modules/**
|
||||
- control-center-preload.cjs
|
||||
- familiar-preload.cjs
|
||||
- prompt-window-preload.cjs
|
||||
- plugin-sdk-preload.cjs
|
||||
- plugin-command-form-preload.cjs
|
||||
- panel-preload.cjs
|
||||
npmRebuild: false
|
||||
publish: null
|
||||
|
||||
|
|
@ -53,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
|
||||
|
|
@ -69,4 +75,4 @@ linux:
|
|||
- rpm
|
||||
- tar.gz
|
||||
category: Utility
|
||||
maintainer: OpenPets
|
||||
maintainer: FamiliarOS
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ let scaling = false;
|
|||
let scaleStartY = 0;
|
||||
let scaleStartValue = 0.56;
|
||||
let scalePreviewTimer = null;
|
||||
const dragThreshold = 4;
|
||||
|
||||
const dismissBubble = (event) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
|
|
@ -27,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;
|
||||
}
|
||||
|
|
@ -59,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;
|
||||
}
|
||||
|
|
@ -75,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;
|
||||
}
|
||||
|
|
@ -94,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, .scale-handle");
|
||||
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") => {
|
||||
|
|
@ -128,19 +129,15 @@ const applyScalePreview = (scale) => {
|
|||
};
|
||||
|
||||
const sendScalePreview = (scale) => {
|
||||
if (scalePreviewTimer) return;
|
||||
scalePreviewTimer = setTimeout(() => {
|
||||
scalePreviewTimer = null;
|
||||
}, 120);
|
||||
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, .scale-handle")) || 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) -------------------
|
||||
|
|
@ -162,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]");
|
||||
|
|
@ -170,54 +167,80 @@ 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 = () => {
|
||||
let clickCount = 0;
|
||||
let clickTimer = null;
|
||||
let firstClickEvent = null;
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flushClicks = () => {
|
||||
resetClickTimer();
|
||||
if (clickCount === 1) {
|
||||
sendPetEvent("familiar:clicked", {});
|
||||
} else if (clickCount === 2 && firstClickEvent) {
|
||||
sendPetEvent("familiar:doubleClicked", {});
|
||||
requestPromptWindow(firstClickEvent);
|
||||
}
|
||||
clickCount = 0;
|
||||
firstClickEvent = null;
|
||||
};
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
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;
|
||||
sendPetEvent("pet:clicked", {});
|
||||
});
|
||||
document.addEventListener("dblclick", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || !target.closest(".pet-hitbox, .pet-shell")) return;
|
||||
sendPetEvent("pet:doubleClicked", {});
|
||||
|
||||
clickCount += 1;
|
||||
if (clickCount === 1) {
|
||||
firstClickEvent = event;
|
||||
}
|
||||
resetClickTimer();
|
||||
|
||||
clickTimer = setTimeout(flushClicks, 200);
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -228,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; }
|
||||
|
|
@ -270,13 +293,24 @@ 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("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`);
|
||||
if (typeof layout.bubbleMaxHeightLong === "number") root.style.setProperty("--bubble-max-height-long", `${layout.bubbleMaxHeightLong}px`);
|
||||
if (typeof layout.bubbleMaxHeightVeryLong === "number") root.style.setProperty("--bubble-max-height-very-long", `${layout.bubbleMaxHeightVeryLong}px`);
|
||||
document.querySelectorAll(".bubble").forEach((bubble) => {
|
||||
bubble.classList.toggle("is-below", layout.bubbleBelow === true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Plugin audio (named WebAudio recipes + bundled data URLs) ---------------
|
||||
|
||||
let audioContext = null;
|
||||
|
|
@ -286,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 */ }
|
||||
|
|
@ -308,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));
|
||||
|
|
@ -351,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 = [];
|
||||
|
|
@ -361,8 +395,11 @@ ipcRenderer.on("openpets:stop-audio", () => {
|
|||
|
||||
// --- Plugin TTS ---------------------------------------------------------------
|
||||
|
||||
ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
|
||||
let currentTtsAudio = null;
|
||||
|
||||
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
|
||||
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
|
||||
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
|
||||
|
|
@ -374,10 +411,37 @@ ipcRenderer.on("openpets:tts-speak", (_event, payload) => {
|
|||
} catch { /* tts is best-effort */ }
|
||||
});
|
||||
|
||||
ipcRenderer.on("openpets:tts-stop", () => {
|
||||
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch { /* noop */ }
|
||||
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (!payload || !payload.audio || !payload.mimeType) return;
|
||||
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
|
||||
const blob = new Blob([buffer], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentTtsAudio = new Audio(url);
|
||||
currentTtsAudio.addEventListener("ended", () => {
|
||||
URL.revokeObjectURL(url);
|
||||
currentTtsAudio = null;
|
||||
});
|
||||
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
|
||||
} catch { /* tts is best-effort */ }
|
||||
});
|
||||
|
||||
ipcRenderer.on("familiaros:tts-stop", () => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
function stopCurrentTtsAudio() {
|
||||
if (currentTtsAudio) {
|
||||
try { currentTtsAudio.pause(); } catch { /* noop */ }
|
||||
try { currentTtsAudio.src = ""; } catch { /* noop */ }
|
||||
currentTtsAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
const installMouseInterop = () => {
|
||||
lastInteractiveHit = null;
|
||||
dragging = false;
|
||||
|
|
@ -387,16 +451,28 @@ const installMouseInterop = () => {
|
|||
if (handleBubbleInteraction(event)) return;
|
||||
dismissBubble(event);
|
||||
});
|
||||
document.addEventListener("dblclick", requestPromptWindow);
|
||||
installPetSenses();
|
||||
|
||||
let dragStartPoint = null;
|
||||
let dragCandidate = null;
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
updateInteractiveHit(event);
|
||||
if (dragging) ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY });
|
||||
if (dragging) {
|
||||
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;
|
||||
if (Math.hypot(dx, dy) > dragThreshold) {
|
||||
dragging = true;
|
||||
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
|
||||
dragCandidate = null;
|
||||
setInteractiveHit(true);
|
||||
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
|
||||
}
|
||||
}
|
||||
if (scaling) {
|
||||
const newScale = scaleStartValue + (event.clientY - scaleStartY) * 0.003;
|
||||
const newScale = scaleStartValue + (event.screenY - scaleStartY) * 0.003;
|
||||
const clamped = applyScalePreview(newScale);
|
||||
sendScalePreview(clamped);
|
||||
}
|
||||
|
|
@ -411,35 +487,36 @@ const installMouseInterop = () => {
|
|||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
scaling = true;
|
||||
scaleStartY = event.clientY;
|
||||
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();
|
||||
dragging = true;
|
||||
dragStartPoint = { screenX: event.screenX, screenY: event.screenY };
|
||||
setInteractiveHit(true);
|
||||
ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY });
|
||||
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
|
||||
dragStartPoint = null;
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (event) => {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > 4) {
|
||||
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > dragThreshold) {
|
||||
suppressClickUntil = Date.now() + 300;
|
||||
}
|
||||
dragStartPoint = null;
|
||||
ipcRenderer.send("openpets:pet-drag-end");
|
||||
dragCandidate = null;
|
||||
ipcRenderer.send("familiaros:familiar-drag-end");
|
||||
} else if (dragCandidate) {
|
||||
dragCandidate = null;
|
||||
}
|
||||
if (scaling) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -447,8 +524,33 @@ const installMouseInterop = () => {
|
|||
if (!dragging && !scaling) setInteractiveHit(false);
|
||||
}, { passive: true });
|
||||
|
||||
const scaleNudgeStep = 0.02;
|
||||
let scaleNudgeCommitTimer = null;
|
||||
const commitScaleNudge = () => {
|
||||
if (scaleNudgeCommitTimer) {
|
||||
clearTimeout(scaleNudgeCommitTimer);
|
||||
scaleNudgeCommitTimer = null;
|
||||
}
|
||||
const finalScale = getCurrentSpriteScale();
|
||||
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const target = event.target;
|
||||
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
const current = getCurrentSpriteScale();
|
||||
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
|
||||
const next = applyScalePreview(current + delta);
|
||||
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") {
|
||||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"name": "@open-pets/desktop",
|
||||
"name": "@familiaros/desktop",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"description": "OpenPets tray-first desktop companion app.",
|
||||
"description": "Create your Familiar. A local-first AI companion with memory, voice, knowledge, and tools.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/alvinunreal/openpets.git",
|
||||
"url": "git+https://github.com/verticaltension/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",
|
||||
"build:deps": "cd ../.. && pnpm --filter './packages/*' build",
|
||||
"build:deps": "node scripts/build-deps.mjs",
|
||||
"test:build": "pnpm build:deps && tsc -p tsconfig.tests.json",
|
||||
"check": "pnpm typecheck && pnpm build && pnpm test",
|
||||
"typecheck": "pnpm build:deps && tsc --noEmit && tsc -p tsconfig.renderer.json --noEmit",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -10,8 +10,11 @@ ipcRenderer.on(`${channel}:message`, (_event, msg) => {
|
|||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("openPetsPanel", {
|
||||
const api = {
|
||||
postMessage: (msg) => { if (channel) ipcRenderer.send(`${channel}:to-plugin`, msg); },
|
||||
onMessage: (handler) => { if (typeof handler === "function") handlers.add(handler); return () => handlers.delete(handler); },
|
||||
close: () => { if (channel) ipcRenderer.send(`${channel}:close`); },
|
||||
});
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("familiarOSPanel", api);
|
||||
contextBridge.exposeInMainWorld("openPetsPanel", api);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("openPetsCommandForm", {
|
||||
const api = {
|
||||
submit: (channel, values) => ipcRenderer.invoke(String(channel), values && typeof values === "object" ? values : {}),
|
||||
resize: (channel, size) => ipcRenderer.send(String(channel), size && typeof size === "object" ? size : {}),
|
||||
close: () => window.close(),
|
||||
});
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("familiarOSCommandForm", api);
|
||||
contextBridge.exposeInMainWorld("openPetsCommandForm", api);
|
||||
|
|
|
|||
|
|
@ -4,20 +4,27 @@ 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);
|
||||
if (result && typeof result === "object") {
|
||||
const errorMessage = typeof result.__familiarOSError === "string"
|
||||
? result.__familiarOSError
|
||||
: typeof result.__openPetsError === "string"
|
||||
? result.__openPetsError
|
||||
: "";
|
||||
if (errorMessage) throw new Error(errorMessage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +104,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 +118,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),
|
||||
|
|
@ -258,9 +265,13 @@ Object.defineProperty(sdk, "locale", {
|
|||
get: () => callSync("i18n.locale", []),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("__familiarOSSdk", sdk);
|
||||
contextBridge.exposeInMainWorld("__openPetsSdk", sdk);
|
||||
contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => {
|
||||
const runCallback = async (id, args) => {
|
||||
const callback = callbacks.get(id);
|
||||
if (callback) return callback(...(Array.isArray(args) ? args : []));
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("__familiarOSRunCallback", runCallback);
|
||||
contextBridge.exposeInMainWorld("__openPetsRunCallback", runCallback);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,62 @@
|
|||
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"),
|
||||
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),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("familiarOSPromptWindow", api);
|
||||
contextBridge.exposeInMainWorld("openPetsPromptWindow", api);
|
||||
|
||||
// --- TTS playback -------------------------------------------------------------
|
||||
|
||||
let currentTtsAudio = null;
|
||||
|
||||
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
|
||||
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
|
||||
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch { /* tts is best-effort */ }
|
||||
});
|
||||
|
||||
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (!payload || !payload.audio || !payload.mimeType) return;
|
||||
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
|
||||
const blob = new Blob([buffer], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentTtsAudio = new Audio(url);
|
||||
currentTtsAudio.addEventListener("ended", () => {
|
||||
URL.revokeObjectURL(url);
|
||||
currentTtsAudio = null;
|
||||
});
|
||||
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
|
||||
} catch { /* tts is best-effort */ }
|
||||
});
|
||||
|
||||
ipcRenderer.on("familiaros:tts-stop", () => {
|
||||
try {
|
||||
stopCurrentTtsAudio();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
function stopCurrentTtsAudio() {
|
||||
if (currentTtsAudio) {
|
||||
try { currentTtsAudio.pause(); } catch { /* noop */ }
|
||||
try { currentTtsAudio.src = ""; } catch { /* noop */ }
|
||||
currentTtsAudio = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
apps/desktop/scripts/build-deps.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env node
|
||||
// Cross-platform workspace dependency build for the desktop app.
|
||||
// Replaces the shell-glob build:deps command so Windows packaging hosts
|
||||
// can build the exact same set of packages without relying on './packages/*'
|
||||
// glob quoting behavior.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const filters = [
|
||||
"@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 = [];
|
||||
for (const filter of filters) {
|
||||
args.push("--filter", filter);
|
||||
}
|
||||
args.push("build");
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const pnpmBin = isWin ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
execFileSync(pnpmBin, args, { cwd: root, stdio: "inherit", shell: isWin });
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -11,25 +11,116 @@ 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",
|
||||
"plugin-command-form-preload.cjs",
|
||||
"prompt-window-preload.cjs",
|
||||
];
|
||||
const behaviorTests = [
|
||||
".test-dist/tests/lease-manager.test.js",
|
||||
".test-dist/tests/default-pet-external-show.test.js",
|
||||
".test-dist/tests/custom-familiar-name.test.js",
|
||||
".test-dist/tests/internal-ui-route-conformance.test.js",
|
||||
".test-dist/tests/control-center-ipc-route-seams.test.js",
|
||||
".test-dist/tests/familiar-window-render.test.js",
|
||||
".test-dist/tests/familiar-window-content.test.js",
|
||||
".test-dist/tests/familiar-window-layout.test.js",
|
||||
".test-dist/tests/familiar-window-renderer-bridge.test.js",
|
||||
".test-dist/tests/local-ipc-socket.test.js",
|
||||
".test-dist/tests/prompt-window-render.test.js",
|
||||
".test-dist/tests/prompt-window-render-seams.test.js",
|
||||
".test-dist/tests/prompt-window-render-script-sections.test.js",
|
||||
".test-dist/tests/familiar-window-interactions.test.js",
|
||||
".test-dist/tests/familiar-window-mouse-interop-runtime.test.js",
|
||||
".test-dist/tests/familiar-window-plugin-menu.test.js",
|
||||
".test-dist/tests/settings-view-tabs.test.js",
|
||||
".test-dist/tests/settings-view-state.test.js",
|
||||
".test-dist/tests/settings-view-action-seams.test.js",
|
||||
".test-dist/tests/settings-view-state-actions-split.test.js",
|
||||
".test-dist/tests/control-center-state-validation.test.js",
|
||||
".test-dist/tests/settings-openapi-group-provider.test.js",
|
||||
".test-dist/tests/settings-tts-group-split.test.js",
|
||||
".test-dist/tests/default-familiar-external-show.test.js",
|
||||
".test-dist/tests/default-familiar-controller-bubbles.test.js",
|
||||
".test-dist/tests/default-familiar-controller-motion.test.js",
|
||||
".test-dist/tests/default-familiar-controller-display-support.test.js",
|
||||
".test-dist/tests/default-familiar-controller-window.test.js",
|
||||
".test-dist/tests/app-state-preferences.test.js",
|
||||
".test-dist/tests/app-state-analytics.test.js",
|
||||
".test-dist/tests/app-state-familiar-records.test.js",
|
||||
".test-dist/tests/app-state-normalization.test.js",
|
||||
".test-dist/tests/app-state-storage.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/openapi-chat-settings.test.js",
|
||||
".test-dist/tests/openapi-chat-prompt-flows.test.js",
|
||||
".test-dist/tests/openapi-chat-prompt-plain-flow.test.js",
|
||||
".test-dist/tests/openapi-chat-provider.test.js",
|
||||
".test-dist/tests/openapi-chat-tool-loop.test.js",
|
||||
".test-dist/tests/openapi-chat-request-helpers.test.js",
|
||||
".test-dist/tests/openapi-chat-conversation-store.test.js",
|
||||
".test-dist/tests/openapi-chat-config-store.test.js",
|
||||
".test-dist/tests/catalog-surfaceable.test.js",
|
||||
".test-dist/tests/zip-safety.test.js",
|
||||
".test-dist/tests/codex-pets.test.js",
|
||||
".test-dist/tests/tts-engine.test.js",
|
||||
".test-dist/tests/codex-familiars.test.js",
|
||||
".test-dist/tests/catalog-remote-seams.test.js",
|
||||
".test-dist/tests/claude-memory.test.js",
|
||||
".test-dist/tests/prompt-memory-extraction.test.js",
|
||||
".test-dist/tests/familiaros-memory-search.test.js",
|
||||
".test-dist/tests/knowledge-store-core-seam.test.js",
|
||||
".test-dist/tests/knowledge-store.test.js",
|
||||
".test-dist/tests/control-center-service-barrels.test.js",
|
||||
".test-dist/tests/mcp-toolkit-installer-seams.test.js",
|
||||
".test-dist/tests/mcp-toolkit-catalog.test.js",
|
||||
".test-dist/tests/integrations-view-toolkit-split.test.js",
|
||||
".test-dist/tests/integrations-view-agent-sections.test.js",
|
||||
".test-dist/tests/integrations-view-detail-dialog.test.js",
|
||||
".test-dist/tests/integrations-view-state.test.js",
|
||||
".test-dist/tests/familiars-view-detail-dialog.test.js",
|
||||
".test-dist/tests/familiars-view-state.test.js",
|
||||
".test-dist/tests/agent-setup-actions.test.js",
|
||||
".test-dist/tests/agent-setup-actions-tooling.test.js",
|
||||
".test-dist/tests/agent-setup-claude-status.test.js",
|
||||
".test-dist/tests/agent-setup-command-runner.test.js",
|
||||
".test-dist/tests/agent-setup-command-context.test.js",
|
||||
".test-dist/tests/agent-setup-support.test.js",
|
||||
".test-dist/tests/plugin-config.test.js",
|
||||
".test-dist/tests/plugin-sdk-bridge.test.js",
|
||||
".test-dist/tests/plugin-sdk-api-builder.test.js",
|
||||
".test-dist/tests/plugin-sdk-api-builder-platform.test.js",
|
||||
".test-dist/tests/plugin-sdk-api-builder-platform-files.test.js",
|
||||
".test-dist/tests/plugin-sdk-bridge-runtime-state.test.js",
|
||||
".test-dist/tests/plugin-sdk-bubble-validation.test.js",
|
||||
".test-dist/tests/plugin-sdk-command-validation.test.js",
|
||||
".test-dist/tests/plugin-sdk-runtime-helpers.test.js",
|
||||
".test-dist/tests/plugin-sdk-storage-stores.test.js",
|
||||
".test-dist/tests/plugin-sdk-surface-boundaries.test.js",
|
||||
".test-dist/tests/plugin-state.test.js",
|
||||
".test-dist/tests/plugin-runtime.test.js",
|
||||
".test-dist/tests/plugin-catalog-validation.test.js",
|
||||
".test-dist/tests/plugin-package.test.js",
|
||||
".test-dist/tests/plugin-js-host-sdk-dispatch.test.js",
|
||||
".test-dist/tests/plugin-service-app.test.js",
|
||||
".test-dist/tests/plugin-service-actions.test.js",
|
||||
".test-dist/tests/plugin-service.test.js",
|
||||
".test-dist/tests/plugin-service-bundled-support.test.js",
|
||||
".test-dist/tests/plugin-service-catalog-support.test.js",
|
||||
".test-dist/tests/plugin-service-support.test.js",
|
||||
".test-dist/tests/plugin-service-dialog-support.test.js",
|
||||
".test-dist/tests/plugin-service-local-support.test.js",
|
||||
".test-dist/tests/plugin-service-ui-helpers.test.js",
|
||||
".test-dist/tests/plugin-manifest-validation-seams.test.js",
|
||||
".test-dist/tests/plugin-manifest-shape-validation.test.js",
|
||||
".test-dist/tests/plugins-view-config-list-editor.test.js",
|
||||
".test-dist/tests/plugins-view-config-fields.test.js",
|
||||
".test-dist/tests/plugin-ui-static.test.js",
|
||||
".test-dist/tests/plugin-user-sound-store.test.js",
|
||||
".test-dist/tests/plugin-bridge-fuzz.test.js",
|
||||
".test-dist/tests/shared-ui-split.test.js",
|
||||
];
|
||||
const contractTests = [
|
||||
".test-dist/contracts/local-ipc-protocol.contract.js",
|
||||
|
|
@ -47,7 +138,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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
181
apps/desktop/src/agent-setup-actions-claude-mcp.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { app } from "electron";
|
||||
import type { ClaudeCommandSpec, ClaudeMcpPreview, FamiliarOSCommandMode } from "@familiaros/claude";
|
||||
|
||||
import {
|
||||
safeInstallClaudeMemory,
|
||||
safeUninstallClaudeMemory,
|
||||
summarizeMemoryMessages,
|
||||
writeActionJournal,
|
||||
type AgentSetupJournalEntry,
|
||||
type JournalAction,
|
||||
} from "./agent-setup-support.js";
|
||||
import { getPreferredClaudeCommand } from "./agent-setup-command-context.js";
|
||||
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
|
||||
import type {
|
||||
AgentSetupAction,
|
||||
AgentSetupActionResult,
|
||||
ClaudeCodeStatus,
|
||||
} from "./agent-setup.js";
|
||||
|
||||
type CommandResult = AgentSetupCommandResult;
|
||||
|
||||
type AgentSetupClaudeMcpActionHelpers = {
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly commandMode: FamiliarOSCommandMode;
|
||||
readonly detectClaudeCodeStatus: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => Promise<ClaudeCodeStatus>;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
readonly sanitizeOutput: (value: string) => string;
|
||||
readonly summarizeCommandResult: (result: CommandResult) => string;
|
||||
readonly runClaudeCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
|
||||
readonly appendAgentSetupJournal: (entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }) => void;
|
||||
readonly journalActionFor: (action: AgentSetupAction) => JournalAction;
|
||||
};
|
||||
|
||||
export async function runRemoveOnlyClaudeMcpAction(
|
||||
action: AgentSetupAction,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
helpers: AgentSetupClaudeMcpActionHelpers,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
return runRemove(createRemoveOnlyPreview(commandMode), "Unknown", action, helpers);
|
||||
}
|
||||
|
||||
export async function runClaudeMcpAction(
|
||||
action: AgentSetupAction,
|
||||
preview: ClaudeMcpPreview,
|
||||
helpers: AgentSetupClaudeMcpActionHelpers,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
const detection = await helpers.detectClaudeCodeStatus(helpers.selectedPetId, helpers.commandMode);
|
||||
const previousStatus = detection.label;
|
||||
if (detection.state === "not_detected") {
|
||||
const result = {
|
||||
ok: false,
|
||||
action,
|
||||
message: "Claude Code was not found. Install Claude Code or use Copy command to configure manually.",
|
||||
changed: false,
|
||||
} satisfies AgentSetupActionResult;
|
||||
helpers.appendAgentSetupJournal({
|
||||
action: helpers.journalActionFor(action),
|
||||
selectedPetId: helpers.selectedPetId,
|
||||
command: [preview.add.command, ...preview.add.args],
|
||||
previousStatus,
|
||||
success: false,
|
||||
message: result.message,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (action === "configure") {
|
||||
return runConfigureClaudeMcpAction(detection, preview, previousStatus, helpers);
|
||||
}
|
||||
if (!detection.openPetsEntry.present) {
|
||||
return runAdd(preview, previousStatus, action, helpers);
|
||||
}
|
||||
const removed = await runRemove(preview, previousStatus, action, helpers);
|
||||
if (!removed.ok) return removed;
|
||||
const added = await runAdd(preview, previousStatus, action, helpers);
|
||||
if (!added.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
action,
|
||||
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 FamiliarOS MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function runConfigureClaudeMcpAction(
|
||||
detection: ClaudeCodeStatus,
|
||||
preview: ClaudeMcpPreview,
|
||||
previousStatus: string,
|
||||
helpers: AgentSetupClaudeMcpActionHelpers,
|
||||
): Promise<AgentSetupActionResult> | AgentSetupActionResult {
|
||||
if (detection.openPetsEntry.present && detection.openPetsEntry.verified && detection.openPetsEntry.matchesExpected) {
|
||||
const memoryResult = safeInstallClaudeMemory(app.getPath("home"));
|
||||
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: "configure",
|
||||
message,
|
||||
changed: memoryResult.ok && memoryResult.message.startsWith("Added"),
|
||||
};
|
||||
}
|
||||
if (detection.openPetsEntry.present) {
|
||||
return {
|
||||
ok: false,
|
||||
action: "configure",
|
||||
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, previousStatus, "configure", helpers);
|
||||
}
|
||||
|
||||
async function runAdd(
|
||||
preview: ClaudeMcpPreview,
|
||||
previousStatus: string,
|
||||
action: AgentSetupAction,
|
||||
helpers: AgentSetupClaudeMcpActionHelpers,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
const result = await helpers.runClaudeCommand(preview.add);
|
||||
const memoryResult = result.ok ? safeInstallClaudeMemory(app.getPath("home")) : { ok: false as const, message: "" };
|
||||
const message = result.ok
|
||||
? `Configured Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
|
||||
: `Claude MCP add failed: ${helpers.summarizeCommandResult(result)}`;
|
||||
writeActionJournal({
|
||||
entry: {
|
||||
action: helpers.journalActionFor(action),
|
||||
selectedPetId: helpers.selectedPetId,
|
||||
command: [preview.add.command, ...preview.add.args],
|
||||
previousStatus,
|
||||
success: result.ok,
|
||||
message,
|
||||
},
|
||||
userDataPath: app.getPath("userData"),
|
||||
formatUserPath: helpers.formatUserPath,
|
||||
sanitizeOutput: helpers.sanitizeOutput,
|
||||
});
|
||||
return { ok: result.ok, action, message, changed: result.ok };
|
||||
}
|
||||
|
||||
async function runRemove(
|
||||
preview: ClaudeMcpPreview,
|
||||
previousStatus: string,
|
||||
action: AgentSetupAction,
|
||||
helpers: AgentSetupClaudeMcpActionHelpers,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
const result = await helpers.runClaudeCommand(preview.remove);
|
||||
const memoryResult = result.ok ? safeUninstallClaudeMemory(app.getPath("home")) : { ok: false as const, message: "" };
|
||||
const message = result.ok
|
||||
? `Removed Claude Code FamiliarOS MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
|
||||
: `Claude MCP remove failed: ${helpers.summarizeCommandResult(result)}`;
|
||||
writeActionJournal({
|
||||
entry: {
|
||||
action: helpers.journalActionFor(action),
|
||||
selectedPetId: helpers.selectedPetId,
|
||||
command: [preview.remove.command, ...preview.remove.args],
|
||||
previousStatus,
|
||||
success: result.ok,
|
||||
message,
|
||||
},
|
||||
userDataPath: app.getPath("userData"),
|
||||
formatUserPath: helpers.formatUserPath,
|
||||
sanitizeOutput: helpers.sanitizeOutput,
|
||||
});
|
||||
return { ok: result.ok, action, message, changed: result.ok };
|
||||
}
|
||||
|
||||
function createRemoveOnlyPreview(commandMode: FamiliarOSCommandMode): ClaudeMcpPreview {
|
||||
const claude = getPreferredClaudeCommand();
|
||||
return {
|
||||
commandMode,
|
||||
add: { command: claude, args: [] },
|
||||
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "familiaros"] },
|
||||
mcpJson: { mcpServers: { familiaros: { type: "stdio", command: "node", args: [] } } },
|
||||
displayCommand: "",
|
||||
};
|
||||
}
|
||||
157
apps/desktop/src/agent-setup-actions-tooling.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { app } from "electron";
|
||||
import type { ClaudeCommandSpec, FamiliarOSCommandMode } from "@familiaros/claude";
|
||||
|
||||
import {
|
||||
buildCursorSetupSnapshot,
|
||||
buildOpenCodeSetupSnapshot,
|
||||
installCursorGlobalConfig,
|
||||
installOpenCodeGlobalConfig,
|
||||
removeCursorGlobalConfig,
|
||||
removeOpenCodeGlobalConfig,
|
||||
replaceCursorGlobalConfig,
|
||||
type CursorSetupPreview,
|
||||
type CursorSetupStatus,
|
||||
type OpenCodeSetupPreview,
|
||||
type OpenCodeSetupStatus,
|
||||
} from "./agent-setup-editor-tools.js";
|
||||
import { buildFamiliarOSMcpServerPreview } from "./agent-setup-support.js";
|
||||
import {
|
||||
getAgentSetupCliEntryPath,
|
||||
getCliPackageVersion,
|
||||
getMcpPackageVersion,
|
||||
getOpenCodePackageVersion,
|
||||
getPreferredNodeCommand,
|
||||
getPreferredOpenCodeCommand,
|
||||
} from "./agent-setup-command-context.js";
|
||||
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
|
||||
import type { AgentSetupActionResult, FamiliarOSMcpServerHealth } from "./agent-setup.js";
|
||||
|
||||
type CommandResult = AgentSetupCommandResult;
|
||||
|
||||
export async function getOpenCodeSetup(
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
selectedPetId: string | undefined,
|
||||
runCommandFn: (spec: ClaudeCommandSpec) => Promise<CommandResult>,
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
|
||||
const detected = await runCommandFn({ command: getPreferredOpenCodeCommand(), args: ["--version"] });
|
||||
return buildOpenCodeSetupSnapshot({
|
||||
env: process.env,
|
||||
homeDir: app.getPath("home"),
|
||||
processPlatform: process.platform,
|
||||
selectedPetId,
|
||||
commandMode,
|
||||
cliVersion: getCliPackageVersion(),
|
||||
pluginVersion: getOpenCodePackageVersion(),
|
||||
cliEntryPath: commandMode === "published" ? undefined : getAgentSetupCliEntryPath(commandMode),
|
||||
detectedOk: detected.ok,
|
||||
preferredOpenCodeCommand: getPreferredOpenCodeCommand(),
|
||||
formatUserPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCursorSetup(
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
selectedPetId: string | undefined,
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
|
||||
void commandMode;
|
||||
return buildCursorSetupSnapshot({
|
||||
homeDir: app.getPath("home"),
|
||||
selectedPetId,
|
||||
mcpVersion: getMcpPackageVersion(),
|
||||
formatUserPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAgentSetupMcpServerHealthCheck(
|
||||
selectedPetId: string | undefined,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
runCommandFn: (spec: ClaudeCommandSpec) => Promise<CommandResult>,
|
||||
): Promise<FamiliarOSMcpServerHealth> {
|
||||
const preview = buildFamiliarOSMcpServerPreview(selectedPetId, commandMode);
|
||||
const result = await runCommandFn({ command: preview.command, args: [...preview.args, "--version"] });
|
||||
if (result.ok) {
|
||||
return { ok: true, output: result.stdout.trim() || "MCP server responded." };
|
||||
}
|
||||
const errorMessage = result.error || result.stderr.trim() || `Command exited with code ${result.exitCode ?? "unknown"}.`;
|
||||
return { ok: false, output: "", error: errorMessage };
|
||||
}
|
||||
|
||||
export async function installOpenCodeGlobal(
|
||||
selectedPetId: string | undefined,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
helpers: {
|
||||
readonly runCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
|
||||
readonly summarizeCommandResult: (result: CommandResult) => string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
},
|
||||
): Promise<AgentSetupActionResult> {
|
||||
if (commandMode === "bundled") {
|
||||
const node = await helpers.runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
|
||||
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. ${helpers.summarizeCommandResult(node)}`,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return installOpenCodeGlobalConfig({
|
||||
env: process.env,
|
||||
homeDir: app.getPath("home"),
|
||||
processPlatform: process.platform,
|
||||
selectedPetId,
|
||||
commandMode,
|
||||
cliVersion: getCliPackageVersion(),
|
||||
pluginVersion: getOpenCodePackageVersion(),
|
||||
cliEntryPath: commandMode === "published" ? undefined : getAgentSetupCliEntryPath(commandMode),
|
||||
formatUserPath: helpers.formatUserPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
|
||||
return removeOpenCodeGlobalConfig({
|
||||
env: process.env,
|
||||
homeDir: app.getPath("home"),
|
||||
processPlatform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installCursorGlobal(
|
||||
selectedPetId: string | undefined,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
void commandMode;
|
||||
return installCursorGlobalConfig({
|
||||
homeDir: app.getPath("home"),
|
||||
selectedPetId,
|
||||
mcpVersion: getMcpPackageVersion(),
|
||||
formatUserPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceCursorGlobal(
|
||||
selectedPetId: string | undefined,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
void commandMode;
|
||||
return replaceCursorGlobalConfig({
|
||||
homeDir: app.getPath("home"),
|
||||
selectedPetId,
|
||||
mcpVersion: getMcpPackageVersion(),
|
||||
formatUserPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeCursorGlobal(
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
return removeCursorGlobalConfig({
|
||||
homeDir: app.getPath("home"),
|
||||
formatUserPath,
|
||||
});
|
||||
}
|
||||
180
apps/desktop/src/agent-setup-actions.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { app } from "electron";
|
||||
import {
|
||||
installClaudeHooks,
|
||||
uninstallClaudeHooks,
|
||||
type ClaudeCommandSpec,
|
||||
type ClaudeHookDoctorResult,
|
||||
type ClaudeMcpPreview,
|
||||
type FamiliarOSCommandMode,
|
||||
} from "@familiaros/claude";
|
||||
|
||||
import {
|
||||
installCursorGlobal,
|
||||
installOpenCodeGlobal,
|
||||
removeCursorGlobal,
|
||||
removeOpenCodeGlobal,
|
||||
replaceCursorGlobal,
|
||||
} from "./agent-setup-actions-tooling.js";
|
||||
export {
|
||||
getCursorSetup,
|
||||
getOpenCodeSetup,
|
||||
runAgentSetupMcpServerHealthCheck,
|
||||
} from "./agent-setup-actions-tooling.js";
|
||||
import {
|
||||
runClaudeMcpAction,
|
||||
runRemoveOnlyClaudeMcpAction,
|
||||
} from "./agent-setup-actions-claude-mcp.js";
|
||||
import {
|
||||
createHookJournalCommand,
|
||||
safeInstallClaudeMemory,
|
||||
type AgentSetupJournalEntry,
|
||||
type JournalAction,
|
||||
} from "./agent-setup-support.js";
|
||||
import {
|
||||
getPreferredNodeCommand,
|
||||
} from "./agent-setup-command-context.js";
|
||||
import type { AgentSetupCommandResult } from "./agent-setup-command-runner.js";
|
||||
import type {
|
||||
AgentSetupAction,
|
||||
AgentSetupActionResult,
|
||||
ClaudeCodeStatus,
|
||||
} from "./agent-setup.js";
|
||||
|
||||
type CommandResult = AgentSetupCommandResult;
|
||||
|
||||
type AgentSetupActionHelpers = {
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly commandMode: FamiliarOSCommandMode;
|
||||
readonly safeDoctorClaudeHooks: (commandMode: FamiliarOSCommandMode, selectedPetId: string | undefined) => ClaudeHookDoctorResult;
|
||||
readonly safeBuildClaudeMcpPreview: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => { readonly preview: ClaudeMcpPreview; readonly error?: string };
|
||||
readonly detectClaudeCodeStatus: (selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode) => Promise<ClaudeCodeStatus>;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
readonly sanitizeOutput: (value: string) => string;
|
||||
readonly summarizeCommandResult: (result: CommandResult) => string;
|
||||
readonly runCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
|
||||
readonly runClaudeCommand: (spec: ClaudeCommandSpec) => Promise<CommandResult>;
|
||||
readonly appendAgentSetupJournal: (entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }) => void;
|
||||
readonly journalActionFor: (action: AgentSetupAction) => JournalAction;
|
||||
};
|
||||
|
||||
export async function executeAgentSetupResolvedAction(
|
||||
action: AgentSetupAction,
|
||||
helpers: AgentSetupActionHelpers,
|
||||
): Promise<AgentSetupActionResult> {
|
||||
const immediateAction = await runImmediateAction(action, helpers);
|
||||
if (immediateAction) return immediateAction;
|
||||
if (helpers.commandMode === "bundled") {
|
||||
const node = await helpers.runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
|
||||
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. ${helpers.summarizeCommandResult(node)}`,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
const previewResult = helpers.safeBuildClaudeMcpPreview(helpers.selectedPetId, helpers.commandMode);
|
||||
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
|
||||
const hookInstallAction = runInstallHooksAction(action, helpers);
|
||||
if (hookInstallAction) return hookInstallAction;
|
||||
return runClaudeMcpAction(action, previewResult.preview, helpers);
|
||||
}
|
||||
|
||||
async function runImmediateAction(
|
||||
action: AgentSetupAction,
|
||||
helpers: AgentSetupActionHelpers,
|
||||
): Promise<AgentSetupActionResult | undefined> {
|
||||
if (action === "opencode-install") return installOpenCodeGlobal(helpers.selectedPetId, helpers.commandMode, helpers);
|
||||
if (action === "opencode-remove") return removeOpenCodeGlobal();
|
||||
if (action === "cursor-install") return installCursorGlobal(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath);
|
||||
if (action === "cursor-replace") return replaceCursorGlobal(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath);
|
||||
if (action === "cursor-remove") return removeCursorGlobal(helpers.formatUserPath);
|
||||
if (action === "doctor-hooks") {
|
||||
const doctor = helpers.safeDoctorClaudeHooks(helpers.commandMode, helpers.selectedPetId);
|
||||
helpers.appendAgentSetupJournal({
|
||||
action: "update",
|
||||
selectedPetId: helpers.selectedPetId,
|
||||
command: createHookJournalCommand("doctor-hooks", helpers.selectedPetId),
|
||||
previousStatus: doctor.status,
|
||||
success: doctor.status !== "error",
|
||||
message: doctor.message,
|
||||
});
|
||||
return { ok: doctor.status !== "error", action, message: doctor.message, changed: false };
|
||||
}
|
||||
if (action === "uninstall-hooks") {
|
||||
return runUninstallHooksAction(helpers.selectedPetId, helpers.commandMode, helpers.formatUserPath, helpers.appendAgentSetupJournal);
|
||||
}
|
||||
if (action === "install-memory") {
|
||||
const result = safeInstallClaudeMemory(app.getPath("home"));
|
||||
return {
|
||||
ok: result.ok,
|
||||
action,
|
||||
message: result.ok ? result.message : `Claude instructions were not updated: ${result.message}`,
|
||||
changed: result.ok && result.message.startsWith("Added"),
|
||||
};
|
||||
}
|
||||
if (action === "remove") return runRemoveOnlyClaudeMcpAction(action, helpers.commandMode, helpers);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runUninstallHooksAction(
|
||||
selectedPetId: string | undefined,
|
||||
commandMode: FamiliarOSCommandMode,
|
||||
formatUserPath: (path: string | undefined) => string | undefined,
|
||||
appendAgentSetupJournal: AgentSetupActionHelpers["appendAgentSetupJournal"],
|
||||
): AgentSetupActionResult {
|
||||
let result;
|
||||
try {
|
||||
result = uninstallClaudeHooks(undefined, commandMode);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
action: "uninstall-hooks",
|
||||
message: error instanceof Error ? error.message : "FamiliarOS hook uninstall failed.",
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
const message = result.changed
|
||||
? `Uninstalled FamiliarOS Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}`
|
||||
: result.message;
|
||||
appendAgentSetupJournal({
|
||||
action: "remove",
|
||||
selectedPetId,
|
||||
command: ["familiaros-claude", "uninstall-hooks"],
|
||||
previousStatus: result.status,
|
||||
success: result.status !== "error",
|
||||
message,
|
||||
});
|
||||
return { ok: result.status !== "error", action: "uninstall-hooks", message, changed: result.changed };
|
||||
}
|
||||
|
||||
function runInstallHooksAction(
|
||||
action: AgentSetupAction,
|
||||
helpers: AgentSetupActionHelpers,
|
||||
): AgentSetupActionResult | undefined {
|
||||
if (action !== "install-hooks") return undefined;
|
||||
let result;
|
||||
try {
|
||||
result = installClaudeHooks(undefined, helpers.commandMode, helpers.selectedPetId, getPreferredNodeCommand());
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
action,
|
||||
message: error instanceof Error ? error.message : "FamiliarOS hook install failed.",
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
const message = result.changed
|
||||
? `Installed FamiliarOS Claude hooks. Backup: ${helpers.formatUserPath(result.backupPath) ?? "not needed"}`
|
||||
: result.message;
|
||||
helpers.appendAgentSetupJournal({
|
||||
action: "update",
|
||||
selectedPetId: helpers.selectedPetId,
|
||||
command: createHookJournalCommand("install-hooks", helpers.selectedPetId),
|
||||
previousStatus: result.status,
|
||||
success: result.status !== "error",
|
||||
message,
|
||||
});
|
||||
return { ok: result.status !== "error", action, message, changed: result.changed };
|
||||
}
|
||||
149
apps/desktop/src/agent-setup-claude-status.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { app } from "electron";
|
||||
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, doctorClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
|
||||
|
||||
import { getPreferredClaudeCommand, getPreferredNodeCommand, quoteCommandForDisplay } from "./agent-setup-command-context.js";
|
||||
import { delay, getAgentSetupClaudeCommandCandidates, isCommandNotFound, runAgentSetupCommand, summarizeAgentSetupCommandResult, type AgentSetupCommandResult } from "./agent-setup-command-runner.js";
|
||||
import type { ClaudeCodeStatus } from "./agent-setup.js";
|
||||
|
||||
type CommandResult = AgentSetupCommandResult;
|
||||
|
||||
export function sanitizeAgentSetupOutput(value: string): string {
|
||||
const home = app.isReady() ? app.getPath("home") : "";
|
||||
return value
|
||||
.replaceAll(home, "~")
|
||||
.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "<path>")
|
||||
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "<redacted-private-key>")
|
||||
.replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
|
||||
.replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=<redacted>")
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
export 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 FamiliarOS command resources are unavailable.";
|
||||
return { preview: createErrorPreview(commandMode, message), error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export 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 FamiliarOS hook resources are unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
export 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 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"] });
|
||||
if (!version.ok) {
|
||||
const hasOverride = getPreferredClaudeCommand() !== "claude";
|
||||
return createStatus("not_detected", "Not detected", `${hasOverride ? "Claude Code did not run from the saved command path" : "Claude Code was not found or did not run"}: ${summarizeCommandResult(version)}`, undefined, version, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
const list = await runClaudeCommandWithTimeoutRetry({ command: "claude", args: ["mcp", "list"] });
|
||||
if (!list.ok) {
|
||||
return createStatus("error", "Error / needs attention", `Claude Code was detected, but MCP status failed: ${summarizeCommandResult(list)}`, sanitizeAgentSetupOutput(version.stdout || version.stderr), list, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
const listed = classifyClaudeMcpStatus(list.stdout, undefined, selectedPetId, commandMode, getPreferredNodeCommand());
|
||||
let entry = listed;
|
||||
if (listed.present) {
|
||||
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
|
||||
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 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);
|
||||
}
|
||||
|
||||
export function formatUserPath(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return path.replace(app.getPath("home"), "~");
|
||||
}
|
||||
|
||||
export async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
for (const command of getClaudeCommandCandidates(spec.command)) {
|
||||
const result = await runCommand({ command, args: spec.args });
|
||||
if (result.ok || !isCommandNotFound(result)) return result;
|
||||
}
|
||||
return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: "Claude command was not found." };
|
||||
}
|
||||
|
||||
export function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
return runAgentSetupCommand(spec, {
|
||||
homeDir: app.getPath("home"),
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
sanitizeOutput: sanitizeAgentSetupOutput,
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeCommandResult(result: CommandResult): string {
|
||||
return summarizeAgentSetupCommandResult(result, sanitizeAgentSetupOutput);
|
||||
}
|
||||
|
||||
function createErrorPreview(commandMode: FamiliarOSCommandMode, message: string): ClaudeMcpPreview {
|
||||
const claude = getPreferredClaudeCommand();
|
||||
return {
|
||||
commandMode,
|
||||
add: { command: claude, args: [] },
|
||||
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "familiaros"] },
|
||||
mcpJson: { mcpServers: { familiaros: { type: "stdio", command: "node", args: [] } } },
|
||||
displayCommand: message,
|
||||
};
|
||||
}
|
||||
|
||||
function withPreferredClaudeCommand(preview: ClaudeMcpPreview): ClaudeMcpPreview {
|
||||
const claude = getPreferredClaudeCommand();
|
||||
if (claude === preview.add.command && claude === preview.remove.command) return preview;
|
||||
return {
|
||||
...preview,
|
||||
add: { ...preview.add, command: claude },
|
||||
remove: { ...preview.remove, command: claude },
|
||||
displayCommand: preview.displayCommand.replace(/^claude(?=\s|$)/, quoteCommandForDisplay(claude)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBundledResourceErrorStatus(message: string): ClaudeCodeStatus {
|
||||
return createStatus("error", "Packaged commands unavailable", message, undefined, { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: message }, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
export function createHookErrorStatus(message: string): ClaudeHookDoctorResult {
|
||||
return { status: "error", settingsPath: "~/.claude/settings.json", exists: false, valid: false, message, preview: {}, asyncSupported: false };
|
||||
}
|
||||
|
||||
async function runClaudeCommandWithTimeoutRetry(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
const first = await runClaudeCommand(spec);
|
||||
if (!first.timedOut) return first;
|
||||
await delay(250);
|
||||
const second = await runClaudeCommand(spec);
|
||||
return second.ok ? second : first;
|
||||
}
|
||||
|
||||
function createStatus(state: ClaudeCodeStatus["state"], label: string, details: string, version: string | undefined, listResult: CommandResult, entry: ParsedClaudeMcpEntry): ClaudeCodeStatus {
|
||||
return {
|
||||
state,
|
||||
label,
|
||||
details,
|
||||
claudeCommand: "claude",
|
||||
version,
|
||||
mcpListWorks: listResult.ok,
|
||||
openPetsEntry: entry,
|
||||
canConfigure: state === "needs_setup",
|
||||
canReplace: entry.present && !(entry.verified && entry.matchesExpected),
|
||||
canRemove: entry.present,
|
||||
};
|
||||
}
|
||||
|
||||
function getClaudeCommandCandidates(command: string): readonly string[] {
|
||||
return getAgentSetupClaudeCommandCandidates(command, getPreferredClaudeCommand(), process.platform);
|
||||
}
|
||||
111
apps/desktop/src/agent-setup-command-context.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { createRequire } from "node:module";
|
||||
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
|
||||
import { mapAsarPathToUnpacked, type FamiliarOSCommandMode } from "@familiaros/claude";
|
||||
|
||||
import { getAppStateSnapshot, updatePreferences, type FamiliarOSStateV1 } from "./app-state.js";
|
||||
|
||||
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function getStoredAgentSetupCommandPaths(): {
|
||||
readonly claude: string;
|
||||
readonly node: string;
|
||||
readonly opencode: string;
|
||||
} {
|
||||
const preferences = getAppStateSnapshot().preferences;
|
||||
return {
|
||||
claude: preferences.claudeCommandPath ?? "",
|
||||
node: preferences.nodeCommandPath ?? "",
|
||||
opencode: preferences.opencodeCommandPath ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function updateStoredAgentSetupCommandPaths(patch: unknown): {
|
||||
readonly claude: string;
|
||||
readonly node: string;
|
||||
readonly opencode: string;
|
||||
} {
|
||||
if (!isRecord(patch)) throw new Error("Invalid command path settings.");
|
||||
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<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");
|
||||
updatePreferences(updates);
|
||||
return getStoredAgentSetupCommandPaths();
|
||||
}
|
||||
|
||||
export function getPreferredClaudeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.claudeCommandPath || "claude";
|
||||
}
|
||||
|
||||
export function getPreferredNodeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.nodeCommandPath || "node";
|
||||
}
|
||||
|
||||
export function getPreferredOpenCodeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.opencodeCommandPath || (process.platform === "win32" ? "opencode.cmd" : "opencode");
|
||||
}
|
||||
|
||||
export function quoteCommandForDisplay(command: string): string {
|
||||
return /\s/.test(command) ? JSON.stringify(command) : command;
|
||||
}
|
||||
|
||||
export function getAgentSetupCliEntryPath(commandMode: FamiliarOSCommandMode): string {
|
||||
const path = require.resolve("@familiaros/cli");
|
||||
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
|
||||
}
|
||||
|
||||
export function getCliPackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@familiaros/cli");
|
||||
}
|
||||
|
||||
export function getOpenCodePackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@familiaros/opencode");
|
||||
}
|
||||
|
||||
export function getMcpPackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@familiaros/mcp");
|
||||
}
|
||||
|
||||
function getWorkspacePackageVersion(packageName: string): string {
|
||||
try {
|
||||
const entryPath = require.resolve(packageName);
|
||||
const packageJsonPath = join(dirname(dirname(entryPath)), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { readonly version?: unknown };
|
||||
return typeof packageJson.version === "string" && packageJson.version ? packageJson.version : "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalCommandPath(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new Error(`${label} command path must be text.`);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length > 4096 || /[\r\n\0]/.test(trimmed)) throw new Error(`${label} command path is invalid.`);
|
||||
if (!isAbsolute(trimmed)) throw new Error(`${label} command path must be a full absolute path.`);
|
||||
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) {
|
||||
throw new Error(`${label} command path contains unsupported shell characters.`);
|
||||
}
|
||||
try {
|
||||
const stat = statSync(trimmed);
|
||||
if (!stat.isFile()) throw new Error();
|
||||
if (process.platform !== "win32") accessSync(trimmed, constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`${label} command path must point to an existing executable file.`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
198
apps/desktop/src/agent-setup-command-runner.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const defaultCommandTimeoutMs = 6_000;
|
||||
const defaultMaxOutputBytes = 16_384;
|
||||
|
||||
export interface AgentSetupCommandSpec {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
|
||||
export interface AgentSetupCommandResult {
|
||||
readonly ok: boolean;
|
||||
readonly timedOut: boolean;
|
||||
readonly exitCode: number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface AgentSetupCommandRuntimeOptions {
|
||||
readonly homeDir: string;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly sanitizeOutput: (value: string) => string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxOutputBytes?: number;
|
||||
}
|
||||
|
||||
export async function runAgentSetupCommand(
|
||||
spec: AgentSetupCommandSpec,
|
||||
options: AgentSetupCommandRuntimeOptions,
|
||||
): Promise<AgentSetupCommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const command = options.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
|
||||
const args = options.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
cwd: options.homeDir,
|
||||
env: createAgentSetupCommandEnv(options),
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: error instanceof Error ? error.message : "Command failed to start.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? defaultCommandTimeoutMs;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? defaultMaxOutputBytes;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill();
|
||||
resolve({
|
||||
ok: false,
|
||||
timedOut: true,
|
||||
exitCode: null,
|
||||
stdout: options.sanitizeOutput(stdout),
|
||||
stderr: options.sanitizeOutput(stderr),
|
||||
error: "Command timed out.",
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout = appendBounded(stdout, chunk.toString("utf8"), maxOutputBytes);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr = appendBounded(stderr, chunk.toString("utf8"), maxOutputBytes);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
ok: false,
|
||||
timedOut: false,
|
||||
exitCode: null,
|
||||
stdout: options.sanitizeOutput(stdout),
|
||||
stderr: options.sanitizeOutput(stderr),
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
timedOut: false,
|
||||
exitCode: code,
|
||||
stdout: options.sanitizeOutput(stdout),
|
||||
stderr: options.sanitizeOutput(stderr),
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentSetupClaudeCommandCandidates(
|
||||
command: string,
|
||||
preferredClaudeCommand: string,
|
||||
platform: NodeJS.Platform,
|
||||
): readonly string[] {
|
||||
if (command !== "claude") return [command];
|
||||
if (preferredClaudeCommand !== "claude") return [preferredClaudeCommand];
|
||||
if (platform === "win32") return ["claude", "claude.cmd"];
|
||||
return ["claude"];
|
||||
}
|
||||
|
||||
export function createAgentSetupCommandEnv(
|
||||
options: Pick<AgentSetupCommandRuntimeOptions, "platform" | "env" | "homeDir">,
|
||||
): NodeJS.ProcessEnv {
|
||||
const separator = options.platform === "win32" ? ";" : ":";
|
||||
const existingPath = options.env.PATH ?? "";
|
||||
return {
|
||||
...options.env,
|
||||
PATH: dedupePathEntries([existingPath, ...getAgentSetupExtraCommandPaths(options)], separator).join(separator),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentSetupExtraCommandPaths(
|
||||
options: Pick<AgentSetupCommandRuntimeOptions, "platform" | "env" | "homeDir">,
|
||||
): readonly string[] {
|
||||
if (options.platform === "win32") return [];
|
||||
const home = options.homeDir;
|
||||
const env = options.env;
|
||||
return filterExistingPaths([
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
join(home, "bin"),
|
||||
join(home, ".local", "bin"),
|
||||
join(home, ".opencode", "bin"),
|
||||
join(env.VOLTA_HOME || join(home, ".volta"), "bin"),
|
||||
join(env.BUN_INSTALL || join(home, ".bun"), "bin"),
|
||||
join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"),
|
||||
join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"),
|
||||
env.PNPM_HOME,
|
||||
join(home, ".local", "share", "pnpm"),
|
||||
join(home, "Library", "pnpm"),
|
||||
join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function summarizeAgentSetupCommandResult(
|
||||
result: AgentSetupCommandResult,
|
||||
sanitizeOutput: (value: string) => string,
|
||||
): string {
|
||||
if (result.timedOut) return "command timed out.";
|
||||
const output = sanitizeOutput(result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`);
|
||||
return output || "command failed.";
|
||||
}
|
||||
|
||||
export function isCommandNotFound(result: AgentSetupCommandResult): boolean {
|
||||
return Boolean(result.error && /ENOENT|not found/i.test(result.error));
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] {
|
||||
return paths.filter((path): path is string => Boolean(path && existsSync(path)));
|
||||
}
|
||||
|
||||
function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: string[] = [];
|
||||
for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) {
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
entries.push(path);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function appendBounded(existing: string, next: string, maxOutputBytes: number): string {
|
||||
const combined = existing + next;
|
||||
return combined.length > maxOutputBytes ? combined.slice(combined.length - maxOutputBytes) : combined;
|
||||
}
|
||||
180
apps/desktop/src/agent-setup-editor-tools-cursor.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { buildCursorRulesPreview, buildFamiliarOSOnlyPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult, type RedactedPreview } from "@familiaros/cursor";
|
||||
|
||||
export interface CursorSetupStatus {
|
||||
readonly state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update";
|
||||
readonly label: string;
|
||||
readonly details: string;
|
||||
readonly configPath: string;
|
||||
readonly canInstall: boolean;
|
||||
readonly canReplace: boolean;
|
||||
readonly canRemove: boolean;
|
||||
}
|
||||
|
||||
export interface CursorSetupPreview {
|
||||
readonly global: true;
|
||||
readonly configPath: string;
|
||||
readonly mcpEntry: RedactedPreview;
|
||||
readonly rulesPath: string;
|
||||
readonly rulesContent: string;
|
||||
readonly commandMode: "published" | "local" | "bundled";
|
||||
}
|
||||
|
||||
type CursorActionResult = {
|
||||
readonly ok: boolean;
|
||||
readonly action: "cursor-install" | "cursor-replace" | "cursor-remove";
|
||||
readonly message: string;
|
||||
readonly changed: boolean;
|
||||
};
|
||||
|
||||
export function buildCursorSetupSnapshot({
|
||||
homeDir,
|
||||
selectedPetId,
|
||||
mcpVersion,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly homeDir: string;
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly mcpVersion: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): { readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview } {
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const petId = selectedPetId || undefined;
|
||||
|
||||
const configResult = readCursorMcpConfig(configPath);
|
||||
const statusResult = classifyCursorMcpStatus(configResult, configPath, { mcpVersion, petId, commandMode: "published" });
|
||||
|
||||
return {
|
||||
status: {
|
||||
state: mapCursorStatusToState(statusResult.status),
|
||||
label: mapCursorStatusToLabel(statusResult.status),
|
||||
details: statusResult.message,
|
||||
configPath: formatUserPath(configPath) ?? configPath,
|
||||
canInstall: statusResult.canInstall,
|
||||
canReplace: statusResult.canReplace,
|
||||
canRemove: statusResult.canRemove,
|
||||
},
|
||||
preview: {
|
||||
global: true,
|
||||
configPath: formatUserPath(configPath) ?? configPath,
|
||||
mcpEntry: buildFamiliarOSOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
|
||||
rulesPath: ".cursor/rules/familiaros.mdc",
|
||||
rulesContent: buildCursorRulesPreview(),
|
||||
commandMode: "published",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function installCursorGlobalConfig({
|
||||
homeDir,
|
||||
selectedPetId,
|
||||
mcpVersion,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly homeDir: string;
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly mcpVersion: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): CursorActionResult {
|
||||
try {
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const plan = planCursorMcpInstall(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-install", message: plan.message, changed: false };
|
||||
}
|
||||
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 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) {
|
||||
return { ok: false, action: "cursor-install", message: error instanceof Error ? error.message : "Cursor MCP install failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceCursorGlobalConfig({
|
||||
homeDir,
|
||||
selectedPetId,
|
||||
mcpVersion,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly homeDir: string;
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly mcpVersion: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): CursorActionResult {
|
||||
try {
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const plan = planCursorMcpReplace(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-replace", message: plan.message, changed: false };
|
||||
}
|
||||
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 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) {
|
||||
return { ok: false, action: "cursor-replace", message: error instanceof Error ? error.message : "Cursor MCP replace failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCursorGlobalConfig({
|
||||
homeDir,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly homeDir: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): CursorActionResult {
|
||||
try {
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const plan = planCursorMcpRemove(configPath);
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-remove", message: plan.message, changed: false };
|
||||
}
|
||||
if ("targetPath" in plan) {
|
||||
executeCursorMcpWrite(plan);
|
||||
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) {
|
||||
return { ok: false, action: "cursor-remove", message: error instanceof Error ? error.message : "Cursor MCP remove failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
function mapCursorStatusToState(status: CursorMcpStatusResult["status"]): CursorSetupStatus["state"] {
|
||||
switch (status) {
|
||||
case "installed":
|
||||
return "configured";
|
||||
case "missing":
|
||||
return "needs_setup";
|
||||
case "needs-update":
|
||||
return "needs_update";
|
||||
case "conflict":
|
||||
return "conflict";
|
||||
case "invalid":
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function mapCursorStatusToLabel(status: CursorMcpStatusResult["status"]): string {
|
||||
switch (status) {
|
||||
case "installed":
|
||||
return "Configured";
|
||||
case "missing":
|
||||
return "Not configured";
|
||||
case "needs-update":
|
||||
return "Needs update";
|
||||
case "conflict":
|
||||
return "Conflict";
|
||||
case "invalid":
|
||||
case "error":
|
||||
return "Config error";
|
||||
default:
|
||||
return "Checking";
|
||||
}
|
||||
}
|
||||
152
apps/desktop/src/agent-setup-editor-tools-opencode.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@familiaros/opencode";
|
||||
import type { FamiliarOSCommandMode } from "@familiaros/claude";
|
||||
|
||||
export interface OpenCodeSetupStatus {
|
||||
readonly state: "configured" | "needs_setup" | "not_detected" | "error";
|
||||
readonly label: string;
|
||||
readonly details: string;
|
||||
readonly configDir: string;
|
||||
readonly canInstall: boolean;
|
||||
readonly canRemove: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeSetupPreview {
|
||||
readonly global: true;
|
||||
readonly configDir: string;
|
||||
readonly configPath: string;
|
||||
readonly cleanupConfigPaths: readonly string[];
|
||||
readonly mcpCommand: readonly string[];
|
||||
readonly plugin: readonly unknown[] | string;
|
||||
readonly instructionPath: string;
|
||||
readonly configPreview: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type OpenCodeActionResult = {
|
||||
readonly ok: boolean;
|
||||
readonly action: "opencode-install" | "opencode-remove";
|
||||
readonly message: string;
|
||||
readonly changed: boolean;
|
||||
};
|
||||
|
||||
export function buildOpenCodeSetupSnapshot({
|
||||
env,
|
||||
homeDir,
|
||||
processPlatform,
|
||||
selectedPetId,
|
||||
commandMode,
|
||||
cliVersion,
|
||||
pluginVersion,
|
||||
cliEntryPath,
|
||||
detectedOk,
|
||||
preferredOpenCodeCommand,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly homeDir: string;
|
||||
readonly processPlatform: NodeJS.Platform;
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly commandMode: FamiliarOSCommandMode;
|
||||
readonly cliVersion: string;
|
||||
readonly pluginVersion: string;
|
||||
readonly cliEntryPath: string | undefined;
|
||||
readonly detectedOk: boolean;
|
||||
readonly preferredOpenCodeCommand: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): { readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview } {
|
||||
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
|
||||
const petId = selectedPetId || undefined;
|
||||
const prepared = safePrepareOpenCode(configDir, petId, cliVersion, pluginVersion, commandMode, cliEntryPath);
|
||||
const globalState = doctorOpenCodeGlobalSetup(configDir);
|
||||
const configured = globalState.status === "installed";
|
||||
|
||||
return {
|
||||
status: {
|
||||
state: globalState.status === "error" || globalState.status === "custom" || globalState.status === "conflict" ? "error" : configured ? "configured" : detectedOk ? "needs_setup" : "not_detected",
|
||||
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detectedOk ? "Ready" : "Not detected",
|
||||
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detectedOk ? "OpenCode was detected. Desktop setup writes global OpenCode config." : preferredOpenCodeCommand === (processPlatform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
|
||||
configDir: formatUserPath(configDir) ?? configDir,
|
||||
canInstall: prepared.ok && !configured,
|
||||
canRemove: configured,
|
||||
},
|
||||
preview: {
|
||||
global: true,
|
||||
configDir: formatUserPath(configDir) ?? configDir,
|
||||
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 ? [`@familiaros/opencode@${pluginVersion}`, { familiar: petId }] : `@familiaros/opencode@${pluginVersion}`),
|
||||
instructionPath: prepared.ok ? (formatUserPath(prepared.instructionPath) ?? prepared.instructionPath) : "",
|
||||
configPreview: prepared.ok ? prepared.configPreview : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function installOpenCodeGlobalConfig({
|
||||
env,
|
||||
homeDir,
|
||||
processPlatform,
|
||||
selectedPetId,
|
||||
commandMode,
|
||||
cliVersion,
|
||||
pluginVersion,
|
||||
cliEntryPath,
|
||||
formatUserPath,
|
||||
}: {
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly homeDir: string;
|
||||
readonly processPlatform: NodeJS.Platform;
|
||||
readonly selectedPetId: string | undefined;
|
||||
readonly commandMode: FamiliarOSCommandMode;
|
||||
readonly cliVersion: string;
|
||||
readonly pluginVersion: string;
|
||||
readonly cliEntryPath: string | undefined;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
}): OpenCodeActionResult {
|
||||
try {
|
||||
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
|
||||
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion, pluginVersion, commandMode, cliEntryPath });
|
||||
writePreparedOpenCodeGlobalSetup(prepared);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
export function removeOpenCodeGlobalConfig({
|
||||
env,
|
||||
homeDir,
|
||||
processPlatform,
|
||||
}: {
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly homeDir: string;
|
||||
readonly processPlatform: NodeJS.Platform;
|
||||
}): OpenCodeActionResult {
|
||||
try {
|
||||
const configDir = getGlobalOpenCodeConfigDir(env, homeDir, processPlatform);
|
||||
const prepared = prepareOpenCodeGlobalRemove(configDir);
|
||||
writePreparedOpenCodeGlobalRemove(prepared);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
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?: { 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?.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." };
|
||||
}
|
||||
}
|
||||
19
apps/desktop/src/agent-setup-editor-tools.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
buildOpenCodeSetupSnapshot,
|
||||
installOpenCodeGlobalConfig,
|
||||
removeOpenCodeGlobalConfig,
|
||||
} from "./agent-setup-editor-tools-opencode.js";
|
||||
export type {
|
||||
OpenCodeSetupPreview,
|
||||
OpenCodeSetupStatus,
|
||||
} from "./agent-setup-editor-tools-opencode.js";
|
||||
export {
|
||||
buildCursorSetupSnapshot,
|
||||
installCursorGlobalConfig,
|
||||
replaceCursorGlobalConfig,
|
||||
removeCursorGlobalConfig,
|
||||
} from "./agent-setup-editor-tools-cursor.js";
|
||||
export type {
|
||||
CursorSetupPreview,
|
||||
CursorSetupStatus,
|
||||
} from "./agent-setup-editor-tools-cursor.js";
|
||||
130
apps/desktop/src/agent-setup-support.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { buildFamiliarOSMcpServerCommand, formatCommandForDisplay, type FamiliarOSCommandMode } from "@familiaros/claude";
|
||||
|
||||
import { getPreferredNodeCommand } from "./agent-setup-command-context.js";
|
||||
import { installClaudeFamiliarOSMemory, uninstallClaudeFamiliarOSMemory } from "./claude-memory.js";
|
||||
|
||||
export type JournalAction = "configure" | "update" | "replace" | "remove";
|
||||
|
||||
export interface AgentSetupJournalEntry {
|
||||
readonly timestamp: string;
|
||||
readonly action: JournalAction;
|
||||
readonly selectedPetId?: string;
|
||||
readonly command: readonly string[];
|
||||
readonly previousStatus: string;
|
||||
readonly success: boolean;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface FamiliarOSMcpServerPreview {
|
||||
readonly commandMode: FamiliarOSCommandMode;
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly displayCommand: string;
|
||||
readonly mcpJson: {
|
||||
readonly mcpServers: {
|
||||
readonly familiaros: {
|
||||
readonly type: "stdio";
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type MemoryInstallResult = { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string };
|
||||
type HookJournalCommand = "doctor-hooks" | "install-hooks";
|
||||
type JournalWriteEntry = Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string };
|
||||
|
||||
export function buildFamiliarOSMcpServerPreview(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): FamiliarOSMcpServerPreview {
|
||||
const nodeCommand = getPreferredNodeCommand();
|
||||
const { command, args } = buildFamiliarOSMcpServerCommand(selectedPetId, commandMode, nodeCommand);
|
||||
const displayCommand = formatCommandForDisplay({ command, args });
|
||||
return {
|
||||
commandMode,
|
||||
command,
|
||||
args,
|
||||
displayCommand,
|
||||
mcpJson: {
|
||||
mcpServers: {
|
||||
familiaros: { type: "stdio", command, args },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeMemoryMessages(...messages: readonly string[]): string {
|
||||
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:FamiliarOS )?instructions[^.]*\./g) ?? []);
|
||||
return memoryMessages.length > 0 ? ` ${memoryMessages.join(" ")}` : "";
|
||||
}
|
||||
|
||||
export function createHookJournalCommand(command: HookJournalCommand, selectedPetId: string | undefined): readonly string[] {
|
||||
return selectedPetId ? ["familiaros-claude", command, "--familiar", selectedPetId] : ["familiaros-claude", command];
|
||||
}
|
||||
|
||||
export function safeInstallClaudeMemory(homeDir: string): MemoryInstallResult {
|
||||
try {
|
||||
const result = installClaudeFamiliarOSMemory(homeDir);
|
||||
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." };
|
||||
}
|
||||
}
|
||||
|
||||
export function safeUninstallClaudeMemory(homeDir: string): MemoryInstallResult {
|
||||
try {
|
||||
const result = uninstallClaudeFamiliarOSMemory(homeDir);
|
||||
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." };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeActionJournal({
|
||||
entry,
|
||||
userDataPath,
|
||||
formatUserPath,
|
||||
sanitizeOutput,
|
||||
}: {
|
||||
readonly entry: JournalWriteEntry;
|
||||
readonly userDataPath: string;
|
||||
readonly formatUserPath: (path: string | undefined) => string | undefined;
|
||||
readonly sanitizeOutput: (value: string) => string;
|
||||
}): void {
|
||||
try {
|
||||
const path = getJournalPath(userDataPath);
|
||||
const entries = readActionJournal(userDataPath).concat({
|
||||
...entry,
|
||||
command: entry.command.map((part) => formatUserPath(part) ?? part),
|
||||
message: sanitizeOutput(entry.message),
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
}).slice(-20);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, path);
|
||||
} catch (error) {
|
||||
console.error("Failed to write FamiliarOS agent setup action journal.", error);
|
||||
}
|
||||
}
|
||||
|
||||
function readActionJournal(userDataPath: string): AgentSetupJournalEntry[] {
|
||||
const path = getJournalPath(userDataPath);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter(isJournalEntry).slice(-20) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getJournalPath(userDataPath: string): string {
|
||||
return join(userDataPath, "agent-setup-actions.json");
|
||||
}
|
||||
|
||||
function isJournalEntry(value: unknown): value is AgentSetupJournalEntry {
|
||||
return typeof value === "object" && value !== null && typeof (value as { timestamp?: unknown }).timestamp === "string";
|
||||
}
|
||||
|
|
@ -1,19 +1,41 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { constants, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, accessSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { app } from "electron";
|
||||
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, createOpenPetsHookSettingsPreview, doctorClaudeHooks, 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 { createFamiliarOSHookSettingsPreview, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type FamiliarOSCommandMode, type ParsedClaudeMcpEntry } from "@familiaros/claude";
|
||||
|
||||
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type OpenPetsStateV1 } from "./app-state.js";
|
||||
import { doctorClaudeOpenPetsMemory, installClaudeOpenPetsMemory, uninstallClaudeOpenPetsMemory, type ClaudeOpenPetsMemoryStatus } from "./claude-memory.js";
|
||||
import type { CursorSetupPreview, CursorSetupStatus, OpenCodeSetupPreview, OpenCodeSetupStatus } from "./agent-setup-editor-tools.js";
|
||||
import {
|
||||
executeAgentSetupResolvedAction,
|
||||
getCursorSetup,
|
||||
getOpenCodeSetup,
|
||||
runAgentSetupMcpServerHealthCheck,
|
||||
} from "./agent-setup-actions.js";
|
||||
import {
|
||||
createBundledResourceErrorStatus,
|
||||
createHookErrorStatus,
|
||||
detectClaudeCodeStatus,
|
||||
formatUserPath,
|
||||
runClaudeCommand,
|
||||
runCommand,
|
||||
safeBuildClaudeMcpPreview,
|
||||
safeDoctorClaudeHooks,
|
||||
sanitizeAgentSetupOutput,
|
||||
summarizeCommandResult,
|
||||
} from "./agent-setup-claude-status.js";
|
||||
import {
|
||||
writeActionJournal,
|
||||
type AgentSetupJournalEntry,
|
||||
type JournalAction,
|
||||
} from "./agent-setup-support.js";
|
||||
import {
|
||||
getStoredAgentSetupCommandPaths,
|
||||
updateStoredAgentSetupCommandPaths,
|
||||
} from "./agent-setup-command-context.js";
|
||||
import { getAppStateSnapshot, type InstalledPetState } from "./app-state.js";
|
||||
import { doctorClaudeFamiliarOSMemory, 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";
|
||||
export { buildFamiliarOSMcpServerPreview } from "./agent-setup-support.js";
|
||||
export type { AgentSetupJournalEntry, FamiliarOSMcpServerPreview, JournalAction } from "./agent-setup-support.js";
|
||||
export { sanitizeAgentSetupOutput } from "./agent-setup-claude-status.js";
|
||||
|
||||
export interface AgentSetupPetOption {
|
||||
readonly id: string;
|
||||
|
|
@ -36,13 +58,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;
|
||||
|
|
@ -58,44 +80,7 @@ export interface AgentSetupCommandPaths {
|
|||
readonly opencode: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeSetupStatus {
|
||||
readonly state: "configured" | "needs_setup" | "not_detected" | "error";
|
||||
readonly label: string;
|
||||
readonly details: string;
|
||||
readonly configDir: string;
|
||||
readonly canInstall: boolean;
|
||||
readonly canRemove: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeSetupPreview {
|
||||
readonly global: true;
|
||||
readonly configDir: string;
|
||||
readonly configPath: string;
|
||||
readonly cleanupConfigPaths: readonly string[];
|
||||
readonly mcpCommand: readonly string[];
|
||||
readonly plugin: readonly unknown[] | string;
|
||||
readonly instructionPath: string;
|
||||
readonly configPreview: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CursorSetupStatus {
|
||||
readonly state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update";
|
||||
readonly label: string;
|
||||
readonly details: string;
|
||||
readonly configPath: string;
|
||||
readonly canInstall: boolean;
|
||||
readonly canReplace: boolean;
|
||||
readonly canRemove: boolean;
|
||||
}
|
||||
|
||||
export interface CursorSetupPreview {
|
||||
readonly global: true;
|
||||
readonly configPath: string;
|
||||
readonly mcpEntry: RedactedPreview;
|
||||
readonly rulesPath: string;
|
||||
readonly rulesContent: string;
|
||||
readonly commandMode: "published" | "local" | "bundled";
|
||||
}
|
||||
export type { CursorSetupPreview, CursorSetupStatus, OpenCodeSetupPreview, OpenCodeSetupStatus } from "./agent-setup-editor-tools.js";
|
||||
|
||||
export interface AgentSetupActionResult {
|
||||
readonly ok: boolean;
|
||||
|
|
@ -104,28 +89,6 @@ export interface AgentSetupActionResult {
|
|||
readonly changed: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSetupJournalEntry {
|
||||
readonly timestamp: string;
|
||||
readonly action: JournalAction;
|
||||
readonly selectedPetId?: string;
|
||||
readonly command: readonly string[];
|
||||
readonly previousStatus: string;
|
||||
readonly success: boolean;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly ok: boolean;
|
||||
readonly timedOut: boolean;
|
||||
readonly exitCode: number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const commandTimeoutMs = 6_000;
|
||||
const maxOutputBytes = 16_384;
|
||||
const require = createRequire(import.meta.url);
|
||||
let operationRunning = false;
|
||||
let lastAction: AgentSetupActionResult | undefined;
|
||||
|
||||
|
|
@ -136,10 +99,10 @@ 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);
|
||||
const opencode = await getOpenCodeSetup(commandMode, petId, runCommand, formatUserPath);
|
||||
const cursor = await getCursorSetup(commandMode, petId, formatUserPath);
|
||||
|
||||
return {
|
||||
selectedPetId: petId,
|
||||
|
|
@ -154,27 +117,16 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandMode
|
|||
opencodePreview: opencode.preview,
|
||||
cursorStatus: cursor.status,
|
||||
cursorPreview: cursor.preview,
|
||||
commandPaths: getAgentSetupCommandPaths(),
|
||||
commandPaths: getStoredAgentSetupCommandPaths(),
|
||||
busy: operationRunning,
|
||||
lastAction,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAgentSetupCommandPaths(patch: unknown): AgentSetupCommandPaths {
|
||||
if (!isRecord(patch)) throw new Error("Invalid command path settings.");
|
||||
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"]>> = {};
|
||||
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");
|
||||
updatePreferences(updates);
|
||||
return getAgentSetupCommandPaths();
|
||||
return updateStoredAgentSetupCommandPaths(patch);
|
||||
}
|
||||
|
||||
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
export async function runAgentSetupAction(action: AgentSetupAction, selectedPetId?: unknown, commandModeInput?: unknown): Promise<AgentSetupSnapshot> {
|
||||
if (operationRunning) throw new Error("Another Claude setup operation is already running.");
|
||||
const petId = validateSelectedPetId(selectedPetId);
|
||||
|
|
@ -182,7 +134,20 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
|
|||
operationRunning = true;
|
||||
|
||||
try {
|
||||
lastAction = await runAction(action, petId, commandMode);
|
||||
lastAction = await executeAgentSetupResolvedAction(action, {
|
||||
selectedPetId: petId,
|
||||
commandMode,
|
||||
safeDoctorClaudeHooks,
|
||||
safeBuildClaudeMcpPreview,
|
||||
detectClaudeCodeStatus,
|
||||
formatUserPath,
|
||||
sanitizeOutput: sanitizeAgentSetupOutput,
|
||||
summarizeCommandResult,
|
||||
runCommand,
|
||||
runClaudeCommand,
|
||||
appendAgentSetupJournal,
|
||||
journalActionFor,
|
||||
});
|
||||
operationRunning = false;
|
||||
return getAgentSetupSnapshot(petId, commandMode);
|
||||
} finally {
|
||||
|
|
@ -190,705 +155,45 @@ export async function runAgentSetupAction(action: AgentSetupAction, selectedPetI
|
|||
}
|
||||
}
|
||||
|
||||
export function sanitizeAgentSetupOutput(value: string): string {
|
||||
const home = app.isReady() ? app.getPath("home") : "";
|
||||
return value
|
||||
.replaceAll(home, "~")
|
||||
.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "<path>")
|
||||
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "<redacted-private-key>")
|
||||
.replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
|
||||
.replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=<redacted>")
|
||||
.slice(0, 500);
|
||||
function appendAgentSetupJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
|
||||
writeActionJournal({
|
||||
entry,
|
||||
userDataPath: app.getPath("userData"),
|
||||
formatUserPath,
|
||||
sanitizeOutput: sanitizeAgentSetupOutput,
|
||||
});
|
||||
}
|
||||
|
||||
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { 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.";
|
||||
return { preview: createErrorPreview(commandMode, message), error: message };
|
||||
}
|
||||
export interface FamiliarOSMcpServerHealth {
|
||||
readonly ok: boolean;
|
||||
readonly output: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, 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.");
|
||||
}
|
||||
}
|
||||
|
||||
function createErrorPreview(commandMode: OpenPetsCommandMode, 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: [] } } },
|
||||
displayCommand: message,
|
||||
};
|
||||
}
|
||||
|
||||
function withPreferredClaudeCommand(preview: ClaudeMcpPreview): ClaudeMcpPreview {
|
||||
const claude = getPreferredClaudeCommand();
|
||||
if (claude === preview.add.command && claude === preview.remove.command) return preview;
|
||||
return {
|
||||
...preview,
|
||||
add: { ...preview.add, command: claude },
|
||||
remove: { ...preview.remove, command: claude },
|
||||
displayCommand: preview.displayCommand.replace(/^claude(?=\s|$)/, quoteCommandForDisplay(claude)),
|
||||
};
|
||||
}
|
||||
|
||||
function createBundledResourceErrorStatus(message: string): ClaudeCodeStatus {
|
||||
return createStatus("error", "Packaged commands unavailable", message, undefined, { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: message }, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
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> {
|
||||
if (action === "opencode-install") return installOpenCodeGlobal(selectedPetId, commandMode);
|
||||
if (action === "opencode-remove") return removeOpenCodeGlobal();
|
||||
if (action === "cursor-install") return installCursorGlobal(selectedPetId, commandMode);
|
||||
if (action === "cursor-replace") return replaceCursorGlobal(selectedPetId, commandMode);
|
||||
if (action === "cursor-remove") return removeCursorGlobal();
|
||||
if (action === "doctor-hooks") {
|
||||
const doctor = safeDoctorClaudeHooks(commandMode, selectedPetId);
|
||||
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("doctor-hooks", selectedPetId), previousStatus: doctor.status, success: doctor.status !== "error", message: doctor.message });
|
||||
return { ok: doctor.status !== "error", action, message: doctor.message, changed: false };
|
||||
}
|
||||
if (action === "uninstall-hooks") {
|
||||
let result;
|
||||
try {
|
||||
result = uninstallClaudeHooks(undefined, commandMode);
|
||||
} catch (error) {
|
||||
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets 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 });
|
||||
return { ok: result.status !== "error", action, message, changed: result.changed };
|
||||
}
|
||||
if (action === "install-memory") {
|
||||
const result = safeInstallClaudeMemory();
|
||||
return { ok: result.ok, action, message: result.ok ? result.message : `Claude instructions were not updated: ${result.message}`, changed: result.ok && result.message.startsWith("Added") };
|
||||
}
|
||||
if (action === "remove") {
|
||||
return runRemove(createErrorPreview(commandMode, ""), selectedPetId, "Unknown", action);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
const previewResult = safeBuildClaudeMcpPreview(selectedPetId, commandMode);
|
||||
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
|
||||
|
||||
if (action === "install-hooks") {
|
||||
let result;
|
||||
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 };
|
||||
}
|
||||
const message = result.changed ? `Installed OpenPets 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 };
|
||||
}
|
||||
const detection = await detectClaudeCodeStatus(selectedPetId, commandMode);
|
||||
const previousStatus = detection.label;
|
||||
const preview = previewResult.preview;
|
||||
|
||||
if (detection.state === "not_detected") {
|
||||
const result = { ok: false, action, message: "Claude Code was not found. Install Claude Code or use Copy command to configure manually.", changed: false };
|
||||
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: false, message: result.message });
|
||||
return result;
|
||||
}
|
||||
|
||||
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}`}`;
|
||||
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 runAdd(preview, selectedPetId, previousStatus, action);
|
||||
}
|
||||
|
||||
if (!detection.openPetsEntry.present) {
|
||||
return runAdd(preview, selectedPetId, previousStatus, action);
|
||||
}
|
||||
|
||||
const removed = await runRemove(preview, selectedPetId, previousStatus, action);
|
||||
if (!removed.ok) return removed;
|
||||
const added = await runAdd(preview, selectedPetId, previousStatus, action);
|
||||
if (!added.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
action,
|
||||
message: `${added.message} The previous openpets 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 };
|
||||
}
|
||||
|
||||
async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, 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();
|
||||
const pluginVersion = getOpenCodePackageVersion();
|
||||
const cliEntryPath = commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode);
|
||||
const prepared = safePrepareOpenCode(configDir, petId, cliVersion, pluginVersion, commandMode, cliEntryPath);
|
||||
const detected = await runCommand({ command: getPreferredOpenCodeCommand(), args: ["--version"] });
|
||||
const globalState = doctorOpenCodeGlobalSetup(configDir);
|
||||
const configured = globalState.status === "installed";
|
||||
return {
|
||||
status: {
|
||||
state: globalState.status === "error" || globalState.status === "custom" || globalState.status === "conflict" ? "error" : configured ? "configured" : detected.ok ? "needs_setup" : "not_detected",
|
||||
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detected.ok ? "Ready" : "Not detected",
|
||||
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detected.ok ? "OpenCode was detected. Desktop setup writes global OpenCode config." : getPreferredOpenCodeCommand() === (process.platform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
|
||||
configDir: formatUserPath(configDir) ?? configDir,
|
||||
canInstall: prepared.ok && !configured,
|
||||
canRemove: configured,
|
||||
},
|
||||
preview: {
|
||||
global: true,
|
||||
configDir: formatUserPath(configDir) ?? configDir,
|
||||
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}`),
|
||||
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 }> {
|
||||
const homeDir = app.getPath("home");
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const petId = selectedPetId || undefined;
|
||||
const mcpVersion = getMcpPackageVersion();
|
||||
|
||||
const configResult = readCursorMcpConfig(configPath);
|
||||
const statusResult = classifyCursorMcpStatus(configResult, configPath, { mcpVersion, petId, commandMode: "published" });
|
||||
|
||||
const state = mapCursorStatusToState(statusResult.status);
|
||||
const label = mapCursorStatusToLabel(statusResult.status);
|
||||
const details = statusResult.message;
|
||||
|
||||
return {
|
||||
status: {
|
||||
state,
|
||||
label,
|
||||
details,
|
||||
configPath: formatUserPath(configPath) ?? configPath,
|
||||
canInstall: statusResult.canInstall,
|
||||
canReplace: statusResult.canReplace,
|
||||
canRemove: statusResult.canRemove,
|
||||
},
|
||||
preview: {
|
||||
global: true,
|
||||
configPath: formatUserPath(configPath) ?? configPath,
|
||||
mcpEntry: buildOpenPetsOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
|
||||
rulesPath: ".cursor/rules/openpets.mdc",
|
||||
rulesContent: buildCursorRulesPreview(),
|
||||
commandMode: "published",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapCursorStatusToState(status: CursorMcpStatusResult["status"]): CursorSetupStatus["state"] {
|
||||
switch (status) {
|
||||
case "installed":
|
||||
return "configured";
|
||||
case "missing":
|
||||
return "needs_setup";
|
||||
case "needs-update":
|
||||
return "needs_update";
|
||||
case "conflict":
|
||||
return "conflict";
|
||||
case "invalid":
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function mapCursorStatusToLabel(status: CursorMcpStatusResult["status"]): string {
|
||||
switch (status) {
|
||||
case "installed":
|
||||
return "Configured";
|
||||
case "missing":
|
||||
return "Not configured";
|
||||
case "needs-update":
|
||||
return "Needs update";
|
||||
case "conflict":
|
||||
return "Conflict";
|
||||
case "invalid":
|
||||
case "error":
|
||||
return "Config error";
|
||||
default:
|
||||
return "Checking";
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentSetupCommandPaths(): AgentSetupCommandPaths {
|
||||
const preferences = getAppStateSnapshot().preferences;
|
||||
return {
|
||||
claude: preferences.claudeCommandPath ?? "",
|
||||
node: preferences.nodeCommandPath ?? "",
|
||||
opencode: preferences.opencodeCommandPath ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function getPreferredClaudeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.claudeCommandPath || "claude";
|
||||
}
|
||||
|
||||
function getPreferredNodeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.nodeCommandPath || "node";
|
||||
}
|
||||
|
||||
function getPreferredOpenCodeCommand(): string {
|
||||
return getAppStateSnapshot().preferences.opencodeCommandPath || (process.platform === "win32" ? "opencode.cmd" : "opencode");
|
||||
}
|
||||
|
||||
function normalizeOptionalCommandPath(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new Error(`${label} command path must be text.`);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length > 4096 || /[\r\n\0]/.test(trimmed)) throw new Error(`${label} command path is invalid.`);
|
||||
if (!isAbsolute(trimmed)) throw new Error(`${label} command path must be a full absolute path.`);
|
||||
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) throw new Error(`${label} command path contains unsupported shell characters.`);
|
||||
try {
|
||||
const stat = statSync(trimmed);
|
||||
if (!stat.isFile()) throw new Error();
|
||||
if (process.platform !== "win32") accessSync(trimmed, constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`${label} command path must point to an existing executable file.`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
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 } {
|
||||
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 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 };
|
||||
} 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> {
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
} catch (error) {
|
||||
return { ok: false, action: "opencode-install", message: error instanceof Error ? error.message : "OpenCode setup failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
|
||||
try {
|
||||
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 };
|
||||
} 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> {
|
||||
void commandMode;
|
||||
try {
|
||||
const homeDir = app.getPath("home");
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const mcpVersion = getMcpPackageVersion();
|
||||
const plan = planCursorMcpInstall(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-install", message: plan.message, changed: false };
|
||||
}
|
||||
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: false, action: "cursor-install", message: "Failed to plan Cursor MCP install.", changed: false };
|
||||
} catch (error) {
|
||||
return { ok: false, action: "cursor-install", message: error instanceof Error ? error.message : "Cursor MCP install failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
|
||||
void commandMode;
|
||||
try {
|
||||
const homeDir = app.getPath("home");
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const mcpVersion = getMcpPackageVersion();
|
||||
const plan = planCursorMcpReplace(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-replace", message: plan.message, changed: false };
|
||||
}
|
||||
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: false, action: "cursor-replace", message: "Failed to plan Cursor MCP replace.", changed: false };
|
||||
} catch (error) {
|
||||
return { ok: false, action: "cursor-replace", message: error instanceof Error ? error.message : "Cursor MCP replace failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCursorGlobal(): Promise<AgentSetupActionResult> {
|
||||
try {
|
||||
const homeDir = app.getPath("home");
|
||||
const configPath = getCursorGlobalMcpPath(homeDir);
|
||||
const plan = planCursorMcpRemove(configPath);
|
||||
if ("ok" in plan && !plan.ok) {
|
||||
return { ok: false, action: "cursor-remove", message: plan.message, changed: false };
|
||||
}
|
||||
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: false, action: "cursor-remove", message: "Failed to plan Cursor MCP remove.", changed: false };
|
||||
} catch (error) {
|
||||
return { ok: false, action: "cursor-remove", message: error instanceof Error ? error.message : "Cursor MCP remove failed.", changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getDesktopCliEntryPath(commandMode: OpenPetsCommandMode): string {
|
||||
const path = require.resolve("@open-pets/cli");
|
||||
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
|
||||
}
|
||||
|
||||
function getCliPackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@open-pets/cli");
|
||||
}
|
||||
|
||||
function getOpenCodePackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@open-pets/opencode");
|
||||
}
|
||||
|
||||
function getWorkspacePackageVersion(packageName: string): string {
|
||||
try {
|
||||
const entryPath = require.resolve(packageName);
|
||||
const packageJsonPath = join(dirname(dirname(entryPath)), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { readonly version?: unknown };
|
||||
return typeof packageJson.version === "string" && packageJson.version ? packageJson.version : "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
function getMcpPackageVersion(): string {
|
||||
return getWorkspacePackageVersion("@open-pets/mcp");
|
||||
}
|
||||
|
||||
function summarizeMemoryMessages(...messages: readonly string[]): string {
|
||||
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:OpenPets )?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];
|
||||
}
|
||||
|
||||
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}`}`
|
||||
: `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 };
|
||||
}
|
||||
|
||||
async function runRemove(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
|
||||
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}`}`
|
||||
: `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 };
|
||||
}
|
||||
|
||||
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." };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
|
||||
}
|
||||
}
|
||||
|
||||
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." };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
|
||||
}
|
||||
}
|
||||
|
||||
async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): 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 });
|
||||
}
|
||||
|
||||
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
|
||||
if (!version.ok) {
|
||||
const hasOverride = getPreferredClaudeCommand() !== "claude";
|
||||
return createStatus("not_detected", "Not detected", `${hasOverride ? "Claude Code did not run from the saved command path" : "Claude Code was not found or did not run"}: ${summarizeCommandResult(version)}`, undefined, version, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
const list = await runClaudeCommandWithTimeoutRetry({ command: "claude", args: ["mcp", "list"] });
|
||||
if (!list.ok) {
|
||||
return createStatus("error", "Error / needs attention", `Claude Code was detected, but MCP status failed: ${summarizeCommandResult(list)}`, sanitizeAgentSetupOutput(version.stdout || version.stderr), list, { present: false, source: "none", verified: false, matchesExpected: false });
|
||||
}
|
||||
|
||||
const listed = classifyClaudeMcpStatus(list.stdout, undefined, selectedPetId, commandMode, getPreferredNodeCommand());
|
||||
let entry = listed;
|
||||
if (listed.present) {
|
||||
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
|
||||
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);
|
||||
}
|
||||
|
||||
async function runClaudeCommandWithTimeoutRetry(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
const first = await runClaudeCommand(spec);
|
||||
if (!first.timedOut) return first;
|
||||
await delay(250);
|
||||
const second = await runClaudeCommand(spec);
|
||||
return second.ok ? second : first;
|
||||
}
|
||||
|
||||
function createStatus(state: ClaudeCodeStatus["state"], label: string, details: string, version: string | undefined, listResult: CommandResult, entry: ParsedClaudeMcpEntry): ClaudeCodeStatus {
|
||||
return {
|
||||
state,
|
||||
label,
|
||||
details,
|
||||
claudeCommand: "claude",
|
||||
version,
|
||||
mcpListWorks: listResult.ok,
|
||||
openPetsEntry: entry,
|
||||
canConfigure: state === "needs_setup",
|
||||
canReplace: entry.present && !(entry.verified && entry.matchesExpected),
|
||||
canRemove: entry.present,
|
||||
};
|
||||
export async function testFamiliarOSMcpServer(selectedPetId: string | undefined, commandMode: FamiliarOSCommandMode): Promise<FamiliarOSMcpServerHealth> {
|
||||
return runAgentSetupMcpServerHealthCheck(selectedPetId, commandMode, runCommand);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
for (const command of getClaudeCommandCandidates(spec.command)) {
|
||||
const result = await runCommand({ command, args: spec.args });
|
||||
if (result.ok || !isCommandNotFound(result)) return result;
|
||||
}
|
||||
return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: "Claude command was not found." };
|
||||
}
|
||||
|
||||
function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const command = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
|
||||
const args = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, { cwd: app.getPath("home"), env: createCommandEnv(), windowsHide: true, shell: false });
|
||||
} catch (error) {
|
||||
resolve({ ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : "Command failed to start." });
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill();
|
||||
resolve({ ok: false, timedOut: true, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: "Command timed out." });
|
||||
}, commandTimeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => { stdout = appendBounded(stdout, chunk.toString("utf8")); });
|
||||
child.stderr?.on("data", (chunk: Buffer) => { stderr = appendBounded(stderr, chunk.toString("utf8")); });
|
||||
child.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, timedOut: false, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: error.message });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: code === 0, timedOut: false, exitCode: code, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: undefined });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getClaudeCommandCandidates(command: string): readonly string[] {
|
||||
if (command !== "claude") return [command];
|
||||
const preferred = getPreferredClaudeCommand();
|
||||
if (preferred !== "claude") return [preferred];
|
||||
if (process.platform === "win32") return ["claude", "claude.cmd"];
|
||||
return ["claude"];
|
||||
}
|
||||
|
||||
function createCommandEnv(): NodeJS.ProcessEnv {
|
||||
const separator = process.platform === "win32" ? ";" : ":";
|
||||
const existingPath = process.env.PATH ?? "";
|
||||
return { ...process.env, PATH: dedupePathEntries([existingPath, ...getExtraCommandPaths()], separator).join(separator) };
|
||||
}
|
||||
|
||||
function getExtraCommandPaths(): readonly string[] {
|
||||
if (process.platform === "win32") return [];
|
||||
const home = app.getPath("home");
|
||||
const env = process.env;
|
||||
return filterExistingPaths([
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
join(home, "bin"),
|
||||
join(home, ".local", "bin"),
|
||||
join(home, ".opencode", "bin"),
|
||||
join(env.VOLTA_HOME || join(home, ".volta"), "bin"),
|
||||
join(env.BUN_INSTALL || join(home, ".bun"), "bin"),
|
||||
join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"),
|
||||
join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"),
|
||||
env.PNPM_HOME,
|
||||
join(home, ".local", "share", "pnpm"),
|
||||
join(home, "Library", "pnpm"),
|
||||
join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"),
|
||||
]);
|
||||
}
|
||||
|
||||
function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] {
|
||||
return paths.filter((path): path is string => Boolean(path && existsSync(path)));
|
||||
}
|
||||
|
||||
function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: string[] = [];
|
||||
for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) {
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
entries.push(path);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isCommandNotFound(result: CommandResult): boolean {
|
||||
return Boolean(result.error && /ENOENT|not found/i.test(result.error));
|
||||
}
|
||||
|
||||
function summarizeCommandResult(result: CommandResult): string {
|
||||
if (result.timedOut) return "command timed out.";
|
||||
const output = sanitizeAgentSetupOutput(result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`);
|
||||
return output || "command failed.";
|
||||
}
|
||||
|
||||
function formatUserPath(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return path.replace(app.getPath("home"), "~");
|
||||
}
|
||||
|
||||
function appendBounded(existing: string, next: string): string {
|
||||
const combined = existing + next;
|
||||
return combined.length > maxOutputBytes ? combined.slice(combined.length - maxOutputBytes) : combined;
|
||||
}
|
||||
|
||||
function writeActionJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
|
||||
try {
|
||||
const path = getJournalPath();
|
||||
const entries = readActionJournal().concat({ ...entry, command: entry.command.map((part) => formatUserPath(part) ?? part), message: sanitizeAgentSetupOutput(entry.message), timestamp: entry.timestamp || new Date().toISOString() }).slice(-20);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.tmp`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function readActionJournal(): AgentSetupJournalEntry[] {
|
||||
const path = getJournalPath();
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter(isJournalEntry).slice(-20) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getJournalPath(): string {
|
||||
return join(app.getPath("userData"), "agent-setup-actions.json");
|
||||
}
|
||||
|
||||
function isJournalEntry(value: unknown): value is AgentSetupJournalEntry {
|
||||
return typeof value === "object" && value !== null && typeof (value as { timestamp?: unknown }).timestamp === "string";
|
||||
function isUsablePet(familiar: InstalledPetState): boolean {
|
||||
return familiar.installed && !familiar.broken && !familiar.builtIn;
|
||||
}
|
||||
|
||||
function journalActionFor(action: AgentSetupAction): JournalAction {
|
||||
|
|
@ -899,5 +204,5 @@ function journalActionFor(action: AgentSetupAction): JournalAction {
|
|||
|
||||
export const agentSetupInternalsForChecks = {
|
||||
sanitizeAgentSetupOutput,
|
||||
createOpenPetsHookSettingsPreview,
|
||||
createFamiliarOSHookSettingsPreview,
|
||||
};
|
||||
|
|
|
|||
96
apps/desktop/src/app-state-analytics.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { builtInPet } from "./built-in-familiar.js";
|
||||
import { allowedReactions, type FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
|
||||
const installedPetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
export interface FamiliarOSAnalyticsState {
|
||||
readonly messagesSent: number;
|
||||
readonly reactionsSent: number;
|
||||
readonly reactionCounts: Record<FamiliarOSReaction, number>;
|
||||
readonly perPetActivityCounts: Record<string, number>;
|
||||
readonly lastActivityAt?: number;
|
||||
}
|
||||
|
||||
export type FamiliarOSActivityRecord =
|
||||
| { readonly kind: "say"; readonly reaction?: FamiliarOSReaction; readonly petId?: string }
|
||||
| { readonly kind: "react"; readonly reaction: FamiliarOSReaction; readonly petId?: string };
|
||||
|
||||
export function createDefaultAnalyticsState(): FamiliarOSAnalyticsState {
|
||||
return {
|
||||
messagesSent: 0,
|
||||
reactionsSent: 0,
|
||||
reactionCounts: normalizeReactionCounts(undefined),
|
||||
perPetActivityCounts: {},
|
||||
lastActivityAt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRecordedActivityAnalytics(
|
||||
analytics: FamiliarOSAnalyticsState,
|
||||
activity: FamiliarOSActivityRecord,
|
||||
now: number = Date.now(),
|
||||
): FamiliarOSAnalyticsState {
|
||||
const reaction = activity.reaction;
|
||||
const petId = activity.petId;
|
||||
|
||||
return {
|
||||
messagesSent: analytics.messagesSent + (activity.kind === "say" ? 1 : 0),
|
||||
reactionsSent: analytics.reactionsSent + (reaction ? 1 : 0),
|
||||
reactionCounts: reaction
|
||||
? { ...analytics.reactionCounts, [reaction]: (analytics.reactionCounts[reaction] ?? 0) + 1 }
|
||||
: analytics.reactionCounts,
|
||||
perPetActivityCounts: petId
|
||||
? { ...analytics.perPetActivityCounts, [petId]: (analytics.perPetActivityCounts[petId] ?? 0) + 1 }
|
||||
: analytics.perPetActivityCounts,
|
||||
lastActivityAt: normalizeTimestamp(now) ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAnalytics(value: unknown): FamiliarOSAnalyticsState {
|
||||
const record = isRecord(value) ? value : {};
|
||||
return {
|
||||
messagesSent: normalizeCount(record.messagesSent),
|
||||
reactionsSent: normalizeCount(record.reactionsSent),
|
||||
reactionCounts: normalizeReactionCounts(record.reactionCounts),
|
||||
perPetActivityCounts: normalizePerPetActivityCounts(record.perPetActivityCounts),
|
||||
lastActivityAt: normalizeTimestamp(record.lastActivityAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionCounts(value: unknown): Record<FamiliarOSReaction, number> {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const counts = {} as Record<FamiliarOSReaction, number>;
|
||||
for (const reaction of allowedReactions) {
|
||||
counts[reaction] = normalizeCount(record[reaction]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizePerPetActivityCounts(value: unknown): Record<string, number> {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [petId, rawCount] of Object.entries(record)) {
|
||||
if (petId !== builtInPet.id && !isSafeInstalledPetId(petId)) {
|
||||
continue;
|
||||
}
|
||||
const count = normalizeCount(rawCount);
|
||||
if (count > 0) counts[petId] = count;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizeCount(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
||||
}
|
||||
|
||||
function isSafeInstalledPetId(petId: string): boolean {
|
||||
return installedPetIdPattern.test(petId) && petId !== builtInPet.id;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -3,11 +3,12 @@ export interface OnboardingPreferenceLike {
|
|||
}
|
||||
|
||||
export const petScaleOptions = [
|
||||
{ label: "XXXS", value: 0.16 },
|
||||
{ label: "XXS", value: 0.24 },
|
||||
{ label: "XS", value: 0.32 },
|
||||
{ label: "Small", value: 0.44 },
|
||||
{ label: "Medium", value: 0.56 },
|
||||
{ label: "Large", value: 0.72 },
|
||||
{ label: "S", value: 0.44 },
|
||||
{ label: "M", value: 0.56 },
|
||||
{ label: "L", value: 0.72 },
|
||||
{ label: "XL", value: 0.88 },
|
||||
{ label: "XXL", value: 1.04 },
|
||||
{ label: "XXXL", value: 1.20 },
|
||||
|
|
@ -28,7 +29,15 @@ export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): b
|
|||
return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false;
|
||||
}
|
||||
|
||||
export function markOnboardingCompleted<T extends { readonly preferences: Record<string, unknown> }>(state: T): T {
|
||||
export function normalizeFamiliarName(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (/[\r\n\0\x01-\x1f\x7f]/.test(value)) return undefined;
|
||||
const trimmed = value.trim().slice(0, 64);
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function markOnboardingCompleted<T extends { readonly preferences: OnboardingPreferenceLike }>(state: T): T {
|
||||
return {
|
||||
...state,
|
||||
preferences: {
|
||||
|
|
|
|||
103
apps/desktop/src/app-state-familiar-records.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface FamiliarStateSource {
|
||||
readonly kind?: "catalog";
|
||||
readonly catalogVersion: 2;
|
||||
readonly zip: string;
|
||||
readonly preview: string;
|
||||
}
|
||||
|
||||
export interface CodexFamiliarStateSource {
|
||||
readonly kind: "codex";
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export interface FamiliarStateRecord {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly description?: string;
|
||||
readonly builtIn: boolean;
|
||||
readonly protected: boolean;
|
||||
readonly installed: true;
|
||||
readonly source?: FamiliarStateSource | CodexFamiliarStateSource;
|
||||
readonly broken?: boolean;
|
||||
readonly brokenReason?: string;
|
||||
}
|
||||
|
||||
export function normalizeInstalledPetRecord(
|
||||
value: unknown,
|
||||
options: {
|
||||
readonly builtInPetId: string;
|
||||
readonly assertSafePetId: (petId: string) => void;
|
||||
readonly validateInstalledPetFiles: (petId: string) => string | undefined;
|
||||
},
|
||||
): FamiliarStateRecord | null {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
options.assertSafePetId(value.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const brokenReason = options.validateInstalledPetFiles(value.id);
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
displayName: value.displayName,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
builtIn: value.id === options.builtInPetId ? true : value.builtIn === true,
|
||||
protected: value.id === options.builtInPetId ? true : value.protected === true,
|
||||
installed: true,
|
||||
source: normalizeFamiliarStateSource(value.source),
|
||||
broken: brokenReason ? true : typeof value.broken === "boolean" ? value.broken : undefined,
|
||||
brokenReason: brokenReason ?? (typeof value.brokenReason === "string" ? value.brokenReason : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFamiliarStateSource(value: unknown): FamiliarStateRecord["source"] | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.kind === "codex" && typeof value.path === "string") {
|
||||
return { kind: "codex", path: value.path };
|
||||
}
|
||||
|
||||
if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "catalog",
|
||||
catalogVersion: 2,
|
||||
zip: value.zip,
|
||||
preview: value.preview,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInstalledPetFiles(
|
||||
petId: string,
|
||||
getInstalledPetDir: (petId: string) => string,
|
||||
): string | undefined {
|
||||
try {
|
||||
const dir = getInstalledPetDir(petId);
|
||||
const petJsonPath = join(dir, "familiar.json");
|
||||
const spritesheetPath = join(dir, "spritesheet.webp");
|
||||
JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown;
|
||||
const spritesheet = statSync(spritesheetPath);
|
||||
if (!spritesheet.isFile()) return "spritesheet.webp is not a file.";
|
||||
if (spritesheet.size <= 0) return "spritesheet.webp is empty.";
|
||||
if (spritesheet.size > 100 * 1024 * 1024) return "spritesheet.webp is too large.";
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Installed familiar files are invalid.";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
139
apps/desktop/src/app-state-normalization.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { builtInPet } from "./built-in-familiar.js";
|
||||
import type { Point } from "./display.js";
|
||||
import {
|
||||
createDefaultAnalyticsState,
|
||||
normalizeAnalytics,
|
||||
type FamiliarOSActivityRecord,
|
||||
type FamiliarOSAnalyticsState,
|
||||
} from "./app-state-analytics.js";
|
||||
import {
|
||||
normalizeInstalledPetRecord,
|
||||
validateInstalledPetFiles,
|
||||
type FamiliarStateRecord,
|
||||
} from "./app-state-familiar-records.js";
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
normalizePreferences,
|
||||
type FamiliarOSPreferences,
|
||||
} from "./app-state-preferences.js";
|
||||
import { assertSafePetId, getInstalledPetDir } from "./familiar-paths.js";
|
||||
|
||||
export interface InstalledPetState {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly description?: string;
|
||||
readonly builtIn: boolean;
|
||||
readonly protected: boolean;
|
||||
readonly installed: boolean;
|
||||
readonly source?: {
|
||||
readonly kind?: "catalog";
|
||||
readonly catalogVersion: 2;
|
||||
readonly zip: string;
|
||||
readonly preview: string;
|
||||
} | {
|
||||
readonly kind: "codex";
|
||||
readonly path: string;
|
||||
};
|
||||
readonly broken?: boolean;
|
||||
readonly brokenReason?: string;
|
||||
}
|
||||
|
||||
export interface FamiliarOSStateV1 {
|
||||
readonly version: 1;
|
||||
readonly preferences: FamiliarOSPreferences;
|
||||
readonly familiars: {
|
||||
readonly installed: readonly InstalledPetState[];
|
||||
};
|
||||
readonly defaultPet: {
|
||||
readonly position?: Point;
|
||||
};
|
||||
readonly analytics: FamiliarOSAnalyticsState;
|
||||
}
|
||||
|
||||
export function normalizeState(value: unknown): FamiliarOSStateV1 {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {};
|
||||
const preferencesRecord = isRecord(record.preferences) ? record.preferences : {};
|
||||
const defaultState = createDefaultState();
|
||||
const position = normalizeMaybePosition(defaultPetRecord.position);
|
||||
const installedPets = normalizeInstalledPets(record);
|
||||
const defaultPetId = typeof preferencesRecord.defaultPetId === "string"
|
||||
&& installedPets.some((familiar) => familiar.id === preferencesRecord.defaultPetId && !familiar.broken)
|
||||
? preferencesRecord.defaultPetId
|
||||
: builtInPet.id;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
preferences: normalizePreferences({
|
||||
...defaultState.preferences,
|
||||
...preferencesRecord,
|
||||
defaultPetId,
|
||||
}),
|
||||
familiars: {
|
||||
installed: installedPets,
|
||||
},
|
||||
defaultPet: position ? { position } : {},
|
||||
analytics: normalizeAnalytics(record.analytics),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultState(): FamiliarOSStateV1 {
|
||||
return {
|
||||
version: 1,
|
||||
preferences: createDefaultPreferences(),
|
||||
familiars: {
|
||||
installed: [builtInPet],
|
||||
},
|
||||
defaultPet: {},
|
||||
analytics: createDefaultAnalyticsState(),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneState(state: FamiliarOSStateV1): FamiliarOSStateV1 {
|
||||
return structuredClone(state) as FamiliarOSStateV1;
|
||||
}
|
||||
|
||||
export function normalizePosition(value: Partial<Point>): Point | undefined {
|
||||
if (typeof value.x !== "number" || typeof value.y !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(value.x),
|
||||
y: Math.round(value.y),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstalledPets(value: Record<string, unknown>): InstalledPetState[] {
|
||||
const installed = isRecord(value.familiars) && Array.isArray(value.familiars.installed)
|
||||
? value.familiars.installed
|
||||
: [];
|
||||
|
||||
const normalized = installed
|
||||
.map((familiar) => normalizeInstalledPetRecord(familiar, {
|
||||
builtInPetId: builtInPet.id,
|
||||
assertSafePetId,
|
||||
validateInstalledPetFiles: (petId) => validateInstalledPetFiles(petId, getInstalledPetDir),
|
||||
}))
|
||||
.filter((familiar): familiar is FamiliarStateRecord => Boolean(familiar && familiar.id !== builtInPet.id));
|
||||
|
||||
return [builtInPet, ...normalized];
|
||||
}
|
||||
|
||||
function normalizeMaybePosition(value: unknown): Point | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizePosition(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export type { FamiliarOSActivityRecord };
|
||||
189
apps/desktop/src/app-state-preferences.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { statSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { defaultPetScale, normalizeFamiliarName, normalizeOnboardingCompleted, normalizePetScale } from "./app-state-core.js";
|
||||
import { builtInPet } from "./built-in-familiar.js";
|
||||
import { isSupportedLocale, type LocalePreference } from "./i18n/catalog.js";
|
||||
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
|
||||
|
||||
export interface FamiliarOSPreferences {
|
||||
readonly defaultPetId: string;
|
||||
readonly openDefaultPetOnLaunch: boolean;
|
||||
readonly locale: LocalePreference;
|
||||
readonly speechBubblesEnabled: boolean;
|
||||
readonly petScale: number;
|
||||
readonly openApiChatModel?: string;
|
||||
readonly openApiChatSystemPrompt?: string;
|
||||
readonly openApiChatEndpoint?: string;
|
||||
readonly openApiChatTheme?: "system" | "light" | "dark";
|
||||
readonly reactionAnimationOverrides?: ReactionAnimationOverrides;
|
||||
readonly onboardingCompleted: boolean;
|
||||
readonly claudeCommandPath?: string;
|
||||
readonly nodeCommandPath?: string;
|
||||
readonly opencodeCommandPath?: string;
|
||||
readonly vanillaChatMcpTools?: readonly string[];
|
||||
readonly openApiChatBaseInstructionsEnabled?: boolean;
|
||||
readonly ttsProvider?: "system" | "openai" | "elevenlabs" | "piper" | "openai-compatible";
|
||||
readonly ttsVoice?: string;
|
||||
readonly ttsSpeed?: number;
|
||||
readonly ttsModel?: string;
|
||||
readonly ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom";
|
||||
readonly ttsEndpoint?: string;
|
||||
readonly familiarName?: string;
|
||||
}
|
||||
|
||||
type LegacyOpenAiPreferences = Partial<{
|
||||
readonly openAiChatModel?: unknown;
|
||||
readonly openAiChatSystemPrompt?: unknown;
|
||||
readonly openAiChatEndpoint?: unknown;
|
||||
readonly openAiChatTheme?: unknown;
|
||||
}>;
|
||||
|
||||
export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses";
|
||||
|
||||
const allowedTtsProviders = ["system", "openai", "elevenlabs", "piper", "openai-compatible"] as const;
|
||||
const allowedTtsEndpointPresets = ["openrouter", "litellm", "wavespeedai", "custom"] as const;
|
||||
|
||||
export function createDefaultPreferences(): FamiliarOSPreferences {
|
||||
return {
|
||||
defaultPetId: builtInPet.id,
|
||||
openDefaultPetOnLaunch: true,
|
||||
locale: "system",
|
||||
speechBubblesEnabled: true,
|
||||
petScale: defaultPetScale,
|
||||
openApiChatModel: undefined,
|
||||
openApiChatSystemPrompt: undefined,
|
||||
openApiChatEndpoint: undefined,
|
||||
openApiChatTheme: "system",
|
||||
reactionAnimationOverrides: undefined,
|
||||
onboardingCompleted: false,
|
||||
claudeCommandPath: undefined,
|
||||
nodeCommandPath: undefined,
|
||||
opencodeCommandPath: undefined,
|
||||
vanillaChatMcpTools: undefined,
|
||||
openApiChatBaseInstructionsEnabled: true,
|
||||
ttsProvider: "system",
|
||||
ttsVoice: undefined,
|
||||
ttsSpeed: 1,
|
||||
ttsModel: undefined,
|
||||
ttsEndpointPreset: "openrouter",
|
||||
ttsEndpoint: undefined,
|
||||
familiarName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePreferences(value: Partial<FamiliarOSPreferences> & LegacyOpenAiPreferences): FamiliarOSPreferences {
|
||||
const defaults = createDefaultPreferences();
|
||||
const legacyValue = value as LegacyOpenAiPreferences;
|
||||
|
||||
return {
|
||||
defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id,
|
||||
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
|
||||
? value.openDefaultPetOnLaunch
|
||||
: defaults.openDefaultPetOnLaunch,
|
||||
locale: normalizeLocalePreference(value.locale),
|
||||
speechBubblesEnabled: true,
|
||||
petScale: normalizePetScale(value.petScale),
|
||||
openApiChatModel: normalizeOpenApiChatModel(value.openApiChatModel ?? legacyValue.openAiChatModel),
|
||||
openApiChatSystemPrompt: normalizeOpenApiChatSystemPrompt(value.openApiChatSystemPrompt ?? legacyValue.openAiChatSystemPrompt),
|
||||
openApiChatEndpoint: normalizeOpenApiChatEndpoint(value.openApiChatEndpoint ?? legacyValue.openAiChatEndpoint),
|
||||
openApiChatTheme: normalizeOpenApiChatTheme(value.openApiChatTheme ?? legacyValue.openAiChatTheme),
|
||||
reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides),
|
||||
onboardingCompleted: normalizeOnboardingCompleted(value),
|
||||
claudeCommandPath: normalizeCommandPath(value.claudeCommandPath),
|
||||
nodeCommandPath: normalizeCommandPath(value.nodeCommandPath),
|
||||
opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath),
|
||||
vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools),
|
||||
openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true,
|
||||
ttsProvider: isAllowedTtsProvider(value.ttsProvider) ? value.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: isAllowedTtsEndpointPreset(value.ttsEndpointPreset) ? value.ttsEndpointPreset : "openrouter",
|
||||
ttsEndpoint: typeof value.ttsEndpoint === "string" ? value.ttsEndpoint : undefined,
|
||||
familiarName: normalizeFamiliarName(value.familiarName),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpenApiChatEndpoint(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 2_048 || /[\0\r\n]/.test(trimmed)) return undefined;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
||||
if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1") {
|
||||
return undefined;
|
||||
}
|
||||
if (!url.hostname || url.username || url.password || url.search || url.hash) return undefined;
|
||||
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/v1";
|
||||
} else if (path.endsWith("/responses") || path.endsWith("/chat/completions")) {
|
||||
url.pathname = path;
|
||||
} else if (path.endsWith("/v1")) {
|
||||
url.pathname = path;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = url.toString();
|
||||
return normalized === defaultOpenApiChatEndpoint ? undefined : normalized;
|
||||
}
|
||||
|
||||
function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const valid = value.filter((candidate): candidate is string => typeof candidate === "string" && /^[a-z0-9-]+$/.test(candidate));
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
function normalizeLocalePreference(value: unknown): LocalePreference {
|
||||
if (value === "system") return "system";
|
||||
return isSupportedLocale(value) ? value : "system";
|
||||
}
|
||||
|
||||
function normalizeCommandPath(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 4_096 || /[\r\n\0]/.test(trimmed) || !isAbsolute(trimmed)) return undefined;
|
||||
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) return undefined;
|
||||
try {
|
||||
if (!statSync(trimmed).isFile()) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatModel(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatSystemPrompt(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.replaceAll("\r\n", "\n").trim();
|
||||
if (!normalized || normalized.length > 8_000 || /[\0]/.test(normalized)) return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark" {
|
||||
return value === "light" || value === "dark" ? value : "system";
|
||||
}
|
||||
|
||||
function isAllowedTtsProvider(value: unknown): value is FamiliarOSPreferences["ttsProvider"] {
|
||||
return typeof value === "string" && (allowedTtsProviders as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isAllowedTtsEndpointPreset(value: unknown): value is FamiliarOSPreferences["ttsEndpointPreset"] {
|
||||
return typeof value === "string" && (allowedTtsEndpointPresets as readonly string[]).includes(value);
|
||||
}
|
||||
98
apps/desktop/src/app-state-storage.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const directInstallLockName = ".install-familiar.lock";
|
||||
const directInstallLockStaleMs = 10 * 60 * 1000;
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export function migrateLegacyStateFilenames(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 readStateJsonFile(path: string): unknown {
|
||||
if (!existsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read FamiliarOS state from ${path}; using defaults.`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStateJsonFile(path: string, state: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, path);
|
||||
}
|
||||
|
||||
export function acquireStartupInstallLock(userDataPath: string): string {
|
||||
mkdirSync(userDataPath, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(userDataPath, directInstallLockName);
|
||||
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: "familiaros-startup" })}\n`, "utf8");
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
if (code !== "EEXIST") throw error;
|
||||
if (isStaleInstallLock(lockPath)) {
|
||||
rmSync(lockPath, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
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 FamiliarOS startup lock.");
|
||||
}
|
||||
|
||||
function isStaleInstallLock(lockPath: string): boolean {
|
||||
try {
|
||||
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")) as { readonly pid?: unknown; readonly createdAt?: unknown };
|
||||
if (typeof owner.createdAt === "number" && Date.now() - owner.createdAt > directInstallLockStaleMs) return true;
|
||||
if (typeof owner.pid === "number" && owner.pid > 0) return !isProcessAlive(owner.pid);
|
||||
} catch {
|
||||
// Fall back to mtime for old/partial locks.
|
||||
}
|
||||
try {
|
||||
return Date.now() - statSync(lockPath).mtimeMs > directInstallLockStaleMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
return code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +1,54 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { rmSync } from "node:fs";
|
||||
import { 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 { defaultPetScale, markOnboardingCompleted, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
|
||||
import {
|
||||
buildRecordedActivityAnalytics,
|
||||
type FamiliarOSActivityRecord,
|
||||
} from "./app-state-analytics.js";
|
||||
import {
|
||||
cloneState,
|
||||
normalizeState,
|
||||
normalizePosition,
|
||||
type FamiliarOSStateV1,
|
||||
type InstalledPetState,
|
||||
} from "./app-state-normalization.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 {
|
||||
normalizePreferences,
|
||||
} from "./app-state-preferences.js";
|
||||
import { publishPluginAgentActivity } from "./plugin-events-source.js";
|
||||
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
|
||||
import {
|
||||
acquireStartupInstallLock,
|
||||
migrateLegacyStateFilenames,
|
||||
readStateJsonFile,
|
||||
writeStateJsonFile,
|
||||
} from "./app-state-storage.js";
|
||||
export { defaultOpenApiChatEndpoint, normalizeOpenApiChatEndpoint } from "./app-state-preferences.js";
|
||||
export type { FamiliarOSActivityRecord, FamiliarOSAnalyticsState } from "./app-state-analytics.js";
|
||||
export type { FamiliarOSStateV1, InstalledPetState } from "./app-state-normalization.js";
|
||||
export type { FamiliarOSPreferences } from "./app-state-preferences.js";
|
||||
|
||||
export interface InstalledPetState {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly description?: string;
|
||||
readonly builtIn: boolean;
|
||||
readonly protected: boolean;
|
||||
readonly installed: boolean;
|
||||
readonly source?: {
|
||||
readonly kind?: "catalog";
|
||||
readonly catalogVersion: 2;
|
||||
readonly zip: string;
|
||||
readonly preview: string;
|
||||
} | {
|
||||
readonly kind: "codex";
|
||||
readonly path: string;
|
||||
};
|
||||
readonly broken?: boolean;
|
||||
readonly brokenReason?: string;
|
||||
}
|
||||
export { defaultPetScale, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue };
|
||||
|
||||
export interface OpenPetsStateV1 {
|
||||
readonly version: 1;
|
||||
readonly preferences: {
|
||||
readonly defaultPetId: string;
|
||||
readonly openDefaultPetOnLaunch: boolean;
|
||||
readonly locale: LocalePreference;
|
||||
readonly speechBubblesEnabled: boolean;
|
||||
readonly petScale: number;
|
||||
readonly openApiChatModel?: string;
|
||||
readonly openApiChatSystemPrompt?: string;
|
||||
readonly openApiChatEndpoint?: string;
|
||||
readonly openApiChatTheme?: "system" | "light" | "dark";
|
||||
readonly reactionAnimationOverrides?: ReactionAnimationOverrides;
|
||||
readonly onboardingCompleted: boolean;
|
||||
readonly claudeCommandPath?: string;
|
||||
readonly nodeCommandPath?: string;
|
||||
readonly opencodeCommandPath?: string;
|
||||
readonly vanillaChatMcpTools?: readonly string[];
|
||||
readonly openApiChatBaseInstructionsEnabled?: boolean;
|
||||
};
|
||||
readonly pets: {
|
||||
readonly installed: readonly InstalledPetState[];
|
||||
};
|
||||
readonly defaultPet: {
|
||||
readonly position?: Point;
|
||||
};
|
||||
readonly analytics: OpenPetsAnalyticsState;
|
||||
}
|
||||
|
||||
export interface OpenPetsAnalyticsState {
|
||||
readonly messagesSent: number;
|
||||
readonly reactionsSent: number;
|
||||
readonly reactionCounts: Record<OpenPetsReaction, 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 { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue };
|
||||
|
||||
export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses";
|
||||
|
||||
const stateFileName = "openpets-state.json";
|
||||
const directInstallLockName = ".install-pet.lock";
|
||||
const directInstallLockStaleMs = 10 * 60 * 1000;
|
||||
const stateFileName = "familiaros-state.json";
|
||||
let statePath: string | null = null;
|
||||
let currentState: OpenPetsStateV1 | null = null;
|
||||
let currentState: FamiliarOSStateV1 | null = null;
|
||||
let startupInstallLockPath: string | null = null;
|
||||
|
||||
export function initializeAppState(): void {
|
||||
const userDataPath = app.getPath("userData");
|
||||
migrateLegacyStateFilenames(userDataPath);
|
||||
startupInstallLockPath = acquireStartupInstallLock(userDataPath);
|
||||
|
||||
statePath = join(userDataPath, stateFileName);
|
||||
const nextState = normalizeState(readStateFile(statePath));
|
||||
const nextState = normalizeState(readStateJsonFile(statePath));
|
||||
writeStateToDisk(nextState);
|
||||
currentState = nextState;
|
||||
console.log(`OpenPets state initialized at ${statePath}.`);
|
||||
console.log(`FamiliarOS state initialized at ${statePath}.`);
|
||||
}
|
||||
|
||||
export function releaseStartupInstallLock(): void {
|
||||
|
|
@ -101,11 +57,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 });
|
||||
|
||||
|
|
@ -122,23 +78,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({
|
||||
|
|
@ -153,7 +109,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({
|
||||
|
|
@ -168,7 +124,7 @@ export function setDefaultPetPosition(position: Point): OpenPetsStateV1 {
|
|||
return getAppStateSnapshot();
|
||||
}
|
||||
|
||||
export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 {
|
||||
export function resetDefaultPetPosition(position: Point): FamiliarOSStateV1 {
|
||||
return setDefaultPetPosition(position);
|
||||
}
|
||||
|
||||
|
|
@ -176,45 +132,32 @@ 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;
|
||||
const reaction = activity.kind === "react" ? activity.reaction : activity.reaction;
|
||||
const petId = activity.petId;
|
||||
const nextState = normalizeState({
|
||||
...state,
|
||||
analytics: {
|
||||
messagesSent: analytics.messagesSent + (activity.kind === "say" ? 1 : 0),
|
||||
reactionsSent: analytics.reactionsSent + (reaction ? 1 : 0),
|
||||
reactionCounts: reaction
|
||||
? { ...analytics.reactionCounts, [reaction]: (analytics.reactionCounts[reaction] ?? 0) + 1 }
|
||||
: analytics.reactionCounts,
|
||||
perPetActivityCounts: petId
|
||||
? { ...analytics.perPetActivityCounts, [petId]: (analytics.perPetActivityCounts[petId] ?? 0) + 1 }
|
||||
: analytics.perPetActivityCounts,
|
||||
lastActivityAt: normalizeTimestamp(now) ?? Date.now(),
|
||||
},
|
||||
analytics: buildRecordedActivityAnalytics(state.analytics, activity, now),
|
||||
});
|
||||
|
||||
commitState(nextState);
|
||||
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,
|
||||
|
|
@ -227,16 +170,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;
|
||||
|
|
@ -247,8 +190,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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -256,7 +199,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) {
|
||||
|
|
@ -269,8 +212,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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -280,406 +223,25 @@ 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;
|
||||
}
|
||||
|
||||
function readStateFile(path: string): unknown {
|
||||
if (!existsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read OpenPets state from ${path}; using defaults.`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): OpenPetsStateV1 {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {};
|
||||
const preferencesRecord = isRecord(record.preferences) ? record.preferences : {};
|
||||
const defaultState = createDefaultState();
|
||||
const position = normalizeMaybePosition(defaultPetRecord.position);
|
||||
const installedPets = normalizeInstalledPets(record);
|
||||
const defaultPetId = typeof preferencesRecord.defaultPetId === "string"
|
||||
&& installedPets.some((pet) => pet.id === preferencesRecord.defaultPetId && !pet.broken)
|
||||
? preferencesRecord.defaultPetId
|
||||
: builtInPet.id;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
preferences: normalizePreferences({
|
||||
...defaultState.preferences,
|
||||
...preferencesRecord,
|
||||
defaultPetId,
|
||||
}),
|
||||
pets: {
|
||||
installed: installedPets,
|
||||
},
|
||||
defaultPet: position ? { position } : {},
|
||||
analytics: normalizeAnalytics(record.analytics),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnalytics(value: unknown): OpenPetsAnalyticsState {
|
||||
const record = isRecord(value) ? value : {};
|
||||
return {
|
||||
messagesSent: normalizeCount(record.messagesSent),
|
||||
reactionsSent: normalizeCount(record.reactionsSent),
|
||||
reactionCounts: normalizeReactionCounts(record.reactionCounts),
|
||||
perPetActivityCounts: normalizePerPetActivityCounts(record.perPetActivityCounts),
|
||||
lastActivityAt: normalizeTimestamp(record.lastActivityAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionCounts(value: unknown): Record<OpenPetsReaction, number> {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const counts = {} as Record<OpenPetsReaction, number>;
|
||||
for (const reaction of allowedReactions) {
|
||||
counts[reaction] = normalizeCount(record[reaction]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizePerPetActivityCounts(value: unknown): Record<string, number> {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [petId, rawCount] of Object.entries(record)) {
|
||||
if (petId !== builtInPet.id) {
|
||||
try {
|
||||
assertSafePetId(petId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const count = normalizeCount(rawCount);
|
||||
if (count > 0) counts[petId] = count;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizeCount(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
}
|
||||
|
||||
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"] {
|
||||
const defaultState = createDefaultState();
|
||||
const legacyValue = value as Partial<{
|
||||
readonly openAiChatModel?: unknown;
|
||||
readonly openAiChatSystemPrompt?: unknown;
|
||||
readonly openAiChatEndpoint?: unknown;
|
||||
readonly openAiChatTheme?: unknown;
|
||||
}>;
|
||||
|
||||
return {
|
||||
defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id,
|
||||
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
|
||||
? value.openDefaultPetOnLaunch
|
||||
: defaultState.preferences.openDefaultPetOnLaunch,
|
||||
locale: normalizeLocalePreference(value.locale),
|
||||
speechBubblesEnabled: true,
|
||||
petScale: normalizePetScale(value.petScale),
|
||||
openApiChatModel: normalizeOpenApiChatModel(value.openApiChatModel ?? legacyValue.openAiChatModel),
|
||||
openApiChatSystemPrompt: normalizeOpenApiChatSystemPrompt(value.openApiChatSystemPrompt ?? legacyValue.openAiChatSystemPrompt),
|
||||
openApiChatEndpoint: normalizeOpenApiChatEndpoint(value.openApiChatEndpoint ?? legacyValue.openAiChatEndpoint),
|
||||
openApiChatTheme: normalizeOpenApiChatTheme(value.openApiChatTheme ?? legacyValue.openAiChatTheme),
|
||||
reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides),
|
||||
onboardingCompleted: normalizeOnboardingCompleted(value),
|
||||
claudeCommandPath: normalizeCommandPath(value.claudeCommandPath),
|
||||
nodeCommandPath: normalizeCommandPath(value.nodeCommandPath),
|
||||
opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath),
|
||||
vanillaChatMcpTools: normalizeVanillaChatMcpTools(value.vanillaChatMcpTools),
|
||||
openApiChatBaseInstructionsEnabled: typeof value.openApiChatBaseInstructionsEnabled === "boolean" ? value.openApiChatBaseInstructionsEnabled : true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const valid = value.filter((v): v is string => typeof v === "string" && /^[a-z0-9-]+$/.test(v));
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
function normalizeLocalePreference(value: unknown): LocalePreference {
|
||||
if (value === "system") return "system";
|
||||
return isSupportedLocale(value) ? value : "system";
|
||||
}
|
||||
|
||||
function normalizeCommandPath(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 4096 || /[\r\n\0]/.test(trimmed) || !isAbsolute(trimmed)) return undefined;
|
||||
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) return undefined;
|
||||
try {
|
||||
if (!statSync(trimmed).isFile()) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatModel(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatSystemPrompt(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.replaceAll("\r\n", "\n").trim();
|
||||
if (!normalized || normalized.length > 8_000 || /[\0]/.test(normalized)) return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeOpenApiChatEndpoint(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 2_048 || /[\0\r\n]/.test(trimmed)) return undefined;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
||||
if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1") {
|
||||
return undefined;
|
||||
}
|
||||
if (!url.hostname || url.username || url.password || url.search || url.hash) return undefined;
|
||||
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/v1";
|
||||
} else if (path.endsWith("/responses") || path.endsWith("/chat/completions")) {
|
||||
url.pathname = path;
|
||||
} else if (path.endsWith("/v1")) {
|
||||
url.pathname = path;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = url.toString();
|
||||
return normalized === defaultOpenApiChatEndpoint ? undefined : normalized;
|
||||
}
|
||||
|
||||
function normalizeOpenApiChatTheme(value: unknown): "system" | "light" | "dark" {
|
||||
return value === "light" || value === "dark" ? value : "system";
|
||||
}
|
||||
|
||||
function normalizeInstalledPets(value: Record<string, unknown>): InstalledPetState[] {
|
||||
const installed = isRecord(value.pets) && Array.isArray(value.pets.installed)
|
||||
? value.pets.installed
|
||||
: [];
|
||||
|
||||
const normalized = installed
|
||||
.map((pet) => normalizeInstalledPet(pet))
|
||||
.filter((pet): pet is InstalledPetState => Boolean(pet && pet.id !== builtInPet.id));
|
||||
|
||||
return [builtInPet, ...normalized];
|
||||
}
|
||||
|
||||
function normalizeInstalledPet(value: unknown): InstalledPetState | null {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
assertSafePetId(value.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const brokenReason = validateInstalledPetFiles(value.id);
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
displayName: value.displayName,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
builtIn: value.id === builtInPet.id ? true : value.builtIn === true,
|
||||
protected: value.id === builtInPet.id ? true : value.protected === true,
|
||||
installed: true,
|
||||
source: normalizeSource(value.source),
|
||||
broken: brokenReason ? true : typeof value.broken === "boolean" ? value.broken : undefined,
|
||||
brokenReason: brokenReason ?? (typeof value.brokenReason === "string" ? value.brokenReason : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultState(): OpenPetsStateV1 {
|
||||
return {
|
||||
version: 1,
|
||||
preferences: {
|
||||
defaultPetId: builtInPet.id,
|
||||
openDefaultPetOnLaunch: true,
|
||||
locale: "system",
|
||||
speechBubblesEnabled: true,
|
||||
petScale: defaultPetScale,
|
||||
openApiChatModel: undefined,
|
||||
openApiChatSystemPrompt: undefined,
|
||||
openApiChatEndpoint: undefined,
|
||||
openApiChatTheme: "system",
|
||||
reactionAnimationOverrides: undefined,
|
||||
onboardingCompleted: false,
|
||||
claudeCommandPath: undefined,
|
||||
nodeCommandPath: undefined,
|
||||
opencodeCommandPath: undefined,
|
||||
vanillaChatMcpTools: undefined,
|
||||
openApiChatBaseInstructionsEnabled: true,
|
||||
},
|
||||
pets: {
|
||||
installed: [builtInPet],
|
||||
},
|
||||
defaultPet: {},
|
||||
analytics: {
|
||||
messagesSent: 0,
|
||||
reactionsSent: 0,
|
||||
reactionCounts: normalizeReactionCounts(undefined),
|
||||
perPetActivityCounts: {},
|
||||
lastActivityAt: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commitState(nextState: OpenPetsStateV1): void {
|
||||
function commitState(nextState: FamiliarOSStateV1): void {
|
||||
writeStateToDisk(nextState);
|
||||
currentState = nextState;
|
||||
}
|
||||
|
||||
function writeStateToDisk(state: OpenPetsStateV1): void {
|
||||
const path = getStateFilePath();
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, path);
|
||||
}
|
||||
|
||||
function validateInstalledPetFiles(petId: string): string | undefined {
|
||||
try {
|
||||
const dir = getInstalledPetDir(petId);
|
||||
const petJsonPath = join(dir, "pet.json");
|
||||
const spritesheetPath = join(dir, "spritesheet.webp");
|
||||
JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown;
|
||||
const spritesheet = statSync(spritesheetPath);
|
||||
if (!spritesheet.isFile()) return "spritesheet.webp is not a file.";
|
||||
if (spritesheet.size <= 0) return "spritesheet.webp is empty.";
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSource(value: unknown): InstalledPetState["source"] | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.kind === "codex" && typeof value.path === "string") {
|
||||
return { kind: "codex", path: value.path };
|
||||
}
|
||||
|
||||
if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined;
|
||||
|
||||
return {
|
||||
kind: "catalog",
|
||||
catalogVersion: 2,
|
||||
zip: value.zip,
|
||||
preview: value.preview,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaybePosition(value: unknown): Point | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizePosition(value);
|
||||
}
|
||||
|
||||
function normalizePosition(value: Partial<Point>): Point | undefined {
|
||||
if (typeof value.x !== "number" || typeof value.y !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(value.x),
|
||||
y: Math.round(value.y),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: OpenPetsStateV1): OpenPetsStateV1 {
|
||||
return structuredClone(state) as OpenPetsStateV1;
|
||||
}
|
||||
|
||||
function acquireStartupInstallLock(userDataPath: string): string {
|
||||
mkdirSync(userDataPath, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(userDataPath, directInstallLockName);
|
||||
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");
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
if (code !== "EEXIST") throw error;
|
||||
if (isStaleInstallLock(lockPath)) {
|
||||
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("Could not acquire OpenPets startup lock.");
|
||||
}
|
||||
|
||||
function isStaleInstallLock(lockPath: string): boolean {
|
||||
try {
|
||||
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")) as { readonly pid?: unknown; readonly createdAt?: unknown };
|
||||
if (typeof owner.createdAt === "number" && Date.now() - owner.createdAt > directInstallLockStaleMs) return true;
|
||||
if (typeof owner.pid === "number" && owner.pid > 0) return !isProcessAlive(owner.pid);
|
||||
} catch {
|
||||
// Fall back to mtime for old/partial locks.
|
||||
}
|
||||
try {
|
||||
return Date.now() - statSync(lockPath).mtimeMs > directInstallLockStaleMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
return code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
function writeStateToDisk(state: FamiliarOSStateV1): void {
|
||||
writeStateJsonFile(getStateFilePath(), state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export const builtInPet = {
|
||||
id: "builtin",
|
||||
displayName: "Built-in Pet",
|
||||
displayName: "Built-in Familiar",
|
||||
builtIn: true,
|
||||
protected: true,
|
||||
installed: true,
|
||||
195
apps/desktop/src/catalog-remote.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
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://familiaros.dev/familiars/catalog.v2.json";
|
||||
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
|
||||
|
||||
type CatalogLoadResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: string };
|
||||
|
||||
const fixtureRelativePath = "catalog.v2.fixture.json";
|
||||
const maxCatalogBytes = 1_000_000;
|
||||
const maxCatalogV3PageBytes = 256_000;
|
||||
const fetchTimeoutMs = 5_000;
|
||||
|
||||
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
|
||||
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
|
||||
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
|
||||
let v2CatalogPromise: Promise<CatalogV2> | null = null;
|
||||
|
||||
export async function tryLoadRemoteCatalogV3Index(): Promise<CatalogLoadResult<CatalogV3Index>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV3Index() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<CatalogLoadResult<readonly CatalogPetV2[]>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV3Page(page, index) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadRemoteCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV2() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadFixtureCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
|
||||
try {
|
||||
return { ok: true, value: validateCatalogV2(await loadFixtureCatalog()) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
|
||||
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
|
||||
return await v3IndexPromise;
|
||||
}
|
||||
|
||||
export async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
|
||||
const cached = v3PageCache.get(page);
|
||||
if (cached) return cached;
|
||||
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 familiars = payload.familiars.map(toCatalogPetV2Compat);
|
||||
assertUniquePetIds(familiars);
|
||||
v3PageCache.set(page, familiars);
|
||||
return familiars;
|
||||
}
|
||||
|
||||
export 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 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;
|
||||
}
|
||||
|
||||
export async function getV2CatalogOrFixture(): Promise<CatalogV2> {
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
if (remote.ok) return remote.value;
|
||||
const fixture = await tryLoadFixtureCatalog();
|
||||
if (fixture.ok) return fixture.value;
|
||||
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
|
||||
}
|
||||
|
||||
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: familiar.id,
|
||||
displayName: familiar.displayName,
|
||||
description: familiar.description,
|
||||
preview: familiar.thumbnail,
|
||||
spritesheet: familiar.spritesheet,
|
||||
zip: familiar.zip,
|
||||
category: familiar.category,
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
|
||||
...(familiar.original === undefined ? {} : { original: familiar.original }),
|
||||
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
|
||||
};
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV2(): Promise<CatalogV2> {
|
||||
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
|
||||
return await v2CatalogPromise;
|
||||
}
|
||||
|
||||
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
validateCatalogEndpoint(response.url, url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await readLimitedResponse(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFixtureCatalog(): Promise<unknown> {
|
||||
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
|
||||
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Catalog response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(concatChunks(chunks, total));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateCatalogEndpoint(value: string, expected: string): void {
|
||||
const url = new URL(value);
|
||||
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
|
||||
}
|
||||
|
||||
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
|
||||
const ids = new Set<string>();
|
||||
for (const familiar of familiars) {
|
||||
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
|
||||
ids.add(familiar.id);
|
||||
}
|
||||
}
|
||||
21
apps/desktop/src/catalog-surfaceable.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export interface SurfaceableCatalogPet {
|
||||
readonly original?: boolean;
|
||||
readonly featured?: boolean;
|
||||
}
|
||||
|
||||
export function isSurfaceablePet(familiar: SurfaceableCatalogPet): boolean {
|
||||
return familiar.original === true || familiar.featured === true;
|
||||
}
|
||||
|
||||
export function filterSurfaceablePets<T extends SurfaceableCatalogPet>(familiars: readonly T[]): readonly T[] {
|
||||
return familiars.filter(isSurfaceablePet);
|
||||
}
|
||||
|
||||
export function summarizeSurfaceablePets<T extends SurfaceableCatalogPet>(familiars: readonly T[], pageSize: number): { readonly total: number; readonly pageCount: number } {
|
||||
if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error("Surfaceable page size must be a positive integer.");
|
||||
const total = filterSurfaceablePets(familiars).length;
|
||||
return {
|
||||
total,
|
||||
pageCount: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,36 +169,40 @@ 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 {
|
||||
const entry: CatalogPetV2 = {
|
||||
id,
|
||||
displayName: validateString(value.displayName, "displayName", 120),
|
||||
description: validateString(value.description, "description", 500),
|
||||
preview: validateCatalogUrl(value.preview, "preview"),
|
||||
zip: validateCatalogUrl(value.zip, "zip"),
|
||||
...(value.spritesheet === undefined ? {} : { spritesheet: validateCatalogUrl(value.spritesheet, "preview") }),
|
||||
...(value.category === undefined ? {} : { category: validateCategory(value.category) }),
|
||||
...(value.subcategory === undefined ? {} : { subcategory: validateString(value.subcategory, "subcategory", 80) }),
|
||||
};
|
||||
return withCatalogMeta(entry, value);
|
||||
}
|
||||
|
||||
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 +228,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 +248,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 +267,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 +288,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.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { filterSurfaceablePets, isSurfaceablePet, summarizeSurfaceablePets } from "./catalog-surfaceable.js";
|
||||
import { getRemoteCatalogV3Page, getRemoteCatalogV3Search, getV2CatalogOrFixture, tryLoadFixtureCatalog, tryLoadRemoteCatalog, tryLoadRemoteCatalogV3Index } from "./catalog-remote.js";
|
||||
import type { CatalogPetV2, CatalogV3Index, CatalogV3SearchPet } from "./catalog-validation.js";
|
||||
|
||||
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";
|
||||
const fixtureRelativePath = "catalog.v2.fixture.json";
|
||||
const maxCatalogBytes = 1_000_000;
|
||||
const maxCatalogV3PageBytes = 256_000;
|
||||
const fetchTimeoutMs = 5_000;
|
||||
export { catalogUrl, catalogV3Url } from "./catalog-remote.js";
|
||||
|
||||
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,34 +21,30 @@ export interface CatalogUiState {
|
|||
|
||||
export interface CatalogSearchUiState {
|
||||
readonly source: "remote" | "error";
|
||||
readonly pets: readonly CatalogV3SearchPet[];
|
||||
readonly familiars: readonly CatalogV3SearchPet[];
|
||||
readonly total?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
|
||||
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
|
||||
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
|
||||
let v2CatalogPromise: Promise<CatalogV2> | null = null;
|
||||
|
||||
export async function getCatalogUiState(): Promise<CatalogUiState> {
|
||||
const remoteV3 = await tryLoadRemoteCatalogV3Index();
|
||||
|
||||
if (remoteV3.ok) {
|
||||
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index);
|
||||
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value);
|
||||
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
|
||||
return {
|
||||
source: "remote",
|
||||
pets: filterSurfaceablePets(firstPage.pets),
|
||||
generatedAt: remoteV3.index.generatedAt,
|
||||
familiars: filterSurfaceablePets(firstPage.familiars),
|
||||
generatedAt: remoteV3.value.generatedAt,
|
||||
version: 3,
|
||||
total: surfaceableTotal(remoteV3.index),
|
||||
categories: remoteV3.index.filters.categories,
|
||||
total: surfaceableStats.total,
|
||||
categories: remoteV3.value.filters.categories,
|
||||
page: 0,
|
||||
pageCount: surfaceablePageCount(remoteV3.index),
|
||||
pageCount: surfaceableStats.pageCount,
|
||||
supportsCategories: true,
|
||||
originalsCount: remoteV3.index.filters.originalsCount,
|
||||
featuredCount: remoteV3.index.filters.featuredCount,
|
||||
originalsCount: remoteV3.value.filters.originalsCount,
|
||||
featuredCount: remoteV3.value.filters.featuredCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,35 +54,36 @@ 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 (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 (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
|
||||
if (page >= surfaceableStats.pageCount) throw new Error("Catalog page is out of range.");
|
||||
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.value);
|
||||
if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error };
|
||||
|
||||
return {
|
||||
source: "remote",
|
||||
pets: filterSurfaceablePets(pageResult.pets),
|
||||
generatedAt: remoteV3.index.generatedAt,
|
||||
familiars: filterSurfaceablePets(pageResult.familiars),
|
||||
generatedAt: remoteV3.value.generatedAt,
|
||||
version: 3,
|
||||
total: surfaceableTotal(remoteV3.index),
|
||||
categories: remoteV3.index.filters.categories,
|
||||
total: surfaceableStats.total,
|
||||
categories: remoteV3.value.filters.categories,
|
||||
page,
|
||||
pageCount: surfaceablePageCount(remoteV3.index),
|
||||
pageCount: surfaceableStats.pageCount,
|
||||
supportsCategories: true,
|
||||
originalsCount: remoteV3.index.filters.originalsCount,
|
||||
featuredCount: remoteV3.index.filters.featuredCount,
|
||||
originalsCount: remoteV3.value.filters.originalsCount,
|
||||
featuredCount: remoteV3.value.filters.featuredCount,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value);
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,40 +92,39 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
|
|||
if (remoteV3.ok) {
|
||||
let blockedHiddenV3Pet = false;
|
||||
try {
|
||||
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
|
||||
const searchPet = searchPets.find((pet) => pet.id === petId);
|
||||
const searchPets = await getRemoteCatalogV3Search(remoteV3.value);
|
||||
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 page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value);
|
||||
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> {
|
||||
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
|
||||
if (remote.ok) {
|
||||
return {
|
||||
source: "remote",
|
||||
pets: filterSurfaceablePets(remote.catalog.pets),
|
||||
generatedAt: remote.catalog.generatedAt,
|
||||
familiars: filterSurfaceablePets(remote.value.familiars),
|
||||
generatedAt: remote.value.generatedAt,
|
||||
version: 2,
|
||||
total: filterSurfaceablePets(remote.catalog.pets).length,
|
||||
total: filterSurfaceablePets(remote.value.familiars).length,
|
||||
supportsCategories: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -146,213 +134,39 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
|
|||
if (fixture.ok) {
|
||||
return {
|
||||
source: "fixture",
|
||||
pets: filterSurfaceablePets(fixture.catalog.pets),
|
||||
generatedAt: fixture.catalog.generatedAt,
|
||||
familiars: filterSurfaceablePets(fixture.value.familiars),
|
||||
generatedAt: fixture.value.generatedAt,
|
||||
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
|
||||
version: 2,
|
||||
total: filterSurfaceablePets(fixture.catalog.pets).length,
|
||||
total: filterSurfaceablePets(fixture.value.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 isSurfaceablePet(pet: { readonly original?: boolean; readonly featured?: boolean }): boolean {
|
||||
return pet.original === true || pet.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),
|
||||
}));
|
||||
}
|
||||
|
||||
function surfaceableTotal(index: CatalogV3Index): number {
|
||||
return (index.filters.originalsCount ?? 0) + (index.filters.featuredCount ?? 0);
|
||||
}
|
||||
|
||||
function surfaceablePageCount(index: CatalogV3Index): number {
|
||||
return Math.ceil(surfaceableTotal(index) / index.pageSize);
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; readonly index: CatalogV3Index } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
const index = await getRemoteCatalogV3Index();
|
||||
return { ok: true, index };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, pets: 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" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
|
||||
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
|
||||
return await v3IndexPromise;
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
|
||||
const cached = v3PageCache.get(page);
|
||||
if (cached) return cached;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
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 {
|
||||
const entry: CatalogPetV2 = {
|
||||
id: pet.id,
|
||||
displayName: pet.displayName,
|
||||
description: pet.description,
|
||||
preview: pet.thumbnail,
|
||||
spritesheet: pet.spritesheet,
|
||||
zip: pet.zip,
|
||||
category: pet.category,
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
...(pet.subcategory ? { subcategory: pet.subcategory } : {}),
|
||||
...(pet.original === undefined ? {} : { original: pet.original }),
|
||||
...(pet.featured === undefined ? {} : { featured: pet.featured }),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, catalog: await getRemoteCatalogV2() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV2(): Promise<CatalogV2> {
|
||||
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
|
||||
return await v2CatalogPromise;
|
||||
}
|
||||
|
||||
async function getV2CatalogOrFixture(): Promise<CatalogV2> {
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
if (remote.ok) return remote.catalog;
|
||||
const fixture = await tryLoadFixtureCatalog();
|
||||
if (fixture.ok) return fixture.catalog;
|
||||
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
|
||||
}
|
||||
|
||||
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
validateCatalogEndpoint(response.url, url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await readLimitedResponse(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFixtureCatalog(): Promise<unknown> {
|
||||
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
|
||||
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Catalog response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(concatChunks(chunks, total));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateCatalogEndpoint(value: string, expected: string): void {
|
||||
const url = new URL(value);
|
||||
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
|
||||
}
|
||||
|
||||
function assertUniquePetIds(pets: 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -27,55 +27,86 @@ main.ts
|
|||
├── plugin-service.ts (plugin state/runtime init, JS host wiring)
|
||||
├── tray.ts (tray creation)
|
||||
├── local-ipc.ts (IPC server start)
|
||||
│ ├── local-ipc-request-handlers.ts (protocol request dispatch, familiar actions, lease routes, and memory CRUD shaping)
|
||||
│ └── local-ipc-socket.ts (socket accept loop, advertised endpoint resolution, and remote address filtering)
|
||||
└── windows.ts (UI handlers)
|
||||
```
|
||||
|
||||
**IPC Request Flow**:
|
||||
```
|
||||
local-ipc.ts → parseIpcRequest() → handleRequest()
|
||||
├── hello/status/pets.list/pets.install
|
||||
local-ipc.ts → parseIpcRequest() → handleLocalIpcRequest()
|
||||
├── 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()
|
||||
├── familiar-window-host.ts (Electron BrowserWindow shell, navigation allowlist, and serialized HTML load queue)
|
||||
├── familiar-window-interactions.ts (public interaction facade plus motion-state publishing)
|
||||
├── familiar-window-mouse-interop.ts (public mouse passthrough/drag facade plus recovery accessors)
|
||||
├── familiar-window-mouse-interop-runtime.ts (IPC handler attachment and lifecycle seam delegation for familiar window mouse interop)
|
||||
├── familiar-window-mouse-interop-runtime-lifecycle.ts (listener registration, cleanup, and load/reset recovery for familiar window mouse interop)
|
||||
├── familiar-window-mouse-interop-support.ts (context creation, platform passthrough helpers, and hit-test probes)
|
||||
├── familiar-window-plugin-menu.ts (pet context menu, default-pet plugin commands, plugin command forms)
|
||||
├── familiar-window-renderer-bridge.ts (bubble layout, reaction, sprite, audio, and TTS renderer IPC helpers)
|
||||
├── loadDefaultPetContent() / loadExplicitPetContent()
|
||||
│ ├── HTML generation with CSS sprite animation
|
||||
│ ├── familiar-window-render.ts (shared familiar-window CSS/sprite shell plus bubble seam re-exports)
|
||||
│ │ └── familiar-window-render-bubbles.ts (bubble/plugin HUD markup, status badges, and HTML escaping helpers)
|
||||
│ ├── 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**:
|
||||
```
|
||||
windows.ts (IPC handlers)
|
||||
└── agent-setup.ts
|
||||
├── detectClaudeCodeStatus() (claude --version, claude mcp list)
|
||||
├── 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)
|
||||
├── agent-setup-claude-status.ts
|
||||
│ ├── Claude MCP preview/status detection and packaged-resource fallback shaping
|
||||
│ └── Claude/OpenCode command wrappers, output sanitization, and user-path formatting
|
||||
├── agent-setup-actions.ts
|
||||
│ ├── runAgentSetupResolvedAction() shell and immediate action routing
|
||||
│ ├── agent-setup-actions-claude-mcp.ts (Claude MCP add/remove/configure/replace orchestration)
|
||||
│ └── agent-setup-actions-tooling.ts (OpenCode/Cursor setup snapshots, global config actions, and FamiliarOS MCP server health)
|
||||
├── agent-setup-command-context.ts
|
||||
│ ├── persisted command path preferences
|
||||
│ └── CLI/package version resolution for agent setup
|
||||
├── agent-setup-command-runner.ts
|
||||
│ ├── Claude/OpenCode command execution and retries
|
||||
│ └── PATH shaping plus command output summarization
|
||||
├── agent-setup-support.ts
|
||||
│ ├── FamiliarOS MCP preview shaping
|
||||
│ ├── Claude memory install/uninstall safety wrappers
|
||||
│ └── bounded action journal writes
|
||||
├── agent-setup-editor-tools.ts
|
||||
│ └── stable barrel over the editor-specific setup helper seams
|
||||
├── agent-setup-editor-tools-opencode.ts
|
||||
│ └── OpenCode global config management (@familiaros/opencode)
|
||||
└── agent-setup-editor-tools-cursor.ts
|
||||
└── 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
|
||||
│ ├── familiar-installation-archive.ts
|
||||
│ │ ├── downloadPetZip() → validate ZIP magic
|
||||
│ │ └── extractPetZip() → yauzl with entry validation
|
||||
│ └── installPetState() → app-state.ts
|
||||
└── importCodexPet() → codex-pets.ts
|
||||
│ ├── app-state-familiar-records.ts normalizes installed familiar records and file validation
|
||||
│ └── app-state-storage.ts handles state-file migration, atomic writes, and install locks
|
||||
└── importCodexPet() → codex-familiars.ts
|
||||
```
|
||||
|
||||
**Control Center Flow**:
|
||||
|
|
@ -83,113 +114,256 @@ 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
|
||||
├── control-center-ipc.ts installs the Control Center IPC shell
|
||||
│ ├── control-center-ipc-settings.ts (settings, updates, TTS, clipboard, external links)
|
||||
│ ├── control-center-ipc-knowledge.ts (memories and knowledge-store mutations)
|
||||
│ ├── control-center-ipc-plugin.ts (plugin runtime/config/catalog/platform actions)
|
||||
│ ├── control-center-ipc-pets.ts (catalog, local install, Codex import, default familiar actions)
|
||||
│ └── control-center-ipc-agent.ts (agent setup, MCP toolkit, vanilla-chat MCP tools)
|
||||
├── Dashboard snapshot: default familiar, catalog, plugin health, update status, activity
|
||||
└── renderer/src/main.tsx routes Dashboard/Familiars/Integrations/Plugins/Settings
|
||||
```
|
||||
|
||||
**Prompt Window Flow**:
|
||||
```
|
||||
familiar-window preload double-click or tray/chat entry → openPromptWindow() → prompt-window.ts
|
||||
├── prompt-window.ts owns BrowserWindow lifecycle and composes the extracted prompt window seams
|
||||
├── prompt-window-bounds.ts owns bounded placement, resize normalization, and render sizing defaults
|
||||
├── prompt-window-ipc.ts owns prompt/settings/conversation/file IPC routes and file-upload normalization
|
||||
├── prompt-window-render.ts builds the inline prompt window HTML document/data URL
|
||||
│ └── prompt-window-render-script.ts owns the injected prompt window client script
|
||||
├── prompt-window-preload.cjs exposes the narrow prompt window bridge
|
||||
└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-store.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, knowledge-store.ts, and knowledge-store-core.ts handle prompt submission, long-term memory recall, knowledge-file storage/search, credential persistence, request shaping, provider routing, history, and attachment storage
|
||||
```
|
||||
|
||||
**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-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads
|
||||
│ │ └── plugin-js-host-sdk-dispatch.ts owns the JavaScript SDK IPC route table and callback/config dispatch plumbing
|
||||
│ └── plugin-sdk-bridge.ts → plugin-sdk-api-builder.ts, plugin-sdk-api-builder-platform.ts, plugin-sdk-familiar-surface.ts, plugin-sdk-bubble-support.ts, plugin-sdk-bubble-validation.ts, plugin-sdk-command-validation.ts, plugin-sdk-runtime-helpers.ts, plugin-sdk-bridge-runtime-state.ts, plugin-sdk-storage-stores.ts, plus plugin-familiar-api.ts/default-familiar-controller for schedules, storage, commands, status, logs, and restricted network
|
||||
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, load-local, and runtime reloads while delegating the heavier catalog/install/uninstall/config-sound workflows into focused helper seams
|
||||
│ ├── plugin-service-app.ts (app-global singleton, default familiar command/menu bridge, and test override hooks)
|
||||
│ ├── plugin-service-support.ts (service barrel for action/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams)
|
||||
│ ├── plugin-service-actions.ts (catalog snapshot/install-update, config-sound pick, and uninstall action workflows)
|
||||
│ ├── plugin-service-guard-helpers.ts (service-local manifest/sound error shaping plus approval subset helpers)
|
||||
│ ├── plugin-service-bundled-support.ts (bundled source lookup and bundled install materialization helpers)
|
||||
│ ├── plugin-service-catalog-support.ts (catalog compatibility, status, and installed-record metadata helpers)
|
||||
│ ├── plugin-service-dialog-support.ts (dialog picker defaults and permission confirmation helpers)
|
||||
│ ├── plugin-service-local-support.ts (local dev plugin load, root scan, and stale plugins-dev prune helpers)
|
||||
└── 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-service-local-support.ts → plugin-local-loader.ts validates selected folder manifests, snapshots only familiaros.plugin.json to userData/plugins-dev/{id}, and prunes stale dev installs after successful root scans
|
||||
```
|
||||
|
||||
## 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.ts`: Persistent JSON state initialization and mutation API with reaction animation overrides
|
||||
- `app-state-normalization.ts`: Extracted state schema, default-state factory, clone helper, and position/install normalization
|
||||
- `app-state-preferences.ts`: Extracted state preference defaults plus OpenAPI/TTS/command-path normalization helpers
|
||||
- `app-state-analytics.ts`: Extracted analytics normalization and activity accumulation helpers for persistent state
|
||||
- `app-state-familiar-records.ts`: Extracted installed familiar record normalization, source shaping, and familiar file validation helpers
|
||||
- `app-state-core.ts`: Familiar scale options, onboarding normalization
|
||||
- `app-state-storage.ts`: Extracted state-file migration, atomic JSON persistence, and startup install-lock helpers
|
||||
- `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
|
||||
- `control-center-ipc.ts`: Control Center IPC installer that composes extracted route registration seams for settings, knowledge, plugins, pets, and agent setup
|
||||
- `control-center-core-services.ts`: Control Center service barrel for state snapshots, validation helpers, update helpers, and locale wiring
|
||||
- `control-center-state.ts`: Control Center state snapshot and dashboard/preview loaders
|
||||
- `control-center-state-validation.ts`: Extracted Control Center preferences patch, plain-object, and external-URL validation helpers
|
||||
- `control-center-ipc-shared.ts`: Shared Control Center IPC sender validation and window-kind helpers
|
||||
- `control-center-ipc-settings.ts`: Settings/update/TTS/openapi credential/clipboard/external-link IPC routes for the Control Center
|
||||
- `control-center-ipc-knowledge.ts`: FamiliarOS memory and knowledge-store IPC routes for the Control Center
|
||||
- `control-center-ipc-plugin.ts`: Plugin snapshot/config/catalog/platform IPC routes for the Control Center
|
||||
- `control-center-ipc-pets.ts`: Familiar/catalog/Codex import and default familiar IPC routes for the Control Center
|
||||
- `control-center-ipc-agent.ts`: Agent setup, toolkit, and FamiliarOS MCP preview/test IPC routes for the Control Center
|
||||
- `agent-setup-actions.ts`: Extracted action execution shell for hook handling plus Claude MCP add/remove orchestration
|
||||
- `agent-setup-actions-claude-mcp.ts`: Extracted Claude MCP add/remove/configure/replace orchestration plus memory-aware journaling
|
||||
- `agent-setup-actions-tooling.ts`: Extracted OpenCode/Cursor setup loading, global config actions, and FamiliarOS MCP server health checks
|
||||
- `agent-setup-editor-tools-opencode.ts`: Extracted OpenCode global setup status, previews, and config write helpers
|
||||
- `agent-setup-editor-tools-cursor.ts`: Extracted Cursor global setup status, previews, and MCP config write helpers
|
||||
- `agent-setup-support.ts`: Extracted MCP preview, Claude memory safety wrappers, and bounded action journal helpers for agent setup
|
||||
- `mcp-toolkit-installer.ts`: Public MCP toolkit installer facade for persistent host-agent server setup and managed install result shaping
|
||||
- `mcp-toolkit-installer-support.ts`: Extracted MCP toolkit bundle/server-definition, argument, note, and command-candidate helpers
|
||||
- `mcp-toolkit-installer-commands.ts`: Extracted MCP toolkit PATH/process execution, timeout, and command-failure shaping helpers
|
||||
- `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
|
||||
- `prompt-window.ts`: Floating prompt window BrowserWindow lifecycle and seam composition for the prompt UI shell
|
||||
- `prompt-window-bounds.ts`: Extracted prompt-window placement, resize normalization, and bounded work-area clamping
|
||||
- `prompt-window-ipc.ts`: Extracted prompt-window IPC route installation plus knowledge-file upload normalization
|
||||
- `prompt-window-render.ts`: Extracted inline prompt window renderer document builder with theme, conversation, attachment, and resize UI logic
|
||||
- `prompt-window-render-script.ts`: Extracted inline prompt window client script builder for the prompt window renderer document
|
||||
- `prompt-window-render-script-sections.ts`: Stable prompt-window client-script section barrel with bootstrap plus re-exports for the extracted render and layout builders
|
||||
- `prompt-window-render-script-render-section.ts`: Extracted prompt-window history, conversation, feedback, busy-state, and attachment render helpers
|
||||
- `prompt-window-render-script-layout-section.ts`: Extracted prompt-window resize queueing, editor shell layout, and refresh/render state helpers
|
||||
- `prompt-window-render-script-interactions.ts`: Extracted prompt window client-script interaction and resize handlers
|
||||
- `openapi-chat.ts`: Public OpenAPI chat facade, conversation/runtime state holder, and prompt-window entrypoint
|
||||
- `openapi-chat-prompt-flows.ts`: Public prompt-flow barrel plus MCP-tool chat orchestration over the extracted plain-flow seam
|
||||
- `openapi-chat-prompt-plain-flow.ts`: Extracted plain prompt submission flow, instruction-context assembly, and provider fallback handling
|
||||
- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, endpoint normalization, and required-credential helpers
|
||||
- `openapi-chat-model.ts`: Extracted pure OpenAPI chat model-name normalization helper
|
||||
- `openapi-chat-presentation.ts`: Extracted familiar/TTS pending, success, and failure presentation helpers for prompt-window chat
|
||||
- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows
|
||||
- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers
|
||||
- `familiaros-memory-search.ts`: Extracted pure memory query normalization, scoring, recall-intent fallback, chat-history relevance, and context-block shaping for FamiliarOS memory
|
||||
- `knowledge-store.ts`: Public knowledge-store facade that combines file search/storage with FamiliarOS memory search results
|
||||
- `knowledge-store-core.ts`: Extracted disk-backed knowledge file store class for CRUD, index caching, and path lookup
|
||||
- `knowledge-store-core-support.ts`: Extracted knowledge-store public support seam for file validation, search/context assembly, normalization, and atomic writes
|
||||
- `knowledge-store-core-helpers.ts`: Low-level knowledge-store parsing, MIME/text inference, query scoring, and tokenization helpers used by the public support seam
|
||||
- `openapi-chat-tool-loop.ts`: Extracted chat-completions tool-call loop, history seeding, tool-result threading, and tool-aware instruction helpers for prompt-window OpenAPI chat
|
||||
- `openapi-chat-config-store.ts`: Extracted credential persistence, storage-mode selection, current/legacy config path handling, and stored-credential decode helpers for prompt-window OpenAPI chat
|
||||
- `openapi-chat-request-helpers.ts`: Extracted pure request-body and chat-completions history shaping for prompt-window OpenAPI chat
|
||||
- `openapi-chat-conversation-store.ts`: Extracted current-conversation state ownership and disk-backed persistence for prompt-window chat
|
||||
- `openapi-chat-conversation-store-core.ts`: Pure transcript normalization, per-conversation capping, and title helpers for the extracted conversation store seam
|
||||
- `openapi-chat-provider.ts`: Stable provider barrel for prompt-window OpenAPI chat transport helpers.
|
||||
- `openapi-chat-provider-support.ts`: Public provider support barrel that keeps prompt-window imports stable while forwarding to focused routing and payload seams.
|
||||
- `openapi-chat-provider-routing.ts`: Extracted endpoint routing, auth header shaping, provider labeling, and normalized provider-error fallback decisions.
|
||||
- `openapi-chat-provider-payloads.ts`: Extracted responses/chat-completions payload parsing and tool-call envelope extraction for the MCP loop.
|
||||
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Familiars, Integrations, Plugins, and Settings, with extracted integrations/settings/helper route seams under `renderer/src/control-center/`, including the `settings-view-state.ts` hook seam, the `settings-view-state-actions.ts` route action shell, and the extracted state plugin/knowledge/type seams that keep route mutations bounded.
|
||||
|
||||
**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`: Default and agent familiar entrypoints, content reload orchestration, sprite animation states, speech bubble lifecycle, status badges, transient displays
|
||||
- `familiar-window-host.ts`: Extracted Electron familiar window shell, renderer navigation hardening, and serialized HTML reload queue
|
||||
- `familiar-window-content.ts`: Extracted familiar HTML render builders and installed-familiar fallback logic
|
||||
- `familiar-window-layout.ts`: Extracted familiar bubble sizing, scroll-cap selection, and base window layout calculations
|
||||
- `familiar-window-interactions.ts`: Public familiar-window interaction facade that re-exports mouse interop and owns motion-state publishing
|
||||
- `familiar-window-mouse-interop.ts`: Public familiar mouse passthrough, drag/scale state, and recovery facade over the extracted runtime seam
|
||||
- `familiar-window-mouse-interop-runtime.ts`: Extracted familiar mouse IPC handlers and delegation into the lifecycle attachment seam
|
||||
- `familiar-window-mouse-interop-runtime-lifecycle.ts`: Extracted familiar mouse window/webContents listener attachment, cleanup, navigation/load reset, and recovery registration
|
||||
- `familiar-window-mouse-interop-support.ts`: Extracted familiar mouse interop context, platform-specific passthrough rearm helpers, cursor hit-test probes, and payload guards
|
||||
- `familiar-window-plugin-menu.ts`: Extracted pet context-menu construction, default familiar plugin command menus, and plugin command form hosting
|
||||
- `familiar-window-renderer-bridge.ts`: Extracted renderer IPC helpers for bubble layout updates, reaction state, sprite overrides, audio playback, and TTS
|
||||
- `familiar-window-render.ts`: Shared familiar-window CSS/sprite render shell plus stage markup and bubble seam re-exports
|
||||
- `familiar-window-render-bubbles.ts`: Extracted familiar-window bubble/plugin HUD markup, status badge, and HTML escaping helpers
|
||||
- `default-familiar-controller.ts`: Default familiar visibility, position persistence, logging, and composition of the extracted display/external-action/motion/bubble/window seams
|
||||
- `default-familiar-controller-bubbles.ts`: Extracted default familiar plugin-bubble slot state, arbiter routing, and familiar-surface refresh/show callbacks
|
||||
- `default-familiar-controller-display.ts`: Extracted transient display timers, status badge lifetimes, and dismiss-token shaping
|
||||
- `default-familiar-controller-display-support.ts`: Extracted controller-local display helper factory for dismiss handling, badge wiring, and scale persistence
|
||||
- `default-familiar-controller-external.ts`: Extracted external reaction/say/internal-message/status/move orchestration for the default familiar control surface
|
||||
- `default-familiar-controller-motion.ts`: Extracted default familiar movement blocking and animated reposition helpers
|
||||
- `default-familiar-controller-window.ts`: Extracted default familiar content reload, hide/destroy lifecycle, display handler wiring, bounds recovery, and resume/topology mouse-interop helpers
|
||||
- `agent-familiar-controller.ts`: Lease-triggered familiar windows, dismissal tracking, transient displays, status badges, logging
|
||||
- `plugins-view-presentation.tsx`: Extracted Control Center plugins route iconography, label maps, permission tones, and primary/status badge helpers
|
||||
- `plugins-view-state.ts`: Extracted Control Center plugins route state, selection/filter derivation, and install/update/load actions
|
||||
- `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
|
||||
|
||||
**IPC**:
|
||||
- `local-ipc.ts`: net.Server implementation, request routing, discovery file management, network security (loopback/private address filtering), logging
|
||||
- `local-ipc.ts`: net.Server lifecycle, discovery file management, lease wiring, and request-entry logging
|
||||
- `local-ipc-request-handlers.ts`: Extracted local IPC protocol request dispatch, status shaping, familiar actions, and memory CRUD handlers
|
||||
- `local-ipc-socket.ts`: Extracted socket accept loop, advertised endpoint resolution, and loopback/private address filtering
|
||||
- `local-ipc-protocol.ts`: Protocol constants, request/response types, validation functions
|
||||
- `local-ipc-paths.ts`: Platform-specific socket paths and discovery file locations
|
||||
- `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
|
||||
- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback
|
||||
- `familiar-installation.ts`: ZIP download, yauzl extraction with safety limits, familiar validation
|
||||
- `familiar-installation-archive.ts`: Extracted ZIP download, yauzl entry validation, and bounded archive extraction
|
||||
- `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`: Catalog UI-state assembly, v3 surfaceability paging, pet lookup, and fixture fallback presentation.
|
||||
- `catalog-remote.ts`: Extracted remote catalog v2/v3 fetch/cache plumbing, fixture loading, and bounded response validation.
|
||||
- `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 plus the top-level declarative/JavaScript entrypoint validation, permissions, and exported manifest constants.
|
||||
- `plugin-manifest-config-validation.ts`: Extracted config-schema plus timer trigger/action validation for plugin-manifest, including reaction-select config checks.
|
||||
- `plugin-manifest-shape-validation.ts`: Extracted manifest shape validation for shared string/object guards, entry/icon/network checks, and v3 asset/panel declarations.
|
||||
- `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-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-familiar-api.ts`: Narrow adapter from plugin actions to default familiar external `say`/`react` controller calls.
|
||||
- `plugin-familiar-registry.ts`: Plugin-spawned familiar lifecycle ownership, list/change subscriptions, per-plugin teardown, and delegation into the extracted familiar action seam.
|
||||
- `plugin-familiar-registry-actions.ts`: Extracted plugin familiar reaction/animation/status/motion/state/arbiter routing for default and spawned familiar handles.
|
||||
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, local load, bundled seeding, and delegation into the extracted bundled lifecycle plus catalog/install/uninstall/config-sound seams.
|
||||
- `plugin-service-app.ts`: Extracted app-global plugin-service singleton, default familiar command/menu bridge, and test override hooks
|
||||
- `plugin-service-support.ts`: Service barrel for the extracted plugin-service action/bundled-lifecycle/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams
|
||||
- `plugin-service-actions.ts`: Extracted catalog snapshot/install-update, config-sound picker, and uninstall workflows for plugin-service
|
||||
- `plugin-service-guard-helpers.ts`: Extracted manifest/sound-safe error shaping plus permission/network approval subset helpers for plugin-service
|
||||
- `plugin-service-bundled-lifecycle.ts`: Extracted bundled plugin seed/prune lifecycle orchestration, official bundled id ownership, and default-enable preservation for plugin-service
|
||||
- `plugin-service-bundled-support.ts`: Extracted bundled source lookup and bundled install materialization helpers for plugin-service
|
||||
- `plugin-service-catalog-support.ts`: Extracted catalog compatibility, version gating, and installed-record metadata helpers for plugin-service
|
||||
- `plugin-service-dialog-support.ts`: Extracted dialog picker defaults and permission confirmation helpers for plugin-service
|
||||
- `plugin-service-local-support.ts`: Extracted local dev-plugin load, root scan, and stale plugins-dev pruning helpers for plugin-service
|
||||
- `plugin-service-default-pet.ts`: Extracted default familiar command/menu list shaping for plugin-service and familiar-window plugin menus
|
||||
- `plugin-service-snapshot.ts`: Extracted safe plugin snapshot shaping, locale/icon/config resolution, and runtime command/status projection for plugin-service
|
||||
- `plugin-service-text-helpers.ts`: Extracted UI-safe plugin text shaping, config/command display translation, and command-error sanitization for plugin-service
|
||||
- `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-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, startup/registration orchestration, and teardown.
|
||||
- `plugin-js-host-sdk-dispatch.ts`: Extracted JavaScript plugin SDK IPC installation, route-dispatch table, callback bridging, config listener cleanup, and legacy error alias shaping for the hidden host.
|
||||
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK shell that owns runtime state, host defaults, and delegation into the extracted API builder plus runtime-state/helper seams.
|
||||
- `plugin-sdk-api-builder.ts`: Public JavaScript plugin SDK namespace builder that composes extracted familiar, UI, storage, bus, and platform seams.
|
||||
- `plugin-sdk-api-builder-platform.ts`: Extracted JavaScript plugin SDK platform namespaces for network, notifications, AI, secrets, voice, auth, system, commands, status, HTTP, logs, and locale helpers.
|
||||
- `plugin-sdk-api-builder-platform-files.ts`: Extracted JavaScript plugin SDK files namespace builder for picker, handle-read, and save flows.
|
||||
- `plugin-sdk-familiar-surface.ts`: Extracted familiar, familiar-manager, asset-resolution, and schedule namespace assembly for the JavaScript plugin SDK builder.
|
||||
- `plugin-sdk-bubble-support.ts`: Extracted bubble text/markdown, action/input, host-icon, accent, and CSS color validation helpers for the JavaScript plugin SDK UI surface
|
||||
- `plugin-sdk-bubble-validation.ts`: Extracted bubble descriptor, indicator, and HUD validation helpers for the JavaScript plugin SDK bridge UI surface
|
||||
- `plugin-sdk-command-validation.ts`: Extracted command metadata, icon, form-schema, and form-value validation helpers for the JavaScript plugin SDK bridge
|
||||
- `plugin-sdk-validators.ts`: Stable validator facade for shared plugin SDK validation, scheduling, movement, OAuth, AI request, and runtime-safe JSON helpers
|
||||
- `plugin-sdk-bridge-runtime-state.ts`: Extracted bridge runtime-state shaping, command/menu callback execution, schedule resync, and cleanup helpers for the JavaScript plugin SDK bridge.
|
||||
- `plugin-sdk-runtime-helpers.ts`: Extracted schedule timing, timeout wrapping, bubble counting, and callback error normalization helpers for the JavaScript plugin SDK bridge.
|
||||
- `plugin-sdk-storage-stores.ts`: Extracted JSON and in-memory plugin storage-store implementations for the JavaScript plugin SDK bridge.
|
||||
|
||||
**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`)
|
||||
- `agent-setup.ts`: Agent-setup public entry points, snapshot assembly, selected-pet validation, and action journal coordination
|
||||
- `agent-setup-actions.ts`: Extracted action execution shell plus immediate editor, hook, and memory action routing
|
||||
- `agent-setup-actions-claude-mcp.ts`: Extracted Claude MCP add/remove/configure/replace orchestration plus memory-aware journaling
|
||||
- `agent-setup-actions-tooling.ts`: Extracted editor setup loading, global config actions, and FamiliarOS MCP server health checks
|
||||
- `agent-setup-claude-status.ts`: Extracted Claude MCP preview/status detection, command wrappers, output sanitization, and packaged-resource error shaping
|
||||
- `agent-setup-command-context.ts`: Agent setup command path persistence, preferred command resolution, and workspace package metadata helpers
|
||||
- `agent-setup-command-runner.ts`: Extracted agent-setup command execution, PATH shaping, Claude command candidate selection, and sanitized command result summarization
|
||||
- `agent-setup-editor-tools.ts`: Stable barrel for the extracted OpenCode and Cursor editor setup helper seams
|
||||
- `agent-setup-editor-tools-opencode.ts`: Extracted OpenCode global setup status, previews, and config write helpers
|
||||
- `agent-setup-editor-tools-cursor.ts`: Extracted Cursor global setup status, previews, and MCP config write helpers
|
||||
- `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 +377,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 |
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -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 {
|
||||
10
apps/desktop/src/control-center-agent-services.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {
|
||||
buildFamiliarOSMcpServerPreview,
|
||||
getAgentSetupSnapshot,
|
||||
runAgentSetupAction,
|
||||
testFamiliarOSMcpServer,
|
||||
updateAgentSetupCommandPaths,
|
||||
} from "./agent-setup.js";
|
||||
export { refreshAgentPetContent } from "./agent-familiar-controller.js";
|
||||
export { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js";
|
||||
export { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js";
|
||||
18
apps/desktop/src/control-center-core-services.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export { getAppStateSnapshot, updatePreferences } from "./app-state.js";
|
||||
export {
|
||||
getDashboardSnapshot,
|
||||
getI18nSnapshot,
|
||||
getLaunchAtLoginState,
|
||||
getPetsStateSnapshot,
|
||||
getReactionAnimationSettingsSnapshot,
|
||||
getSettingsStateSnapshot,
|
||||
} from "./control-center-state.js";
|
||||
export {
|
||||
isPlainObject,
|
||||
validateExternalUrl,
|
||||
validatePreferencePatch,
|
||||
} from "./control-center-state-validation.js";
|
||||
export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
|
||||
export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js";
|
||||
export { debug, error as logError, warn } from "./logger.js";
|
||||
export { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";
|
||||
23
apps/desktop/src/control-center-data-services.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export {
|
||||
forgetFamiliarOSMemory,
|
||||
listFamiliarOSMemories,
|
||||
searchFamiliarOSMemories,
|
||||
storeFamiliarOSMemory,
|
||||
updateFamiliarOSMemory,
|
||||
} from "./familiaros-memory.js";
|
||||
export {
|
||||
addKnowledgeMemory,
|
||||
deleteKnowledgeFile,
|
||||
listKnowledgeFiles,
|
||||
searchKnowledgeStore,
|
||||
storeKnowledgeFile,
|
||||
} from "./knowledge-store.js";
|
||||
export {
|
||||
clearOpenApiCredential,
|
||||
getOpenApiChatSettingsSnapshot,
|
||||
resetOpenApiConversationContext,
|
||||
saveOpenApiCredential,
|
||||
} from "./openapi-chat.js";
|
||||
export { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js";
|
||||
export { clearTtsCredential, saveTtsCredential } from "./tts-credentials.js";
|
||||
export { fetchTtsVoiceList, getTtsSettingsSnapshot, speakTts, stopTts, type TtsProviderId } from "./tts-service.js";
|
||||
116
apps/desktop/src/control-center-ipc-agent.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { ipcMain } from "electron";
|
||||
|
||||
import {
|
||||
buildFamiliarOSMcpServerPreview,
|
||||
getAgentSetupSnapshot,
|
||||
getMcpChatClientManager,
|
||||
installPersistentToolkit,
|
||||
listMcpChatVanillaServerIds,
|
||||
runAgentSetupAction,
|
||||
testFamiliarOSMcpServer,
|
||||
updateAgentSetupCommandPaths,
|
||||
} from "./control-center-agent-services.js";
|
||||
import {
|
||||
getAppStateSnapshot,
|
||||
updatePreferences,
|
||||
} from "./control-center-core-services.js";
|
||||
import {
|
||||
assertAllowedSender,
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
|
||||
type SupportedAgentSetupAction = Parameters<typeof runAgentSetupAction>[0];
|
||||
type SupportedCommandMode = Parameters<typeof testFamiliarOSMcpServer>[1];
|
||||
|
||||
export function installControlCenterAgentHandlers(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
): void {
|
||||
ipcMain.handle("familiaros:install-mcp-toolkit", async (event, target: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (target !== "claude-user" && target !== "codex-global") {
|
||||
throw new Error("Invalid MCP toolkit target.");
|
||||
}
|
||||
return installPersistentToolkit(target);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-vanilla-chat-mcp-tools", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
enabled: state.preferences.vanillaChatMcpTools ?? [],
|
||||
available: listMcpChatVanillaServerIds(),
|
||||
active: getMcpChatClientManager().getActiveServerIds(),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-vanilla-chat-mcp-tools", async (event, toolIds: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!Array.isArray(toolIds) || !toolIds.every((id) => typeof id === "string")) {
|
||||
throw new Error("Invalid vanilla chat MCP tool list.");
|
||||
}
|
||||
const validIds = new Set(listMcpChatVanillaServerIds());
|
||||
const sanitized = toolIds.filter((id: string) => validIds.has(id));
|
||||
updatePreferences({ vanillaChatMcpTools: sanitized });
|
||||
await getMcpChatClientManager().startEnabledServers(sanitized);
|
||||
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getAgentSetupSnapshot(selectedPetId, commandMode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isSupportedAgentSetupAction(action)) {
|
||||
throw new Error("Invalid agent setup action.");
|
||||
}
|
||||
return runAgentSetupAction(action, selectedPetId, commandMode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-command-paths", (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return updateAgentSetupCommandPaths(patch);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:familiaros-mcp-server-preview", (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return buildFamiliarOSMcpServerPreview(
|
||||
normalizeSelectedPetId(selectedPetId),
|
||||
normalizeCommandMode(commandMode),
|
||||
);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:test-familiaros-mcp-server", async (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return testFamiliarOSMcpServer(
|
||||
normalizeSelectedPetId(selectedPetId),
|
||||
normalizeCommandMode(commandMode),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isSupportedAgentSetupAction(value: unknown): value is SupportedAgentSetupAction {
|
||||
return value === "configure"
|
||||
|| value === "replace"
|
||||
|| value === "remove"
|
||||
|| value === "install-memory"
|
||||
|| value === "doctor-hooks"
|
||||
|| value === "install-hooks"
|
||||
|| value === "uninstall-hooks"
|
||||
|| value === "opencode-install"
|
||||
|| value === "opencode-remove"
|
||||
|| value === "cursor-install"
|
||||
|| value === "cursor-replace"
|
||||
|| value === "cursor-remove";
|
||||
}
|
||||
|
||||
function normalizeSelectedPetId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeCommandMode(value: unknown): SupportedCommandMode {
|
||||
return value === "published" || value === "bundled" || value === "local"
|
||||
? value
|
||||
: "published";
|
||||
}
|
||||
131
apps/desktop/src/control-center-ipc-knowledge.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { readFile, stat } from "node:fs/promises";
|
||||
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron";
|
||||
|
||||
import {
|
||||
addKnowledgeMemory,
|
||||
deleteKnowledgeFile,
|
||||
forgetFamiliarOSMemory,
|
||||
listFamiliarOSMemories,
|
||||
listKnowledgeFiles,
|
||||
searchFamiliarOSMemories,
|
||||
searchKnowledgeStore,
|
||||
storeFamiliarOSMemory,
|
||||
storeKnowledgeFile,
|
||||
updateFamiliarOSMemory,
|
||||
} from "./control-center-data-services.js";
|
||||
import {
|
||||
assertAllowedSender,
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
|
||||
export function installControlCenterKnowledgeHandlers(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
): void {
|
||||
ipcMain.handle("familiaros:get-memories", async (event, query: unknown, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const normalizedQuery = typeof query === "string" ? query.trim() : "";
|
||||
const normalizedLimit = normalizeLimit(limit, 50, 100);
|
||||
if (normalizedQuery) {
|
||||
return searchFamiliarOSMemories(normalizedQuery, normalizedLimit).map((hit) => hit.entry);
|
||||
}
|
||||
return listFamiliarOSMemories(normalizedLimit);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
return storeFamiliarOSMemory({
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:update-memory", async (event, id: unknown, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
const result = updateFamiliarOSMemory(id.trim(), {
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
if (!result) throw new Error("Memory not found.");
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:delete-memory", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
|
||||
const removed = forgetFamiliarOSMemory(id.trim());
|
||||
if (!removed) throw new Error("Memory not found.");
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-list", async (event, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return listKnowledgeFiles(normalizeLimit(limit, 50, 100));
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-search", async (event, query: unknown, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const normalizedQuery = typeof query === "string" ? query.trim() : "";
|
||||
const normalizedLimit = normalizeLimit(limit, 10, 100);
|
||||
if (!normalizedQuery) {
|
||||
return {
|
||||
files: listKnowledgeFiles(normalizedLimit).map((file) => ({ file, score: 0 })),
|
||||
memories: [],
|
||||
};
|
||||
}
|
||||
return searchKnowledgeStore(normalizedQuery, normalizedLimit);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-delete-file", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("File id is required.");
|
||||
const removed = await deleteKnowledgeFile(id.trim());
|
||||
if (!removed) throw new Error("File not found.");
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-add-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
return addKnowledgeMemory({
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-store-file", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
|
||||
const options: OpenDialogOptions = {
|
||||
title: "Add file to Knowledge Store",
|
||||
buttonLabel: "Add",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "All files", extensions: ["*"] }],
|
||||
};
|
||||
const result = owner
|
||||
? await dialog.showOpenDialog(owner, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
if (result.canceled || !result.filePaths[0]) return null;
|
||||
const filePath = result.filePaths[0];
|
||||
const stats = await stat(filePath);
|
||||
if (!stats.isFile()) throw new Error("Selected path is not a file.");
|
||||
const data = await readFile(filePath);
|
||||
const name = filePath.replace(/\\/g, "/").split("/").pop() ?? "unnamed";
|
||||
return storeKnowledgeFile({ name, data });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLimit(value: unknown, fallback: number, max: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(1, Math.min(max, Math.round(value)))
|
||||
: fallback;
|
||||
}
|
||||
190
apps/desktop/src/control-center-ipc-pets.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { stat } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
shell,
|
||||
type OpenDialogOptions,
|
||||
} from "electron";
|
||||
|
||||
import {
|
||||
debug,
|
||||
getCatalogPageUiState,
|
||||
getCatalogSearchUiState,
|
||||
getCatalogUiState,
|
||||
getPetsStateSnapshot,
|
||||
getSettingsStateSnapshot,
|
||||
getAppStateSnapshot,
|
||||
logError,
|
||||
} from "./control-center-core-services.js";
|
||||
import {
|
||||
installPet,
|
||||
installPetFromFolder,
|
||||
installPetFromZipFile,
|
||||
recoverDefaultPetMouseInterop,
|
||||
refreshDefaultPetContent,
|
||||
removePet,
|
||||
resetDefaultPetToInitialPosition,
|
||||
setDefaultInstalledPet,
|
||||
} from "./control-center-pet-services.js";
|
||||
import {
|
||||
assertAllowedSender,
|
||||
isControlCenterSender,
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
|
||||
export function installControlCenterPetHandlers(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
): void {
|
||||
ipcMain.handle("familiaros:get-familiars-state", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPetsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getCatalogUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog-page", async (event, page: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof page !== "number" || !Number.isInteger(page) || page < 0) {
|
||||
throw new Error("Invalid catalog page.");
|
||||
}
|
||||
return getCatalogPageUiState(page);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog-search", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getCatalogSearchUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-codex-familiars", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return (await loadCodexPetsModule()).getCodexPetsUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-default-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
|
||||
|
||||
const state = await setDefaultInstalledPet(petId);
|
||||
refreshDefaultPetContent();
|
||||
recoverDefaultPetMouseInterop("default-familiar-changed");
|
||||
setTimeout(() => recoverDefaultPetMouseInterop("default-familiar-changed+500ms"), 500).unref?.();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getPetsStateSnapshot()
|
||||
: state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:install-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
|
||||
|
||||
const state = await installPet(petId);
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getPetsStateSnapshot()
|
||||
: state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:install-local-familiar", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
|
||||
const importKind = await chooseLocalPetImportKind(owner);
|
||||
if (!importKind) return getPetsStateSnapshot();
|
||||
const options: OpenDialogOptions = importKind === "zip"
|
||||
? {
|
||||
title: "Install familiar from ZIP",
|
||||
buttonLabel: "Install Familiar",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "FamiliarOS ZIP", extensions: ["zip"] }],
|
||||
}
|
||||
: {
|
||||
title: "Install familiar from folder",
|
||||
buttonLabel: "Install Familiar",
|
||||
properties: ["openDirectory"],
|
||||
};
|
||||
const result = owner
|
||||
? await dialog.showOpenDialog(owner, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
if (result.canceled || !result.filePaths[0]) return getPetsStateSnapshot();
|
||||
const selectedPath = result.filePaths[0];
|
||||
try {
|
||||
const selectedStats = await stat(selectedPath);
|
||||
const state = selectedStats.isDirectory()
|
||||
? await installPetFromFolder(selectedPath)
|
||||
: await installPetFromZipFile(selectedPath);
|
||||
debug("ui", "local familiar import succeeded", {
|
||||
kind: selectedStats.isDirectory() ? "folder" : "zip",
|
||||
});
|
||||
refreshDefaultPetContent();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getPetsStateSnapshot()
|
||||
: state;
|
||||
} catch (error) {
|
||||
logError("ui", "local familiar import failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-gallery", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
await shell.openExternal("https://familiaros.dev/gallery");
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:import-codex-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
|
||||
|
||||
const state = await (await loadCodexPetsModule()).importCodexPet(petId);
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getPetsStateSnapshot()
|
||||
: state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:remove-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") throw new Error("Invalid familiar id.");
|
||||
|
||||
const state = await removePet(petId);
|
||||
refreshDefaultPetContent();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getPetsStateSnapshot()
|
||||
: state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:reset-default-familiar-position", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
resetDefaultPetToInitialPosition();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getSettingsStateSnapshot()
|
||||
: getAppStateSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCodexPetsModule() {
|
||||
return import("./codex-familiars.js");
|
||||
}
|
||||
|
||||
function chooseLocalPetImportKind(
|
||||
owner: BrowserWindow | undefined,
|
||||
): Promise<"zip" | "folder" | null> {
|
||||
const options = {
|
||||
type: "question" as const,
|
||||
title: "Install familiar",
|
||||
message: "Install familiar from ZIP or folder?",
|
||||
detail: "Choose the source type before selecting the familiar package.",
|
||||
buttons: ["ZIP", "Folder", "Cancel"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
noLink: true,
|
||||
};
|
||||
return (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)).then((result) => {
|
||||
if (result.response === 0) return "zip";
|
||||
if (result.response === 1) return "folder";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
177
apps/desktop/src/control-center-ipc-plugin.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { ipcMain } from "electron";
|
||||
|
||||
import {
|
||||
getPluginService,
|
||||
type PluginConfigSoundPickResult,
|
||||
type PluginServiceResult,
|
||||
} from "./control-center-data-services.js";
|
||||
import {
|
||||
debug,
|
||||
isPlainObject,
|
||||
logError,
|
||||
warn,
|
||||
} from "./control-center-core-services.js";
|
||||
import {
|
||||
assertAllowedSender,
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
|
||||
const pluginIdPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
|
||||
const pluginCommandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
|
||||
export function installControlCenterPluginHandlers(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
): void {
|
||||
ipcMain.handle("familiaros:plugins-snapshot", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().getSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || typeof enabled !== "boolean") {
|
||||
return pluginUiError("Invalid plugin enable request.");
|
||||
}
|
||||
return getPluginService().setEnabled(id, enabled);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-save-config", async (event, id: unknown, config: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || !isPlainObject(config)) {
|
||||
return pluginUiError("Invalid plugin config request.");
|
||||
}
|
||||
return getPluginService().saveConfig(id, config);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-pick-config-sound", async (event, id: unknown): Promise<PluginConfigSoundPickResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) {
|
||||
warn("ui", "Plugin sound pick invalid request.", { ok: false, reason: "invalid-plugin-id" });
|
||||
return pluginUiSoundError("Invalid plugin sound request.");
|
||||
}
|
||||
debug("ui", "Plugin sound pick requested.", { pluginId: id });
|
||||
try {
|
||||
const result = await getPluginService().pickConfigSound(id);
|
||||
if (result.ok && "sound" in result && result.sound.id) {
|
||||
debug("ui", "Plugin sound pick succeeded.", { pluginId: id, ok: true, soundId: result.sound.id });
|
||||
} else if (result.ok) {
|
||||
debug("ui", "Plugin sound pick canceled.", { pluginId: id, ok: true, canceled: true });
|
||||
} else {
|
||||
warn("ui", "Plugin sound pick failed.", { pluginId: id, ok: false, reason: result.error });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
logError("ui", "Plugin sound pick errored.", {
|
||||
pluginId: id,
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin reload request.");
|
||||
return getPluginService().reload(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-execute-command", async (event, id: unknown, commandId: unknown, args: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || !isValidPluginCommandId(commandId) || (args !== undefined && !isPlainObject(args))) {
|
||||
return pluginUiError("Invalid plugin command request.");
|
||||
}
|
||||
return getPluginService().executeCommand(
|
||||
id,
|
||||
commandId,
|
||||
isPlainObject(args) ? args as Record<string, unknown> : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-load-local", async (event): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().loadLocal();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-catalog-snapshot", async (event, refresh: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().getCatalogSnapshot(refresh === true);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-install-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin install request.");
|
||||
return getPluginService().installCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-update-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin update request.");
|
||||
return getPluginService().updateCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-uninstall", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin uninstall request.");
|
||||
return getPluginService().uninstall(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-inspector", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) throw new Error("Invalid plugin inspector request.");
|
||||
return getPluginService().runtime.getInspectorState(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-settings-get", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginPlatformSettings } = await import("./plugin-platform-settings.js");
|
||||
return getPluginPlatformSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-settings-update", async (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isPlainObject(patch)) throw new Error("Invalid plugin platform settings patch.");
|
||||
const { updatePluginPlatformSettings } = await import("./plugin-platform-settings.js");
|
||||
return updatePluginPlatformSettings(patch as never);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-ai-key-set", async (event, key: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
|
||||
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
|
||||
const capabilities = getPluginHostCapabilitiesForUi();
|
||||
if (!capabilities) throw new Error("Plugin host capabilities are unavailable.");
|
||||
if (key === null || key === "") {
|
||||
await capabilities.secretsStore.delete(hostSecretsOwner, hostAiApiKeySecret);
|
||||
return { ok: true, hasKey: false };
|
||||
}
|
||||
if (typeof key !== "string" || key.length > 4096) throw new Error("Invalid AI API key.");
|
||||
await capabilities.secretsStore.set(hostSecretsOwner, hostAiApiKeySecret, key);
|
||||
return { ok: true, hasKey: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-ai-key-status", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
|
||||
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
|
||||
const capabilities = getPluginHostCapabilitiesForUi();
|
||||
if (!capabilities) return { hasKey: false };
|
||||
return { hasKey: await capabilities.secretsStore.has(hostSecretsOwner, hostAiApiKeySecret) };
|
||||
});
|
||||
}
|
||||
|
||||
function pluginUiError(error: string): PluginServiceResult {
|
||||
return { ok: false, error, snapshot: { plugins: [] } };
|
||||
}
|
||||
|
||||
function pluginUiSoundError(error: string): PluginConfigSoundPickResult {
|
||||
return { ok: false, error, snapshot: { plugins: [] } };
|
||||
}
|
||||
|
||||
function isValidPluginId(value: unknown): value is string {
|
||||
return typeof value === "string" && pluginIdPattern.test(value);
|
||||
}
|
||||
|
||||
function isValidPluginCommandId(value: unknown): value is string {
|
||||
return typeof value === "string" && pluginCommandIdPattern.test(value);
|
||||
}
|
||||
200
apps/desktop/src/control-center-ipc-settings.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { app, clipboard, ipcMain, shell } from "electron";
|
||||
|
||||
import { refreshAgentPetContent } from "./control-center-agent-services.js";
|
||||
import {
|
||||
clearOpenApiCredential,
|
||||
clearTtsCredential,
|
||||
fetchTtsVoiceList,
|
||||
getOpenApiChatSettingsSnapshot,
|
||||
getTtsSettingsSnapshot,
|
||||
resetOpenApiConversationContext,
|
||||
saveOpenApiCredential,
|
||||
saveTtsCredential,
|
||||
speakTts,
|
||||
stopTts,
|
||||
type TtsProviderId,
|
||||
} from "./control-center-data-services.js";
|
||||
import {
|
||||
checkForGitHubReleaseUpdate,
|
||||
getActiveLocale,
|
||||
getDashboardSnapshot,
|
||||
getI18nSnapshot,
|
||||
getLaunchAtLoginState,
|
||||
getSettingsStateSnapshot,
|
||||
getReactionAnimationSettingsSnapshot,
|
||||
getUpdateStatus,
|
||||
openUpdateReleasePage,
|
||||
setLocaleFromPreference,
|
||||
updatePreferences,
|
||||
validateExternalUrl,
|
||||
validatePreferencePatch,
|
||||
} from "./control-center-core-services.js";
|
||||
import { refreshDefaultPetContent } from "./control-center-pet-services.js";
|
||||
import {
|
||||
assertAllowedSender,
|
||||
isControlCenterSender,
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
|
||||
export function installControlCenterSettingsHandlers(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
): void {
|
||||
ipcMain.handle("familiaros:get-settings-state", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getSettingsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-i18n", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getI18nSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-dashboard-snapshot", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getDashboardSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-openapi-chat-settings", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getOpenApiChatSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:save-openapi-credential", (event, apiKey: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof apiKey !== "string") throw new Error("Invalid chat credential.");
|
||||
return saveOpenApiCredential(apiKey);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:clear-openapi-credential", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return clearOpenApiCredential();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-reaction-animation-settings", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getReactionAnimationSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:update-preferences", (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const currentState = getSettingsStateSnapshot();
|
||||
const previousScale = currentState.preferences.petScale;
|
||||
const previousOverrides = JSON.stringify(currentState.preferences.reactionAnimationOverrides ?? {});
|
||||
const previousLocale = getActiveLocale();
|
||||
const nextState = updatePreferences(validatePreferencePatch(patch));
|
||||
if (
|
||||
currentState.preferences.openApiChatModel !== nextState.preferences.openApiChatModel
|
||||
|| currentState.preferences.openApiChatEndpoint !== nextState.preferences.openApiChatEndpoint
|
||||
) {
|
||||
resetOpenApiConversationContext();
|
||||
}
|
||||
const nextOverrides = JSON.stringify(nextState.preferences.reactionAnimationOverrides ?? {});
|
||||
if (nextState.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
|
||||
refreshDefaultPetContent();
|
||||
refreshAgentPetContent();
|
||||
}
|
||||
const nextLocale = setLocaleFromPreference(nextState.preferences.locale);
|
||||
if (
|
||||
nextLocale !== previousLocale
|
||||
|| nextState.preferences.familiarName !== currentState.preferences.familiarName
|
||||
) {
|
||||
void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu());
|
||||
if (nextLocale !== previousLocale) {
|
||||
broadcastPluginRecordsRefresh(getControlCenterWindow);
|
||||
}
|
||||
}
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id)
|
||||
? getSettingsStateSnapshot()
|
||||
: nextState;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-launch-at-login", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getLaunchAtLoginState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-launch-at-login", (event, enabled: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
|
||||
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
|
||||
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
|
||||
return getLaunchAtLoginState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-update-status", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getUpdateStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:check-for-updates", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const status = await checkForGitHubReleaseUpdate();
|
||||
const { refreshTrayMenu } = await import("./tray.js");
|
||||
refreshTrayMenu();
|
||||
return status;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-update-release-page", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
await openUpdateReleasePage();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:copy-text", (event, text: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid copy payload.");
|
||||
clipboard.writeText(text);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-external-url", async (event, rawUrl: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const url = validateExternalUrl(rawUrl);
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-tts-settings", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getTtsSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-tts-voices", async (event, provider: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
return fetchTtsVoiceList(provider as TtsProviderId);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:save-tts-credential", (event, provider: unknown, credential: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
if (typeof credential !== "string") throw new Error("Credential must be a string.");
|
||||
return saveTtsCredential(provider, credential);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:clear-tts-credential", (event, provider: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
return clearTtsCredential(provider);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:test-tts", async (event, text: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Text must be a non-empty string.");
|
||||
await speakTts(text.trim(), "familiar");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:tts-stop", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
stopTts("familiar");
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastPluginRecordsRefresh(getControlCenterWindow: ControlCenterWindowGetter): void {
|
||||
const controlCenterWindow = getControlCenterWindow();
|
||||
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) {
|
||||
controlCenterWindow.webContents.send("familiaros:plugins-refresh");
|
||||
}
|
||||
}
|
||||
|
||||
function isLaunchAtLoginSupported(): boolean {
|
||||
return process.platform === "darwin" || process.platform === "win32";
|
||||
}
|
||||
37
apps/desktop/src/control-center-ipc-shared.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { BrowserWindow, type IpcMainInvokeEvent } from "electron";
|
||||
|
||||
export type InternalUiWindowKind = "control-center";
|
||||
export type ControlCenterWindowGetter = () => BrowserWindow | null;
|
||||
|
||||
export function assertAllowedSender(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
event: IpcMainInvokeEvent,
|
||||
allowedKinds: readonly InternalUiWindowKind[],
|
||||
): void {
|
||||
const actualKind = getInternalUiWindowKindForWebContents(getControlCenterWindow, event.sender.id);
|
||||
if (!actualKind || !allowedKinds.includes(actualKind)) {
|
||||
throw new Error("FamiliarOS internal UI request came from an unexpected window.");
|
||||
}
|
||||
}
|
||||
|
||||
export function isControlCenterSender(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
webContentsId: number,
|
||||
): boolean {
|
||||
return getInternalUiWindowKindForWebContents(getControlCenterWindow, webContentsId) === "control-center";
|
||||
}
|
||||
|
||||
function getInternalUiWindowKindForWebContents(
|
||||
getControlCenterWindow: ControlCenterWindowGetter,
|
||||
webContentsId: number,
|
||||
): InternalUiWindowKind | null {
|
||||
const controlCenterWindow = getControlCenterWindow();
|
||||
if (
|
||||
controlCenterWindow
|
||||
&& !controlCenterWindow.isDestroyed()
|
||||
&& controlCenterWindow.webContents.id === webContentsId
|
||||
) {
|
||||
return "control-center";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
32
apps/desktop/src/control-center-ipc.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
installControlCenterAgentHandlers,
|
||||
} from "./control-center-ipc-agent.js";
|
||||
import {
|
||||
installControlCenterKnowledgeHandlers,
|
||||
} from "./control-center-ipc-knowledge.js";
|
||||
import {
|
||||
installControlCenterPetHandlers,
|
||||
} from "./control-center-ipc-pets.js";
|
||||
import {
|
||||
installControlCenterPluginHandlers,
|
||||
} from "./control-center-ipc-plugin.js";
|
||||
import {
|
||||
type ControlCenterWindowGetter,
|
||||
} from "./control-center-ipc-shared.js";
|
||||
import {
|
||||
installControlCenterSettingsHandlers,
|
||||
} from "./control-center-ipc-settings.js";
|
||||
|
||||
let internalUiHandlersInstalled = false;
|
||||
|
||||
export function installInternalUiHandlers(getControlCenterWindow: ControlCenterWindowGetter): void {
|
||||
if (internalUiHandlersInstalled) return;
|
||||
|
||||
internalUiHandlersInstalled = true;
|
||||
|
||||
installControlCenterSettingsHandlers(getControlCenterWindow);
|
||||
installControlCenterKnowledgeHandlers(getControlCenterWindow);
|
||||
installControlCenterPluginHandlers(getControlCenterWindow);
|
||||
installControlCenterPetHandlers(getControlCenterWindow);
|
||||
installControlCenterAgentHandlers(getControlCenterWindow);
|
||||
}
|
||||
12
apps/desktop/src/control-center-pet-services.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
recoverDefaultPetMouseInterop,
|
||||
refreshDefaultPetContent,
|
||||
resetDefaultPetToInitialPosition,
|
||||
} from "./default-familiar-controller.js";
|
||||
export {
|
||||
installPet,
|
||||
installPetFromFolder,
|
||||
installPetFromZipFile,
|
||||
removePet,
|
||||
setDefaultInstalledPet,
|
||||
} from "./familiar-installation.js";
|
||||
199
apps/desktop/src/control-center-state-validation.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
normalizeFamiliarName,
|
||||
normalizePetScale,
|
||||
} from "./app-state-core.js";
|
||||
import {
|
||||
normalizeOpenApiChatEndpoint,
|
||||
type FamiliarOSPreferences,
|
||||
} from "./app-state-preferences.js";
|
||||
import {
|
||||
isSupportedLocale,
|
||||
} from "./i18n/index.js";
|
||||
import { normalizeChatModel } from "./openapi-chat-model.js";
|
||||
import { validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
|
||||
import { listTtsProviders } from "./tts-engine.js";
|
||||
import { type TtsProviderId } from "./tts-service.js";
|
||||
|
||||
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
export type ControlCenterPreferencesPatch = Partial<Mutable<FamiliarOSPreferences>>;
|
||||
|
||||
export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid preferences patch.");
|
||||
}
|
||||
|
||||
const patch: ControlCenterPreferencesPatch = {};
|
||||
|
||||
if ("openDefaultPetOnLaunch" in value) {
|
||||
if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value.");
|
||||
patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch;
|
||||
}
|
||||
|
||||
if ("locale" in value) {
|
||||
if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value.");
|
||||
patch.locale = value.locale;
|
||||
}
|
||||
|
||||
if ("petScale" in value) {
|
||||
const scale = normalizePetScale(value.petScale);
|
||||
if (scale !== value.petScale) throw new Error("Invalid familiar scale value.");
|
||||
patch.petScale = scale;
|
||||
}
|
||||
|
||||
if ("reactionAnimationOverrides" in value) {
|
||||
patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides);
|
||||
}
|
||||
|
||||
if ("openApiChatModel" in value) {
|
||||
if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") {
|
||||
patch.openApiChatModel = undefined;
|
||||
} else {
|
||||
const model = normalizeChatModel(value.openApiChatModel);
|
||||
if (!model) throw new Error("Invalid model name.");
|
||||
patch.openApiChatModel = model;
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatSystemPrompt" in value) {
|
||||
if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") {
|
||||
patch.openApiChatSystemPrompt = undefined;
|
||||
} else if (typeof value.openApiChatSystemPrompt === "string") {
|
||||
patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt;
|
||||
} else {
|
||||
throw new Error("Invalid familiar character prompt.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatEndpoint" in value) {
|
||||
if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") {
|
||||
patch.openApiChatEndpoint = undefined;
|
||||
} else {
|
||||
const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint);
|
||||
if (!endpoint) {
|
||||
throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL.");
|
||||
}
|
||||
patch.openApiChatEndpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatTheme" in value) {
|
||||
if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") {
|
||||
throw new Error("Invalid theme mode.");
|
||||
}
|
||||
patch.openApiChatTheme = value.openApiChatTheme;
|
||||
}
|
||||
|
||||
if ("openApiChatBaseInstructionsEnabled" in value) {
|
||||
if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") {
|
||||
throw new Error("Invalid base instructions value.");
|
||||
}
|
||||
patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled;
|
||||
}
|
||||
|
||||
if ("ttsProvider" in value) {
|
||||
const validIds = listTtsProviders().map((provider) => provider.id);
|
||||
if (!validIds.includes(value.ttsProvider as TtsProviderId)) {
|
||||
throw new Error("Invalid TTS provider.");
|
||||
}
|
||||
patch.ttsProvider = value.ttsProvider as TtsProviderId;
|
||||
}
|
||||
|
||||
if ("ttsVoice" in value) {
|
||||
if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") {
|
||||
patch.ttsVoice = undefined;
|
||||
} else if (typeof value.ttsVoice === "string") {
|
||||
patch.ttsVoice = value.ttsVoice;
|
||||
} else {
|
||||
throw new Error("Invalid TTS voice.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("ttsSpeed" in value) {
|
||||
const speed = Number(value.ttsSpeed);
|
||||
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
|
||||
throw new Error("Invalid TTS speed. Must be between 0.5 and 2.");
|
||||
}
|
||||
patch.ttsSpeed = Math.round(speed * 10) / 10;
|
||||
}
|
||||
|
||||
if ("ttsModel" in value) {
|
||||
if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") {
|
||||
patch.ttsModel = undefined;
|
||||
} else if (typeof value.ttsModel === "string") {
|
||||
const trimmed = value.ttsModel.trim();
|
||||
if (trimmed.length > 120) throw new Error("Invalid TTS model name.");
|
||||
patch.ttsModel = trimmed;
|
||||
} else {
|
||||
throw new Error("Invalid TTS model.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("ttsEndpointPreset" in value) {
|
||||
if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") {
|
||||
throw new Error("Invalid TTS endpoint preset.");
|
||||
}
|
||||
patch.ttsEndpointPreset = value.ttsEndpointPreset;
|
||||
}
|
||||
|
||||
if ("ttsEndpoint" in value) {
|
||||
if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") {
|
||||
patch.ttsEndpoint = undefined;
|
||||
} else {
|
||||
const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint);
|
||||
if (!endpoint) {
|
||||
throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL.");
|
||||
}
|
||||
patch.ttsEndpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
if ("familiarName" in value) {
|
||||
if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") {
|
||||
patch.familiarName = undefined;
|
||||
} else if (typeof value.familiarName === "string") {
|
||||
const name = normalizeFamiliarName(value.familiarName);
|
||||
if (name === undefined) {
|
||||
throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters.");
|
||||
}
|
||||
patch.familiarName = name;
|
||||
} else {
|
||||
throw new Error("Invalid Familiar name.");
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value) as unknown;
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function validateExternalUrl(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
|
||||
const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
|
||||
if ((parsed.protocol !== "https:" && !isLocalHttp)
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.hash) {
|
||||
throw new Error("Only https URLs, or localhost http URLs, are allowed.");
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
177
apps/desktop/src/control-center-state.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import { getAppStateSnapshot, petScaleOptions } from "./app-state.js";
|
||||
import { getCatalogUiState } from "./catalog.js";
|
||||
import { getInstalledPetDir } from "./familiar-paths.js";
|
||||
import { getActiveLocale, getActiveMessages, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js";
|
||||
import { warn } from "./logger.js";
|
||||
import { getOpenApiChatSettingsSnapshot } from "./openapi-chat.js";
|
||||
import { getPluginService } from "./plugin-service.js";
|
||||
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata } from "./reaction-animation-mapping.js";
|
||||
import { getUpdateStatus } from "./update-checker.js";
|
||||
|
||||
type ControlCenterSettingsPreferences = Pick<
|
||||
ReturnType<typeof getAppStateSnapshot>["preferences"],
|
||||
| "openDefaultPetOnLaunch"
|
||||
| "locale"
|
||||
| "petScale"
|
||||
| "reactionAnimationOverrides"
|
||||
| "openApiChatModel"
|
||||
| "openApiChatSystemPrompt"
|
||||
| "openApiChatEndpoint"
|
||||
| "openApiChatTheme"
|
||||
| "vanillaChatMcpTools"
|
||||
| "openApiChatBaseInstructionsEnabled"
|
||||
| "ttsProvider"
|
||||
| "ttsVoice"
|
||||
| "ttsSpeed"
|
||||
| "ttsModel"
|
||||
| "ttsEndpointPreset"
|
||||
| "ttsEndpoint"
|
||||
| "familiarName"
|
||||
>;
|
||||
|
||||
export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType<typeof getAppStateSnapshot>["familiars"] } {
|
||||
const state = getAppStateSnapshot();
|
||||
return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars };
|
||||
}
|
||||
|
||||
export function getSettingsStateSnapshot(): {
|
||||
preferences: ControlCenterSettingsPreferences;
|
||||
petScaleOptions: typeof petScaleOptions;
|
||||
openApiChat: ReturnType<typeof getOpenApiChatSettingsSnapshot>;
|
||||
} {
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
preferences: {
|
||||
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
|
||||
locale: state.preferences.locale,
|
||||
petScale: state.preferences.petScale,
|
||||
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
|
||||
openApiChatModel: state.preferences.openApiChatModel,
|
||||
openApiChatSystemPrompt: state.preferences.openApiChatSystemPrompt,
|
||||
openApiChatEndpoint: state.preferences.openApiChatEndpoint,
|
||||
openApiChatTheme: state.preferences.openApiChatTheme,
|
||||
vanillaChatMcpTools: state.preferences.vanillaChatMcpTools,
|
||||
openApiChatBaseInstructionsEnabled: state.preferences.openApiChatBaseInstructionsEnabled,
|
||||
ttsProvider: state.preferences.ttsProvider,
|
||||
ttsVoice: state.preferences.ttsVoice,
|
||||
ttsSpeed: state.preferences.ttsSpeed,
|
||||
ttsModel: state.preferences.ttsModel,
|
||||
ttsEndpointPreset: state.preferences.ttsEndpointPreset,
|
||||
ttsEndpoint: state.preferences.ttsEndpoint,
|
||||
familiarName: state.preferences.familiarName,
|
||||
},
|
||||
petScaleOptions,
|
||||
openApiChat: getOpenApiChatSettingsSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getI18nSnapshot(): {
|
||||
locale: Locale;
|
||||
localePreference: LocalePreference;
|
||||
availableLocales: { value: Locale; label: string }[];
|
||||
messages: ReturnType<typeof getActiveMessages>;
|
||||
} {
|
||||
return {
|
||||
locale: getActiveLocale(),
|
||||
localePreference: getAppStateSnapshot().preferences.locale,
|
||||
availableLocales: SUPPORTED_LOCALES.map((value) => ({ value, label: LOCALE_LABELS[value] })),
|
||||
messages: getActiveMessages(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardSnapshot(): Promise<{
|
||||
readonly defaultPet: { readonly id: string; readonly displayName: string; readonly previewSpriteUrl: string };
|
||||
readonly installedPetCount: number;
|
||||
readonly catalog: { readonly source: string; readonly total?: number; readonly page?: number; readonly pageCount?: number; readonly error?: string };
|
||||
readonly plugins: { readonly installed: number; readonly enabled: number; readonly broken: number };
|
||||
readonly updateStatus: ReturnType<typeof getUpdateStatus>;
|
||||
readonly activity: ReturnType<typeof getAppStateSnapshot>["analytics"];
|
||||
}> {
|
||||
const state = getAppStateSnapshot();
|
||||
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId && !familiar.broken) ?? state.familiars.installed[0];
|
||||
const preview = await getDefaultPetPreviewSpriteInfo();
|
||||
const catalog = await getCatalogUiState().catch((error: unknown) => ({ source: "error" as const, familiars: [], total: undefined, page: undefined, pageCount: undefined, error: error instanceof Error ? error.message : "Catalog unavailable." }));
|
||||
const pluginSnapshot = await getPluginService().getSnapshot().catch((error: unknown) => {
|
||||
warn("ui", "dashboard plugin snapshot unavailable", { error: error instanceof Error ? error.message : String(error) });
|
||||
return { plugins: [] } as const;
|
||||
});
|
||||
const installedPlugins = pluginSnapshot.plugins.length;
|
||||
const brokenPlugins = pluginSnapshot.plugins.filter((plugin) => Boolean(plugin.brokenReason)).length;
|
||||
const enabledPlugins = pluginSnapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason).length;
|
||||
|
||||
return {
|
||||
defaultPet: {
|
||||
id: defaultPet?.id ?? state.preferences.defaultPetId,
|
||||
displayName: defaultPet?.displayName ?? "FamiliarOS",
|
||||
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
|
||||
},
|
||||
installedPetCount: state.familiars.installed.length,
|
||||
catalog: {
|
||||
source: catalog.source,
|
||||
total: catalog.total,
|
||||
page: catalog.page,
|
||||
pageCount: catalog.pageCount,
|
||||
error: catalog.error,
|
||||
},
|
||||
plugins: {
|
||||
installed: installedPlugins,
|
||||
enabled: enabledPlugins,
|
||||
broken: brokenPlugins,
|
||||
},
|
||||
updateStatus: getUpdateStatus(),
|
||||
activity: state.analytics,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReactionAnimationSettingsSnapshot(): Promise<unknown> {
|
||||
const state = getAppStateSnapshot();
|
||||
const preview = await getDefaultPetPreviewSpriteInfo();
|
||||
return {
|
||||
reactions: reactionAnimationMetadata.map((reaction) => ({
|
||||
...reaction,
|
||||
label: t(`settings.reaction.${reaction.id}.label`),
|
||||
description: t(`settings.reaction.${reaction.id}.description`),
|
||||
})),
|
||||
animations: selectableAnimationMetadata.map((animation) => ({
|
||||
...animation,
|
||||
label: t(`settings.animation.${animation.id}.label`),
|
||||
description: t(`settings.animation.${animation.id}.description`),
|
||||
})),
|
||||
sprite: defaultPetSprite,
|
||||
overrides: state.preferences.reactionAnimationOverrides ?? {},
|
||||
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string; readonly version: string }> {
|
||||
const state = getAppStateSnapshot();
|
||||
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
|
||||
const builtInPath = join(app.getAppPath(), "assets", defaultPetSprite.fileName);
|
||||
const candidatePath = selected && !selected.broken && !selected.builtIn
|
||||
? join(getInstalledPetDir(selected.id), "spritesheet.webp")
|
||||
: builtInPath;
|
||||
try {
|
||||
const spritesheet = await stat(candidatePath);
|
||||
if (spritesheet.isFile() && spritesheet.size > 0 && spritesheet.size <= 100 * 1024 * 1024) {
|
||||
return { path: candidatePath, version: `${selected?.id ?? "builtin"}-${Math.round(spritesheet.mtimeMs)}-${spritesheet.size}` };
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the bundled familiar if an installed default disappears while Settings is open.
|
||||
}
|
||||
const fallback = await stat(builtInPath);
|
||||
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
|
||||
}
|
||||
|
||||
export function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } {
|
||||
if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false };
|
||||
return { supported: true, enabled: app.getLoginItemSettings().openAtLogin };
|
||||
}
|
||||
|
||||
function isLaunchAtLoginSupported(): boolean {
|
||||
return process.platform === "darwin" || process.platform === "win32";
|
||||
}
|
||||
57
apps/desktop/src/default-familiar-controller-bubbles.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { type PetPluginBubbles } from "./familiar-window.js";
|
||||
import {
|
||||
PetBubbleArbiter,
|
||||
type ActiveBubble,
|
||||
type PetBubbleSink,
|
||||
} from "./plugin-bubble-arbiter.js";
|
||||
|
||||
export interface DefaultPetBubbleRuntime {
|
||||
readonly arbiter: PetBubbleArbiter;
|
||||
getPluginBubbles(): PetPluginBubbles | null;
|
||||
handleDismissed(dismissToken: string): boolean;
|
||||
}
|
||||
|
||||
interface CreateDefaultPetBubbleRuntimeOptions {
|
||||
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
|
||||
readonly showDefaultPetForExternalEvent: () => void;
|
||||
readonly refreshDefaultPetContent: () => void;
|
||||
}
|
||||
|
||||
export function createDefaultPetBubbleRuntime(
|
||||
options: CreateDefaultPetBubbleRuntimeOptions,
|
||||
): DefaultPetBubbleRuntime {
|
||||
let pluginTransientBubble: ActiveBubble | null = null;
|
||||
let pluginPinnedBubble: ActiveBubble | null = null;
|
||||
|
||||
const sink: PetBubbleSink = {
|
||||
present(slot, content) {
|
||||
if (slot === "pinned") pluginPinnedBubble = content;
|
||||
else pluginTransientBubble = content;
|
||||
options.debug("plugin bubble slot", {
|
||||
slot,
|
||||
token: content?.token ?? null,
|
||||
pluginId: content?.pluginId ?? null,
|
||||
});
|
||||
if (content) options.showDefaultPetForExternalEvent();
|
||||
options.refreshDefaultPetContent();
|
||||
},
|
||||
};
|
||||
|
||||
const arbiter = new PetBubbleArbiter(sink);
|
||||
|
||||
return {
|
||||
arbiter,
|
||||
getPluginBubbles() {
|
||||
if (!pluginTransientBubble && !pluginPinnedBubble) return null;
|
||||
return {
|
||||
transient: pluginTransientBubble,
|
||||
pinned: pluginPinnedBubble,
|
||||
};
|
||||
},
|
||||
handleDismissed(dismissToken: string): boolean {
|
||||
if (!PetBubbleArbiter.isArbiterToken(dismissToken)) return false;
|
||||
arbiter.handleDismissed(dismissToken);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { BrowserWindow } from "electron";
|
||||
|
||||
import {
|
||||
clearDefaultPetDisplayState,
|
||||
clearDefaultPetStatusBadge,
|
||||
getDefaultPetDismissToken,
|
||||
setDefaultPetStatusBadge,
|
||||
setDefaultPetTransientDisplay,
|
||||
type DefaultPetDisplayState,
|
||||
} from "./default-familiar-controller-display.js";
|
||||
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
import type { PetPluginBubbles, PetStatusBadgeReaction, PetTransientDisplay } from "./familiar-window.js";
|
||||
|
||||
interface DefaultPetBubbleDismissHandler {
|
||||
handleDismissed: (dismissToken: string) => boolean;
|
||||
}
|
||||
|
||||
interface DefaultPetDisplaySupportOptions {
|
||||
readonly displayState: DefaultPetDisplayState;
|
||||
readonly bubbleArbiter: DefaultPetBubbleDismissHandler;
|
||||
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
|
||||
readonly refreshContent: () => void;
|
||||
readonly getWindow: () => BrowserWindow | null;
|
||||
readonly getPaused: () => boolean;
|
||||
readonly getPluginBubbles: () => PetPluginBubbles | null;
|
||||
readonly loadContent: (
|
||||
window: BrowserWindow,
|
||||
paused: boolean,
|
||||
display: PetTransientDisplay | null,
|
||||
badge: PetStatusBadgeReaction | null,
|
||||
dismissToken?: string,
|
||||
pluginBubbles?: PetPluginBubbles | null,
|
||||
) => Promise<void>;
|
||||
readonly updateScalePreference: (scale: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createDefaultPetDisplaySupport(options: DefaultPetDisplaySupportOptions) {
|
||||
function getDefaultPetDisplayDeps() {
|
||||
return {
|
||||
debug: options.debug,
|
||||
refreshContent: options.refreshContent,
|
||||
getWindowForReaction: () => options.getWindow(),
|
||||
};
|
||||
}
|
||||
|
||||
function setTransientDisplay(display: PetTransientDisplay): void {
|
||||
setDefaultPetTransientDisplay(options.displayState, display, getDefaultPetDisplayDeps());
|
||||
}
|
||||
|
||||
function setStatusBadge(reaction: FamiliarOSReaction): void {
|
||||
setDefaultPetStatusBadge(options.displayState, reaction, getDefaultPetDisplayDeps());
|
||||
}
|
||||
|
||||
function clearStatusBadge(): void {
|
||||
clearDefaultPetStatusBadge(options.displayState, getDefaultPetDisplayDeps());
|
||||
}
|
||||
|
||||
function clearDefaultPetDisplayTimers(): void {
|
||||
clearDefaultPetDisplayState(options.displayState);
|
||||
}
|
||||
|
||||
function getCurrentDismissToken(): string | undefined {
|
||||
return getDefaultPetDismissToken(options.displayState);
|
||||
}
|
||||
|
||||
function handleBubbleDismissed(dismissToken: string): void {
|
||||
options.debug("bubble dismissed callback", { windowId: options.getWindow()?.id, dismissToken, currentGeneration: options.displayState.displayGeneration });
|
||||
if (options.bubbleArbiter.handleDismissed(dismissToken)) {
|
||||
return;
|
||||
}
|
||||
if (dismissToken !== String(options.displayState.displayGeneration)) {
|
||||
options.debug("bubble dismissed stale token", { dismissToken, currentGeneration: options.displayState.displayGeneration });
|
||||
return;
|
||||
}
|
||||
clearDefaultPetDisplayTimers();
|
||||
const window = options.getWindow();
|
||||
if (window) {
|
||||
void options.loadContent(window, options.getPaused(), null, null, undefined, options.getPluginBubbles());
|
||||
}
|
||||
}
|
||||
|
||||
function handleScaleChanged(scale: number): void {
|
||||
options.debug("scale changed", { scale });
|
||||
void Promise.resolve(options.updateScalePreference(scale)).catch((error: unknown) => {
|
||||
console.error("Failed to update familiar scale preference.", error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
clearDefaultPetDisplayTimers,
|
||||
clearStatusBadge,
|
||||
getCurrentDismissToken,
|
||||
handleBubbleDismissed,
|
||||
handleScaleChanged,
|
||||
setStatusBadge,
|
||||
setTransientDisplay,
|
||||
};
|
||||
}
|
||||
157
apps/desktop/src/default-familiar-controller-display.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { transientDisplayMs, type FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
import {
|
||||
clearTransientReaction,
|
||||
getTransientDisplayDurationMs,
|
||||
getTransientReactionAnimationMs,
|
||||
mergePetTransientDisplay,
|
||||
setPetReactionState,
|
||||
type PetStatusBadgeReaction,
|
||||
type PetTransientDisplay,
|
||||
} from "./familiar-window.js";
|
||||
import type { FamiliarWindowRendererBridge } from "./familiar-window-renderer-bridge.js";
|
||||
|
||||
const busyStatusBadgeMs = 120_000;
|
||||
|
||||
type DisplayTimer = ReturnType<typeof setTimeout>;
|
||||
type DisplayTimeoutFactory = (callback: () => void, ms: number) => DisplayTimer;
|
||||
type DisplayTimeoutClearer = (timeout: DisplayTimer) => void;
|
||||
|
||||
export interface DefaultPetDisplayState {
|
||||
transientDisplay: PetTransientDisplay | null;
|
||||
statusBadge: PetStatusBadgeReaction | null;
|
||||
transientDisplayTimeout: DisplayTimer | null;
|
||||
transientAnimationTimeout: DisplayTimer | null;
|
||||
statusBadgeTimeout: DisplayTimer | null;
|
||||
displayGeneration: number;
|
||||
}
|
||||
|
||||
interface DefaultPetDisplayDeps {
|
||||
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
|
||||
readonly refreshContent: () => void;
|
||||
readonly scheduleTimeout?: DisplayTimeoutFactory;
|
||||
readonly clearScheduledTimeout?: DisplayTimeoutClearer;
|
||||
}
|
||||
|
||||
interface DefaultPetTransientDisplayDeps extends DefaultPetDisplayDeps {
|
||||
readonly getWindowForReaction: () => FamiliarWindowRendererBridge | null;
|
||||
}
|
||||
|
||||
export function createDefaultPetDisplayState(): DefaultPetDisplayState {
|
||||
return {
|
||||
transientDisplay: null,
|
||||
statusBadge: null,
|
||||
transientDisplayTimeout: null,
|
||||
transientAnimationTimeout: null,
|
||||
statusBadgeTimeout: null,
|
||||
displayGeneration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function setDefaultPetTransientDisplay(
|
||||
state: DefaultPetDisplayState,
|
||||
display: PetTransientDisplay,
|
||||
deps: DefaultPetTransientDisplayDeps,
|
||||
): void {
|
||||
deps.debug("transient display set", {
|
||||
reaction: display.reaction,
|
||||
hasMessage: Boolean(display.message),
|
||||
hasReactionMessage: Boolean(display.reactionMessage),
|
||||
});
|
||||
|
||||
state.displayGeneration += 1;
|
||||
state.transientDisplay = mergePetTransientDisplay(state.transientDisplay, {
|
||||
...display,
|
||||
dismissToken: String(state.displayGeneration),
|
||||
});
|
||||
if (display.reaction) {
|
||||
setDefaultPetStatusBadge(state, display.reaction, deps);
|
||||
}
|
||||
|
||||
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
|
||||
const scheduleTimeout = deps.scheduleTimeout ?? setTimeout;
|
||||
state.transientDisplayTimeout = clearDisplayTimer(state.transientDisplayTimeout, clearScheduledTimeout);
|
||||
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
|
||||
|
||||
const animationMs = getTransientReactionAnimationMs(state.transientDisplay);
|
||||
const displayDurationMs = getTransientDisplayDurationMs(state.transientDisplay);
|
||||
if (animationMs !== null && animationMs < displayDurationMs) {
|
||||
state.transientAnimationTimeout = scheduleTimeout(() => {
|
||||
if (!state.transientDisplay) return;
|
||||
state.transientDisplay = clearTransientReaction(state.transientDisplay);
|
||||
state.transientAnimationTimeout = null;
|
||||
const window = deps.getWindowForReaction();
|
||||
if (window) {
|
||||
setPetReactionState(window, "idle");
|
||||
}
|
||||
}, animationMs);
|
||||
}
|
||||
|
||||
if (!state.transientDisplay.sticky) {
|
||||
state.transientDisplayTimeout = scheduleTimeout(() => {
|
||||
state.transientDisplay = null;
|
||||
state.transientDisplayTimeout = null;
|
||||
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
|
||||
deps.refreshContent();
|
||||
}, displayDurationMs);
|
||||
}
|
||||
|
||||
deps.refreshContent();
|
||||
}
|
||||
|
||||
export function setDefaultPetStatusBadge(
|
||||
state: DefaultPetDisplayState,
|
||||
reaction: FamiliarOSReaction,
|
||||
deps: DefaultPetDisplayDeps,
|
||||
): void {
|
||||
if (reaction === "idle") {
|
||||
clearDefaultPetStatusBadge(state, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
state.statusBadge = reaction;
|
||||
const timeoutMs = isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs;
|
||||
deps.debug("status badge set", { reaction, durationMs: timeoutMs });
|
||||
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
|
||||
const scheduleTimeout = deps.scheduleTimeout ?? setTimeout;
|
||||
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
|
||||
state.statusBadgeTimeout = scheduleTimeout(() => {
|
||||
clearDefaultPetStatusBadge(state, deps);
|
||||
deps.refreshContent();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
export function clearDefaultPetStatusBadge(state: DefaultPetDisplayState, deps: Pick<DefaultPetDisplayDeps, "debug" | "clearScheduledTimeout">): void {
|
||||
if (state.statusBadge) {
|
||||
deps.debug("status badge cleared", { reaction: state.statusBadge });
|
||||
}
|
||||
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
|
||||
state.statusBadge = null;
|
||||
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
|
||||
}
|
||||
|
||||
export function clearDefaultPetDisplayState(
|
||||
state: DefaultPetDisplayState,
|
||||
deps: Pick<DefaultPetDisplayDeps, "clearScheduledTimeout"> = {},
|
||||
): void {
|
||||
const clearScheduledTimeout = deps.clearScheduledTimeout ?? clearTimeout;
|
||||
state.transientDisplayTimeout = clearDisplayTimer(state.transientDisplayTimeout, clearScheduledTimeout);
|
||||
state.transientAnimationTimeout = clearDisplayTimer(state.transientAnimationTimeout, clearScheduledTimeout);
|
||||
state.statusBadgeTimeout = clearDisplayTimer(state.statusBadgeTimeout, clearScheduledTimeout);
|
||||
state.transientDisplay = null;
|
||||
state.statusBadge = null;
|
||||
}
|
||||
|
||||
export function getDefaultPetDismissToken(state: DefaultPetDisplayState): string | undefined {
|
||||
return state.transientDisplay?.dismissToken ?? (state.statusBadge ? String(state.displayGeneration) : undefined);
|
||||
}
|
||||
|
||||
function clearDisplayTimer(timeout: DisplayTimer | null, clearScheduledTimeout: (timeout: DisplayTimer) => void): null {
|
||||
if (timeout) {
|
||||
clearScheduledTimeout(timeout);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBusyStatusBadgeReaction(reaction: FamiliarOSReaction): boolean {
|
||||
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
|
||||
}
|
||||
132
apps/desktop/src/default-familiar-controller-external.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { BrowserWindow } from "electron";
|
||||
|
||||
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
|
||||
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
|
||||
export function applyExternalPetReactionAction(
|
||||
dependencies: {
|
||||
readonly paused: boolean;
|
||||
readonly setTransientDisplay: (display: { readonly reaction: FamiliarOSReaction; readonly suppressReactionMessage?: boolean }) => void;
|
||||
readonly showDefaultPetForExternalEvent: () => void;
|
||||
readonly isDefaultPetVisible: () => boolean;
|
||||
},
|
||||
reaction: FamiliarOSReaction,
|
||||
options: { readonly showMessage?: boolean } = {},
|
||||
): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (dependencies.paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
dependencies.setTransientDisplay({ reaction, ...(options.showMessage === false ? { suppressReactionMessage: true } : {}) });
|
||||
dependencies.showDefaultPetForExternalEvent();
|
||||
return { shown: dependencies.isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyExternalPetSayAction(
|
||||
dependencies: {
|
||||
readonly paused: boolean;
|
||||
readonly clearStatusBadge: () => void;
|
||||
readonly setTransientDisplay: (display: { readonly message: string; readonly reaction?: FamiliarOSReaction }) => void;
|
||||
readonly showDefaultPetForExternalEvent: () => void;
|
||||
readonly isDefaultPetVisible: () => boolean;
|
||||
},
|
||||
message: string,
|
||||
reaction?: FamiliarOSReaction,
|
||||
): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (dependencies.paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
if (!reaction) dependencies.clearStatusBadge();
|
||||
dependencies.setTransientDisplay({ message, reaction });
|
||||
dependencies.showDefaultPetForExternalEvent();
|
||||
return { shown: dependencies.isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyInternalPetMessageAction(
|
||||
dependencies: {
|
||||
readonly paused: boolean;
|
||||
readonly clearStatusBadge: () => void;
|
||||
readonly setTransientDisplay: (display: {
|
||||
readonly message: string;
|
||||
readonly reaction?: FamiliarOSReaction;
|
||||
readonly fullMessage?: boolean;
|
||||
readonly sticky?: boolean;
|
||||
}) => void;
|
||||
readonly showDefaultPet: () => void;
|
||||
readonly isDefaultPetVisible: () => boolean;
|
||||
},
|
||||
message: string,
|
||||
options: {
|
||||
readonly reaction?: FamiliarOSReaction;
|
||||
readonly fullMessage?: boolean;
|
||||
readonly sticky?: boolean;
|
||||
} = {},
|
||||
): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (dependencies.paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
if (!options.reaction) dependencies.clearStatusBadge();
|
||||
dependencies.setTransientDisplay({
|
||||
message,
|
||||
reaction: options.reaction,
|
||||
fullMessage: options.fullMessage,
|
||||
sticky: options.sticky,
|
||||
});
|
||||
dependencies.showDefaultPet();
|
||||
return { shown: dependencies.isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyExternalPetStatusReactionAction(
|
||||
dependencies: {
|
||||
readonly clearStatusBadge: () => void;
|
||||
readonly setStatusBadge: (reaction: FamiliarOSReaction) => void;
|
||||
readonly refreshDefaultPetContent: () => void;
|
||||
},
|
||||
reaction: FamiliarOSReaction | null,
|
||||
): void {
|
||||
if (reaction === null || reaction === "idle") dependencies.clearStatusBadge();
|
||||
else dependencies.setStatusBadge(reaction);
|
||||
dependencies.refreshDefaultPetContent();
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveByAction(
|
||||
options: { readonly x: number; readonly y: number; readonly durationMs?: number },
|
||||
moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>,
|
||||
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
return moveDefaultPetBy(Number(options.x), Number(options.y), options.durationMs);
|
||||
}
|
||||
|
||||
export function applyExternalPetWanderAction(
|
||||
options: { readonly distance?: number; readonly durationMs?: number },
|
||||
maxPluginMoveDistance: number,
|
||||
moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>,
|
||||
random: () => number = Math.random,
|
||||
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
const requestedDistance = Number(options.distance ?? 80);
|
||||
const distance = Number.isFinite(requestedDistance) ? Math.min(Math.max(requestedDistance, 0), maxPluginMoveDistance) : 0;
|
||||
const angle = random() * Math.PI * 2;
|
||||
return moveDefaultPetBy(Math.cos(angle) * distance, Math.sin(angle) * distance, options.durationMs);
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveToHomeAction(
|
||||
dependencies: {
|
||||
readonly getWindow: () => BrowserWindow | null;
|
||||
readonly readWindowPosition: (window: BrowserWindow) => { readonly x: number; readonly y: number };
|
||||
readonly getSafeDefaultPetPosition: (position: { readonly x: number; readonly y: number }) => { readonly x: number; readonly y: number };
|
||||
readonly moveDefaultPetBy: (x: number, y: number, durationMs: unknown, maxDistance?: number) => Promise<{ readonly moved: boolean; readonly reason?: string }>;
|
||||
readonly maxPluginMoveDurationMs: number;
|
||||
},
|
||||
): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
const window = dependencies.getWindow();
|
||||
if (!window || window.isDestroyed()) return Promise.resolve({ moved: false, reason: "no-window" });
|
||||
const current = dependencies.readWindowPosition(window);
|
||||
const home = dependencies.getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
|
||||
return dependencies.moveDefaultPetBy(
|
||||
home.x - current.x,
|
||||
home.y - current.y,
|
||||
dependencies.maxPluginMoveDurationMs,
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
}
|
||||
87
apps/desktop/src/default-familiar-controller-motion.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { BrowserWindow } from "electron";
|
||||
|
||||
export type PetPosition = { readonly x: number; readonly y: number };
|
||||
type MoveResult = { readonly moved: boolean; readonly reason?: string };
|
||||
|
||||
export interface DefaultPetMotionWindow extends Pick<BrowserWindow, "id" | "isDestroyed" | "isVisible" | "setPosition"> {}
|
||||
|
||||
export interface DefaultPetMotionBlockOptions<WindowType extends DefaultPetMotionWindow = DefaultPetMotionWindow> {
|
||||
readonly window: WindowType;
|
||||
readonly allowMoving?: boolean;
|
||||
readonly movementInProgress: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly hasTransientDisplay: boolean;
|
||||
readonly hasStatusBadge: boolean;
|
||||
readonly isPetWindowDragging: (window: WindowType) => boolean;
|
||||
}
|
||||
|
||||
export interface MoveDefaultPetWindowOptions<WindowType extends DefaultPetMotionWindow = DefaultPetMotionWindow> extends DefaultPetMotionBlockOptions<WindowType> {
|
||||
readonly rawX: number;
|
||||
readonly rawY: number;
|
||||
readonly rawDurationMs: unknown;
|
||||
readonly maxDistance: number;
|
||||
readonly minDurationMs: number;
|
||||
readonly maxDurationMs: number;
|
||||
readonly readWindowPosition: (window: WindowType) => PetPosition;
|
||||
readonly getSafeDefaultPetPosition: (position: PetPosition) => PetPosition;
|
||||
readonly persistDefaultPetPosition: (position: PetPosition) => void;
|
||||
readonly setMovementInProgress: (value: boolean) => void;
|
||||
readonly debug: (message: string, fields?: Record<string, unknown>) => void;
|
||||
readonly delayMs?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function getPetMovementBlockedReason<WindowType extends DefaultPetMotionWindow>(options: DefaultPetMotionBlockOptions<WindowType>): string | undefined {
|
||||
if (options.movementInProgress && !options.allowMoving) return "already-moving";
|
||||
if (!options.window.isVisible()) return "hidden";
|
||||
if (options.paused) return "paused";
|
||||
if (options.isPetWindowDragging(options.window)) return "dragging";
|
||||
if (options.hasTransientDisplay) return "transient-display";
|
||||
if (options.hasStatusBadge) return "status-active";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function moveDefaultPetWindowBy<WindowType extends DefaultPetMotionWindow>(options: MoveDefaultPetWindowOptions<WindowType>): Promise<MoveResult> {
|
||||
const blockedReason = getPetMovementBlockedReason(options);
|
||||
if (blockedReason) {
|
||||
options.debug("move skipped", { reason: blockedReason });
|
||||
return { moved: false, reason: blockedReason };
|
||||
}
|
||||
|
||||
const current = options.readWindowPosition(options.window);
|
||||
const distance = Math.min(Math.hypot(options.rawX, options.rawY), options.maxDistance);
|
||||
if (!Number.isFinite(distance) || distance <= 0) return { moved: false, reason: "invalid-distance" };
|
||||
const scale = distance / Math.hypot(options.rawX, options.rawY);
|
||||
const target = options.getSafeDefaultPetPosition({ x: current.x + options.rawX * scale, y: current.y + options.rawY * scale });
|
||||
const durationMs = clampNumber(Number(options.rawDurationMs ?? 700), options.minDurationMs, options.maxDurationMs);
|
||||
const steps = Math.max(8, Math.min(16, Math.round(durationMs / 100)));
|
||||
const delayMs = options.delayMs ?? delay;
|
||||
|
||||
options.setMovementInProgress(true);
|
||||
options.debug("move start", { windowId: options.window.id, from: current, target, durationMs, steps });
|
||||
try {
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
if (options.window.isDestroyed()) return { moved: false, reason: "destroyed" };
|
||||
const blocked = getPetMovementBlockedReason({ ...options, allowMoving: true });
|
||||
if (blocked) return { moved: false, reason: blocked };
|
||||
const t = step / steps;
|
||||
options.window.setPosition(Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false);
|
||||
await delayMs(durationMs / steps);
|
||||
}
|
||||
|
||||
options.window.setPosition(target.x, target.y, false);
|
||||
options.persistDefaultPetPosition(target);
|
||||
options.debug("move finished", { windowId: options.window.id, target });
|
||||
return { moved: true };
|
||||
} finally {
|
||||
options.setMovementInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
154
apps/desktop/src/default-familiar-controller-window.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { powerMonitor, screen, type BrowserWindow } from "electron";
|
||||
|
||||
import { resetDefaultPetPosition, setDefaultPetPosition } from "./app-state.js";
|
||||
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
|
||||
import {
|
||||
getSafeDefaultPetPosition,
|
||||
loadDefaultPetContent,
|
||||
readWindowPosition,
|
||||
recoverPetMouseInterop,
|
||||
type PetPluginBubbles,
|
||||
type PetStatusBadgeReaction,
|
||||
type PetTransientDisplay,
|
||||
} from "./familiar-window.js";
|
||||
import { debug, info } from "./logger.js";
|
||||
|
||||
export interface DefaultPetWindowContentSnapshot {
|
||||
readonly paused: boolean;
|
||||
readonly display: PetTransientDisplay | null;
|
||||
readonly badge: PetStatusBadgeReaction | null;
|
||||
readonly dismissToken?: string;
|
||||
readonly pluginBubbles: PetPluginBubbles | null;
|
||||
}
|
||||
|
||||
export function loadDefaultPetWindowContent(
|
||||
window: BrowserWindow,
|
||||
snapshot: DefaultPetWindowContentSnapshot,
|
||||
): void {
|
||||
void loadDefaultPetContent(
|
||||
window,
|
||||
snapshot.paused,
|
||||
snapshot.display,
|
||||
snapshot.badge,
|
||||
snapshot.dismissToken,
|
||||
snapshot.pluginBubbles,
|
||||
);
|
||||
}
|
||||
|
||||
export function hideManagedDefaultPetWindow(
|
||||
window: BrowserWindow | null,
|
||||
petId: string | undefined,
|
||||
): void {
|
||||
if (!window || window.isDestroyed()) {
|
||||
debug("familiar.default", "hide skipped", { reason: "no-window" });
|
||||
return;
|
||||
}
|
||||
|
||||
info("familiar.default", "hide requested", {
|
||||
windowId: window.id,
|
||||
position: readWindowPosition(window),
|
||||
petId,
|
||||
});
|
||||
setDefaultPetPosition(readWindowPosition(window));
|
||||
window.hide();
|
||||
}
|
||||
|
||||
export function recoverManagedDefaultPetMouseInterop(
|
||||
window: BrowserWindow | null,
|
||||
reason: string,
|
||||
petId: string | undefined,
|
||||
): void {
|
||||
if (!window || window.isDestroyed()) {
|
||||
debug("familiar.default", "mouse interop recovery skipped", {
|
||||
reason,
|
||||
skippedReason: "no-window",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debug("familiar.default", "mouse interop recovery requested", {
|
||||
windowId: window.id,
|
||||
reason,
|
||||
petId,
|
||||
});
|
||||
recoverPetMouseInterop(window, reason);
|
||||
}
|
||||
|
||||
export function destroyManagedDefaultPetWindow(
|
||||
window: BrowserWindow | null,
|
||||
petId: string | undefined,
|
||||
clearDisplayTimers: () => void,
|
||||
): BrowserWindow | null {
|
||||
clearDisplayTimers();
|
||||
|
||||
if (!window || window.isDestroyed()) {
|
||||
debug("familiar.default", "destroy skipped", { reason: "no-window" });
|
||||
return null;
|
||||
}
|
||||
|
||||
info("familiar.default", "destroy requested", {
|
||||
windowId: window.id,
|
||||
position: readWindowPosition(window),
|
||||
petId,
|
||||
});
|
||||
setDefaultPetPosition(readWindowPosition(window));
|
||||
window.setIgnoreMouseEvents(false);
|
||||
window.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function installDefaultPetWindowDisplayHandlers(
|
||||
reclampWindow: () => void,
|
||||
recoverAfterResume: () => void,
|
||||
): void {
|
||||
screen.on("display-added", reclampWindow);
|
||||
screen.on("display-removed", reclampWindow);
|
||||
screen.on("display-metrics-changed", reclampWindow);
|
||||
powerMonitor.on("resume", recoverAfterResume);
|
||||
}
|
||||
|
||||
export function getManagedDefaultPetWindowBounds(
|
||||
window: BrowserWindow | null,
|
||||
): Electron.Rectangle | null {
|
||||
if (!window || window.isDestroyed()) return null;
|
||||
return window.getBounds();
|
||||
}
|
||||
|
||||
export function reclampManagedDefaultPetWindow(
|
||||
window: BrowserWindow | null,
|
||||
recoverMouseInterop: (reason: string) => void,
|
||||
): void {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safePosition = readWindowPosition(window);
|
||||
info("familiar.default", "reclamp position", {
|
||||
windowId: window.id,
|
||||
position: safePosition,
|
||||
});
|
||||
window.setPosition(safePosition.x, safePosition.y, false);
|
||||
setDefaultPetPosition(safePosition);
|
||||
recoverMouseInterop("display-change");
|
||||
}
|
||||
|
||||
export function recoverManagedDefaultPetWindowAfterResume(
|
||||
recoverMouseInterop: (reason: string) => void,
|
||||
scheduleTimeout: typeof setTimeout = setTimeout,
|
||||
): void {
|
||||
recoverMouseInterop("power-resume");
|
||||
scheduleTimeout(() => recoverMouseInterop("power-resume+500ms"), 500).unref?.();
|
||||
}
|
||||
|
||||
export function resetManagedDefaultPetWindowToInitialPosition(
|
||||
window: BrowserWindow | null,
|
||||
): void {
|
||||
const safePosition = getSafeDefaultPetPosition(
|
||||
getDefaultPetInitialPosition(defaultPetWindowSize),
|
||||
);
|
||||
resetDefaultPetPosition(safePosition);
|
||||
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.setPosition(safePosition.x, safePosition.y, false);
|
||||
}
|
||||
}
|
||||
336
apps/desktop/src/default-familiar-controller.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { BrowserWindow } from "electron";
|
||||
|
||||
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
|
||||
import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js";
|
||||
import {
|
||||
createDefaultPetDisplayState,
|
||||
} from "./default-familiar-controller-display.js";
|
||||
import { createDefaultPetDisplaySupport } from "./default-familiar-controller-display-support.js";
|
||||
import {
|
||||
applyExternalPetMoveByAction,
|
||||
applyExternalPetMoveToHomeAction,
|
||||
applyExternalPetReactionAction,
|
||||
applyExternalPetSayAction,
|
||||
applyExternalPetStatusReactionAction,
|
||||
applyExternalPetWanderAction,
|
||||
applyInternalPetMessageAction,
|
||||
} from "./default-familiar-controller-external.js";
|
||||
import { getPetMovementBlockedReason, moveDefaultPetWindowBy } from "./default-familiar-controller-motion.js";
|
||||
import { createDefaultPetBubbleRuntime } from "./default-familiar-controller-bubbles.js";
|
||||
import {
|
||||
destroyManagedDefaultPetWindow,
|
||||
getManagedDefaultPetWindowBounds,
|
||||
hideManagedDefaultPetWindow,
|
||||
installDefaultPetWindowDisplayHandlers,
|
||||
loadDefaultPetWindowContent,
|
||||
reclampManagedDefaultPetWindow,
|
||||
recoverManagedDefaultPetMouseInterop,
|
||||
recoverManagedDefaultPetWindowAfterResume,
|
||||
resetManagedDefaultPetWindowToInitialPosition,
|
||||
} from "./default-familiar-controller-window.js";
|
||||
import { debug, info } from "./logger.js";
|
||||
import { type FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
import { createDefaultPetWindow, getSafeDefaultPetPosition, isPetWindowDragging, loadDefaultPetContent, readWindowPosition, type PetPluginBubbles, type PetTransientDisplay } from "./familiar-window.js";
|
||||
import { publishPluginPetEvent } from "./plugin-events-source.js";
|
||||
|
||||
let defaultPetWindow: BrowserWindow | null = null;
|
||||
let paused = false;
|
||||
const displayState = createDefaultPetDisplayState();
|
||||
const maxPluginMoveDistance = 160;
|
||||
const minPluginMoveDurationMs = 250;
|
||||
const maxPluginMoveDurationMs = 1_500;
|
||||
let movementInProgress = false;
|
||||
let promptRequestHandler: (() => void) | undefined;
|
||||
|
||||
export type PetMoveOptions = { readonly x: number; readonly y: number; readonly durationMs?: number };
|
||||
export type PetWanderOptions = { readonly distance?: number; readonly durationMs?: number };
|
||||
export type InternalPetMessageOptions = {
|
||||
readonly reaction?: FamiliarOSReaction;
|
||||
readonly fullMessage?: boolean;
|
||||
readonly sticky?: boolean;
|
||||
};
|
||||
|
||||
export type PetReactionOptions = { readonly showMessage?: boolean };
|
||||
const defaultPetBubbleRuntime = createDefaultPetBubbleRuntime({
|
||||
debug: (message, fields) => debug("familiar.default", message, fields),
|
||||
showDefaultPetForExternalEvent: () => showDefaultPetForExternalEvent(),
|
||||
refreshDefaultPetContent: () => refreshDefaultPetContent(),
|
||||
});
|
||||
const defaultPetDisplaySupport = createDefaultPetDisplaySupport({
|
||||
displayState,
|
||||
bubbleArbiter: defaultPetBubbleRuntime,
|
||||
debug: (message, fields) => debug("familiar.default", message, fields),
|
||||
refreshContent: () => refreshDefaultPetContent(),
|
||||
getWindow: () => (defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null),
|
||||
getPaused: () => paused,
|
||||
getPluginBubbles: () => getDefaultPetPluginBubbles(),
|
||||
loadContent: (window, pausedState, display, badge, dismissToken, pluginBubbles) => loadDefaultPetContent(window, pausedState, display, badge, dismissToken, pluginBubbles),
|
||||
updateScalePreference: (scale) => import("./app-state.js").then(({ updatePreferences }) => {
|
||||
updatePreferences({ petScale: scale });
|
||||
}),
|
||||
});
|
||||
const {
|
||||
clearDefaultPetDisplayTimers,
|
||||
clearStatusBadge,
|
||||
getCurrentDismissToken,
|
||||
handleBubbleDismissed,
|
||||
handleScaleChanged,
|
||||
setStatusBadge,
|
||||
setTransientDisplay,
|
||||
} = defaultPetDisplaySupport;
|
||||
|
||||
/** The default familiar's bubble arbiter — the Electron bubbles capability targets this. */
|
||||
export const defaultPetBubbleArbiter = defaultPetBubbleRuntime.arbiter;
|
||||
|
||||
export function getDefaultPetPluginBubbles(): PetPluginBubbles | null {
|
||||
return defaultPetBubbleRuntime.getPluginBubbles();
|
||||
}
|
||||
|
||||
export function showDefaultPet(): void {
|
||||
updatePreferences({ openDefaultPetOnLaunch: true });
|
||||
showDefaultPetWindow("user");
|
||||
}
|
||||
|
||||
function showDefaultPetWindow(source: "user" | "external-event"): void {
|
||||
const window = getOrCreateDefaultPetWindow();
|
||||
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();
|
||||
}
|
||||
|
||||
window.showInactive();
|
||||
}
|
||||
|
||||
export function hideDefaultPet(): void {
|
||||
updatePreferences({ openDefaultPetOnLaunch: false });
|
||||
hideManagedDefaultPetWindow(
|
||||
defaultPetWindow,
|
||||
getAppStateSnapshot().preferences.defaultPetId,
|
||||
);
|
||||
}
|
||||
|
||||
export function isDefaultPetVisible(): boolean {
|
||||
return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible());
|
||||
}
|
||||
|
||||
export function setDefaultPetPaused(nextPaused: boolean): void {
|
||||
paused = nextPaused;
|
||||
info("familiar.default", "pause changed", { paused });
|
||||
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadDefaultPetWindowContent(defaultPetWindow, {
|
||||
paused,
|
||||
display: displayState.transientDisplay,
|
||||
badge: displayState.statusBadge,
|
||||
dismissToken: getCurrentDismissToken(),
|
||||
pluginBubbles: getDefaultPetPluginBubbles(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDefaultPetPaused(): boolean {
|
||||
return paused;
|
||||
}
|
||||
|
||||
export function getDefaultPetWindowForPlugins(): BrowserWindow | null {
|
||||
return defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null;
|
||||
}
|
||||
|
||||
export function refreshDefaultPetContent(): void {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
debug("familiar.default", "refresh skipped", { reason: "no-window" });
|
||||
return;
|
||||
}
|
||||
|
||||
debug("familiar.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(displayState.transientDisplay), badge: displayState.statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
loadDefaultPetWindowContent(defaultPetWindow, {
|
||||
paused,
|
||||
display: displayState.transientDisplay,
|
||||
badge: displayState.statusBadge,
|
||||
dismissToken: getCurrentDismissToken(),
|
||||
pluginBubbles: getDefaultPetPluginBubbles(),
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverDefaultPetMouseInterop(reason: string): void {
|
||||
recoverManagedDefaultPetMouseInterop(
|
||||
defaultPetWindow,
|
||||
reason,
|
||||
getAppStateSnapshot().preferences.defaultPetId,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyExternalPetReaction(reaction: FamiliarOSReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } {
|
||||
return applyExternalPetReactionAction({
|
||||
paused,
|
||||
setTransientDisplay,
|
||||
showDefaultPetForExternalEvent,
|
||||
isDefaultPetVisible,
|
||||
}, reaction, options);
|
||||
}
|
||||
|
||||
export function applyExternalPetSay(message: string, reaction?: FamiliarOSReaction): { readonly shown: boolean; readonly reason?: string } {
|
||||
return applyExternalPetSayAction({
|
||||
paused,
|
||||
clearStatusBadge,
|
||||
setTransientDisplay,
|
||||
showDefaultPetForExternalEvent,
|
||||
isDefaultPetVisible,
|
||||
}, message, reaction);
|
||||
}
|
||||
|
||||
export function applyInternalPetMessage(message: string, options: InternalPetMessageOptions = {}): { readonly shown: boolean; readonly reason?: string } {
|
||||
return applyInternalPetMessageAction({
|
||||
paused,
|
||||
clearStatusBadge,
|
||||
setTransientDisplay,
|
||||
showDefaultPet,
|
||||
isDefaultPetVisible,
|
||||
}, message, options);
|
||||
}
|
||||
|
||||
export function applyExternalPetStatusReaction(reaction: FamiliarOSReaction | null): void {
|
||||
applyExternalPetStatusReactionAction({
|
||||
clearStatusBadge,
|
||||
setStatusBadge,
|
||||
refreshDefaultPetContent,
|
||||
}, reaction);
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveBy(options: PetMoveOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
return applyExternalPetMoveByAction(options, moveDefaultPetBy);
|
||||
}
|
||||
|
||||
export function applyExternalPetWander(options: PetWanderOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
return applyExternalPetWanderAction(options, maxPluginMoveDistance, moveDefaultPetBy);
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveToHome(): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
return applyExternalPetMoveToHomeAction({
|
||||
getWindow: () => defaultPetWindow,
|
||||
readWindowPosition,
|
||||
getSafeDefaultPetPosition,
|
||||
moveDefaultPetBy,
|
||||
maxPluginMoveDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyDefaultPet(): void {
|
||||
defaultPetWindow = destroyManagedDefaultPetWindow(
|
||||
defaultPetWindow,
|
||||
getAppStateSnapshot().preferences.defaultPetId,
|
||||
clearDefaultPetDisplayTimers,
|
||||
);
|
||||
}
|
||||
|
||||
export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void {
|
||||
promptRequestHandler = options.onPromptRequested;
|
||||
installDefaultPetWindowDisplayHandlers(
|
||||
reclampDefaultPetWindow,
|
||||
recoverDefaultPetWindowAfterResume,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultPetWindowBounds(): Electron.Rectangle | null {
|
||||
return getManagedDefaultPetWindowBounds(defaultPetWindow);
|
||||
}
|
||||
|
||||
function getOrCreateDefaultPetWindow(): BrowserWindow {
|
||||
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
|
||||
return defaultPetWindow;
|
||||
}
|
||||
|
||||
const position = getSafeDefaultPetPosition(getDefaultPetPosition());
|
||||
|
||||
defaultPetWindow = createDefaultPetWindow({
|
||||
position,
|
||||
paused,
|
||||
display: displayState.transientDisplay,
|
||||
badge: displayState.statusBadge,
|
||||
pluginBubbles: getDefaultPetPluginBubbles(),
|
||||
onPositionChanged: setDefaultPetPosition,
|
||||
onHideRequested: hideDefaultPet,
|
||||
onBubbleDismissed: handleBubbleDismissed,
|
||||
onPromptRequested: () => promptRequestHandler?.(),
|
||||
onScaleChanged: handleScaleChanged,
|
||||
onBubbleAction: (token, actionId) => defaultPetBubbleArbiter.handleAction(token, actionId),
|
||||
onBubbleSubmit: (token, values) => defaultPetBubbleArbiter.handleSubmit(token, values),
|
||||
onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload),
|
||||
}, getCurrentDismissToken());
|
||||
const windowId = defaultPetWindow.id;
|
||||
info("familiar.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
|
||||
defaultPetWindow.on("closed", () => {
|
||||
info("familiar.default", "closed", { windowId });
|
||||
defaultPetWindow = null;
|
||||
});
|
||||
|
||||
return defaultPetWindow;
|
||||
}
|
||||
|
||||
function showDefaultPetForExternalEvent(): void {
|
||||
const state = getAppStateSnapshot();
|
||||
const visible = isDefaultPetVisible();
|
||||
if (!shouldShowDefaultPetForExternalEvent(visible, state.preferences.openDefaultPetOnLaunch, paused)) {
|
||||
debug("familiar.default", "external show skipped", { reason: "paused", visible, openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch });
|
||||
return;
|
||||
}
|
||||
|
||||
showDefaultPetWindow("external-event");
|
||||
}
|
||||
|
||||
async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unknown, maxDistance = maxPluginMoveDistance): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return { moved: false, reason: "no-window" };
|
||||
return moveDefaultPetWindowBy({
|
||||
window: defaultPetWindow,
|
||||
rawX,
|
||||
rawY,
|
||||
rawDurationMs,
|
||||
maxDistance,
|
||||
minDurationMs: minPluginMoveDurationMs,
|
||||
maxDurationMs: maxPluginMoveDurationMs,
|
||||
movementInProgress,
|
||||
paused,
|
||||
hasTransientDisplay: Boolean(displayState.transientDisplay),
|
||||
hasStatusBadge: Boolean(displayState.statusBadge),
|
||||
readWindowPosition,
|
||||
getSafeDefaultPetPosition,
|
||||
persistDefaultPetPosition: setDefaultPetPosition,
|
||||
isPetWindowDragging,
|
||||
setMovementInProgress: (value) => {
|
||||
movementInProgress = value;
|
||||
},
|
||||
debug: (message, fields) => debug("familiar.default", message, fields),
|
||||
});
|
||||
}
|
||||
|
||||
function getMovementBlockedReason(window: BrowserWindow, allowMoving = false): string | undefined {
|
||||
return getPetMovementBlockedReason({
|
||||
window,
|
||||
allowMoving,
|
||||
movementInProgress,
|
||||
paused,
|
||||
hasTransientDisplay: Boolean(displayState.transientDisplay),
|
||||
hasStatusBadge: Boolean(displayState.statusBadge),
|
||||
isPetWindowDragging,
|
||||
});
|
||||
}
|
||||
|
||||
function reclampDefaultPetWindow(): void {
|
||||
reclampManagedDefaultPetWindow(defaultPetWindow, recoverDefaultPetMouseInterop);
|
||||
}
|
||||
|
||||
function recoverDefaultPetWindowAfterResume(): void {
|
||||
recoverManagedDefaultPetWindowAfterResume(recoverDefaultPetMouseInterop);
|
||||
}
|
||||
|
||||
export function shouldOpenDefaultPetOnLaunch(): boolean {
|
||||
return getAppStateSnapshot().preferences.openDefaultPetOnLaunch;
|
||||
}
|
||||
|
||||
export function resetDefaultPetToInitialPosition(): void {
|
||||
resetManagedDefaultPetWindowToInitialPosition(defaultPetWindow);
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
import { BrowserWindow, powerMonitor, screen } from "electron";
|
||||
|
||||
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
|
||||
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 { PetBubbleArbiter, type ActiveBubble, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
|
||||
import { publishPluginPetEvent } from "./plugin-events-source.js";
|
||||
|
||||
let defaultPetWindow: BrowserWindow | null = null;
|
||||
let paused = false;
|
||||
let transientDisplay: PetTransientDisplay | null = null;
|
||||
let statusBadge: PetStatusBadgeReaction | null = null;
|
||||
let transientDisplayTimeout: NodeJS.Timeout | null = null;
|
||||
let transientAnimationTimeout: NodeJS.Timeout | null = null;
|
||||
let statusBadgeTimeout: NodeJS.Timeout | null = null;
|
||||
let displayGeneration = 0;
|
||||
const busyStatusBadgeMs = 120_000;
|
||||
const maxPluginMoveDistance = 160;
|
||||
const minPluginMoveDurationMs = 250;
|
||||
const maxPluginMoveDurationMs = 1_500;
|
||||
let movementInProgress = false;
|
||||
let promptRequestHandler: (() => void) | undefined;
|
||||
|
||||
export type PetMoveOptions = { readonly x: number; readonly y: number; readonly durationMs?: number };
|
||||
export type PetWanderOptions = { readonly distance?: number; readonly durationMs?: number };
|
||||
export type InternalPetMessageOptions = {
|
||||
readonly reaction?: OpenPetsReaction;
|
||||
readonly fullMessage?: boolean;
|
||||
readonly sticky?: boolean;
|
||||
};
|
||||
|
||||
export 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.
|
||||
let pluginTransientBubble: ActiveBubble | null = null;
|
||||
let pluginPinnedBubble: ActiveBubble | null = null;
|
||||
|
||||
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 });
|
||||
if (content) showDefaultPetForExternalEvent();
|
||||
refreshDefaultPetContent();
|
||||
},
|
||||
};
|
||||
|
||||
/** The default pet's bubble arbiter — the Electron bubbles capability targets this. */
|
||||
export const defaultPetBubbleArbiter = new PetBubbleArbiter(defaultPetBubbleSink);
|
||||
|
||||
export function getDefaultPetPluginBubbles(): PetPluginBubbles | null {
|
||||
if (!pluginTransientBubble && !pluginPinnedBubble) return null;
|
||||
return { transient: pluginTransientBubble, pinned: pluginPinnedBubble };
|
||||
}
|
||||
|
||||
export function showDefaultPet(): void {
|
||||
updatePreferences({ openDefaultPetOnLaunch: true });
|
||||
showDefaultPetWindow("user");
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
|
||||
window.showInactive();
|
||||
}
|
||||
|
||||
export function hideDefaultPet(): void {
|
||||
updatePreferences({ openDefaultPetOnLaunch: false });
|
||||
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
debug("pet.default", "hide skipped", { reason: "no-window" });
|
||||
return;
|
||||
}
|
||||
|
||||
info("pet.default", "hide requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
|
||||
defaultPetWindow.hide();
|
||||
}
|
||||
|
||||
export function isDefaultPetVisible(): boolean {
|
||||
return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible());
|
||||
}
|
||||
|
||||
export function setDefaultPetPaused(nextPaused: boolean): void {
|
||||
paused = nextPaused;
|
||||
info("pet.default", "pause changed", { paused });
|
||||
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles());
|
||||
}
|
||||
|
||||
export function getDefaultPetPaused(): boolean {
|
||||
return paused;
|
||||
}
|
||||
|
||||
export function getDefaultPetWindowForPlugins(): BrowserWindow | null {
|
||||
return defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null;
|
||||
}
|
||||
|
||||
export function refreshDefaultPetContent(): void {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
debug("pet.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 });
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
|
||||
debug("pet.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 } {
|
||||
if (paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
setTransientDisplay({ reaction, ...(options.showMessage === false ? { suppressReactionMessage: true } : {}) });
|
||||
showDefaultPetForExternalEvent();
|
||||
return { shown: isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
if (!reaction) clearStatusBadge();
|
||||
setTransientDisplay({ message, reaction });
|
||||
showDefaultPetForExternalEvent();
|
||||
return { shown: isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyInternalPetMessage(message: string, options: InternalPetMessageOptions = {}): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (paused) {
|
||||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
if (!options.reaction) clearStatusBadge();
|
||||
setTransientDisplay({
|
||||
message,
|
||||
reaction: options.reaction,
|
||||
fullMessage: options.fullMessage,
|
||||
sticky: options.sticky,
|
||||
});
|
||||
showDefaultPet();
|
||||
return { shown: isDefaultPetVisible() };
|
||||
}
|
||||
|
||||
export function applyExternalPetStatusReaction(reaction: OpenPetsReaction | null): void {
|
||||
if (reaction === null || reaction === "idle") clearStatusBadge();
|
||||
else setStatusBadge(reaction);
|
||||
refreshDefaultPetContent();
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveBy(options: PetMoveOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
return moveDefaultPetBy(Number(options.x), Number(options.y), options.durationMs);
|
||||
}
|
||||
|
||||
export function applyExternalPetWander(options: PetWanderOptions): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
const distance = clampNumber(Number(options.distance ?? 80), 0, maxPluginMoveDistance);
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
return moveDefaultPetBy(Math.cos(angle) * distance, Math.sin(angle) * distance, options.durationMs);
|
||||
}
|
||||
|
||||
export function applyExternalPetMoveToHome(): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return Promise.resolve({ moved: false, reason: "no-window" });
|
||||
const current = readWindowPosition(defaultPetWindow);
|
||||
const home = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
|
||||
return moveDefaultPetBy(home.x - current.x, home.y - current.y, maxPluginMoveDurationMs, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
export function destroyDefaultPet(): void {
|
||||
clearDefaultPetDisplayTimers();
|
||||
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
debug("pet.default", "destroy skipped", { reason: "no-window" });
|
||||
defaultPetWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
info("pet.default", "destroy requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
setDefaultPetPosition(readWindowPosition(defaultPetWindow));
|
||||
const window = defaultPetWindow;
|
||||
defaultPetWindow = null;
|
||||
window.setIgnoreMouseEvents(false);
|
||||
window.destroy();
|
||||
}
|
||||
|
||||
export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void {
|
||||
promptRequestHandler = options.onPromptRequested;
|
||||
screen.on("display-added", reclampDefaultPetWindow);
|
||||
screen.on("display-removed", reclampDefaultPetWindow);
|
||||
screen.on("display-metrics-changed", reclampDefaultPetWindow);
|
||||
powerMonitor.on("resume", recoverDefaultPetWindowAfterResume);
|
||||
}
|
||||
|
||||
export function getDefaultPetWindowBounds(): Electron.Rectangle | null {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return null;
|
||||
return defaultPetWindow.getBounds();
|
||||
}
|
||||
|
||||
function handleBubbleDismissed(dismissToken: string): void {
|
||||
debug("pet.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayGeneration });
|
||||
if (PetBubbleArbiter.isArbiterToken(dismissToken)) {
|
||||
defaultPetBubbleArbiter.handleDismissed(dismissToken);
|
||||
return;
|
||||
}
|
||||
if (dismissToken !== String(displayGeneration)) {
|
||||
debug("pet.default", "bubble dismissed stale token", { dismissToken, currentGeneration: displayGeneration });
|
||||
return;
|
||||
}
|
||||
clearDefaultPetDisplayTimers();
|
||||
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, null, null, undefined, getDefaultPetPluginBubbles());
|
||||
}
|
||||
}
|
||||
|
||||
function handleScaleChanged(scale: number): void {
|
||||
debug("pet.default", "scale changed", { scale });
|
||||
import("./app-state.js").then(({ updatePreferences }) => {
|
||||
updatePreferences({ petScale: scale });
|
||||
}).catch((error) => {
|
||||
console.error("Failed to update pet scale preference.", error);
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreateDefaultPetWindow(): BrowserWindow {
|
||||
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
|
||||
return defaultPetWindow;
|
||||
}
|
||||
|
||||
const position = getSafeDefaultPetPosition(getDefaultPetPosition());
|
||||
|
||||
defaultPetWindow = createDefaultPetWindow({
|
||||
position,
|
||||
paused,
|
||||
display: transientDisplay,
|
||||
badge: statusBadge,
|
||||
pluginBubbles: getDefaultPetPluginBubbles(),
|
||||
onPositionChanged: setDefaultPetPosition,
|
||||
onHideRequested: hideDefaultPet,
|
||||
onBubbleDismissed: handleBubbleDismissed,
|
||||
onPromptRequested: () => promptRequestHandler?.(),
|
||||
onScaleChanged: handleScaleChanged,
|
||||
onBubbleAction: (token, actionId) => defaultPetBubbleArbiter.handleAction(token, actionId),
|
||||
onBubbleSubmit: (token, values) => defaultPetBubbleArbiter.handleSubmit(token, values),
|
||||
onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload),
|
||||
}, getCurrentDismissToken());
|
||||
const windowId = defaultPetWindow.id;
|
||||
info("pet.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
|
||||
defaultPetWindow.on("closed", () => {
|
||||
info("pet.default", "closed", { windowId });
|
||||
defaultPetWindow = null;
|
||||
});
|
||||
|
||||
return defaultPetWindow;
|
||||
}
|
||||
|
||||
function setTransientDisplay(display: PetTransientDisplay): void {
|
||||
debug("pet.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);
|
||||
|
||||
if (transientDisplayTimeout) {
|
||||
clearTimeout(transientDisplayTimeout);
|
||||
}
|
||||
if (transientAnimationTimeout) {
|
||||
clearTimeout(transientAnimationTimeout);
|
||||
transientAnimationTimeout = null;
|
||||
}
|
||||
|
||||
const animationMs = getTransientReactionAnimationMs(transientDisplay);
|
||||
const displayDurationMs = getTransientDisplayDurationMs(transientDisplay);
|
||||
if (animationMs !== null && animationMs < displayDurationMs) {
|
||||
transientAnimationTimeout = setTimeout(() => {
|
||||
if (!transientDisplay) return;
|
||||
transientDisplay = clearTransientReaction(transientDisplay);
|
||||
transientAnimationTimeout = null;
|
||||
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) setPetReactionState(defaultPetWindow, "idle");
|
||||
}, animationMs);
|
||||
}
|
||||
|
||||
if (!transientDisplay.sticky) {
|
||||
transientDisplayTimeout = setTimeout(() => {
|
||||
transientDisplay = null;
|
||||
transientDisplayTimeout = null;
|
||||
if (transientAnimationTimeout) {
|
||||
clearTimeout(transientAnimationTimeout);
|
||||
transientAnimationTimeout = null;
|
||||
}
|
||||
refreshDefaultPetContent();
|
||||
}, displayDurationMs);
|
||||
}
|
||||
|
||||
refreshDefaultPetContent();
|
||||
}
|
||||
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
|
||||
showDefaultPetWindow("external-event");
|
||||
}
|
||||
|
||||
async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unknown, maxDistance = maxPluginMoveDistance): Promise<{ readonly moved: boolean; readonly reason?: string }> {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return { moved: false, reason: "no-window" };
|
||||
const window = defaultPetWindow;
|
||||
const blockedReason = getMovementBlockedReason(window);
|
||||
if (blockedReason) {
|
||||
debug("pet.default", "move skipped", { reason: blockedReason });
|
||||
return { moved: false, reason: blockedReason };
|
||||
}
|
||||
const current = readWindowPosition(window);
|
||||
const distance = Math.min(Math.hypot(rawX, rawY), maxDistance);
|
||||
if (!Number.isFinite(distance) || distance <= 0) return { moved: false, reason: "invalid-distance" };
|
||||
const scale = distance / Math.hypot(rawX, rawY);
|
||||
const target = getSafeDefaultPetPosition({ x: current.x + rawX * scale, y: current.y + rawY * scale });
|
||||
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 });
|
||||
try {
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
if (window.isDestroyed()) return { moved: false, reason: "destroyed" };
|
||||
const blocked = getMovementBlockedReason(window, true);
|
||||
if (blocked) return { moved: false, reason: blocked };
|
||||
const t = step / steps;
|
||||
window.setPosition(Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false);
|
||||
await delay(durationMs / steps);
|
||||
}
|
||||
window.setPosition(target.x, target.y, false);
|
||||
setDefaultPetPosition(target);
|
||||
debug("pet.default", "move finished", { windowId: window.id, target });
|
||||
return { moved: true };
|
||||
} finally {
|
||||
movementInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMovementBlockedReason(window: BrowserWindow, allowMoving = false): string | undefined {
|
||||
if (movementInProgress && !allowMoving) return "already-moving";
|
||||
if (!window.isVisible()) return "hidden";
|
||||
if (paused) return "paused";
|
||||
if (isPetWindowDragging(window)) return "dragging";
|
||||
if (transientDisplay) return "transient-display";
|
||||
if (statusBadge) return "status-active";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setStatusBadge(reaction: OpenPetsReaction): void {
|
||||
if (reaction === "idle") {
|
||||
clearStatusBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
statusBadge = reaction;
|
||||
debug("pet.default", "status badge set", { reaction, durationMs: isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs });
|
||||
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
|
||||
statusBadgeTimeout = setTimeout(() => {
|
||||
clearStatusBadge();
|
||||
refreshDefaultPetContent();
|
||||
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
|
||||
}
|
||||
|
||||
function clearStatusBadge(): void {
|
||||
if (statusBadge) debug("pet.default", "status badge cleared", { reaction: statusBadge });
|
||||
statusBadge = null;
|
||||
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
|
||||
statusBadgeTimeout = null;
|
||||
}
|
||||
|
||||
function clearDefaultPetDisplayTimers(): void {
|
||||
if (transientDisplayTimeout) clearTimeout(transientDisplayTimeout);
|
||||
if (transientAnimationTimeout) clearTimeout(transientAnimationTimeout);
|
||||
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
|
||||
transientDisplayTimeout = null;
|
||||
transientAnimationTimeout = null;
|
||||
statusBadgeTimeout = null;
|
||||
transientDisplay = null;
|
||||
statusBadge = null;
|
||||
}
|
||||
|
||||
function getCurrentDismissToken(): string | undefined {
|
||||
return transientDisplay?.dismissToken ?? (statusBadge ? String(displayGeneration) : undefined);
|
||||
}
|
||||
|
||||
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
|
||||
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
|
||||
}
|
||||
|
||||
function reclampDefaultPetWindow(): void {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safePosition = readWindowPosition(defaultPetWindow);
|
||||
info("pet.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition });
|
||||
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
|
||||
setDefaultPetPosition(safePosition);
|
||||
recoverDefaultPetMouseInterop("display-change");
|
||||
}
|
||||
|
||||
function recoverDefaultPetWindowAfterResume(): void {
|
||||
recoverDefaultPetMouseInterop("power-resume");
|
||||
setTimeout(() => recoverDefaultPetMouseInterop("power-resume+500ms"), 500).unref?.();
|
||||
}
|
||||
|
||||
export function shouldOpenDefaultPetOnLaunch(): boolean {
|
||||
return getAppStateSnapshot().preferences.openDefaultPetOnLaunch;
|
||||
}
|
||||
|
||||
export function resetDefaultPetToInitialPosition(): void {
|
||||
const safePosition = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize));
|
||||
resetDefaultPetPosition(safePosition);
|
||||
|
||||
if (defaultPetWindow && !defaultPetWindow.isDestroyed()) {
|
||||
defaultPetWindow.setPosition(safePosition.x, safePosition.y, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
232
apps/desktop/src/familiar-installation-archive.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { createWriteStream } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Transform } from "node:stream";
|
||||
|
||||
import yauzl from "yauzl";
|
||||
import type { Entry, ZipFile } from "yauzl";
|
||||
|
||||
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes } from "./codex-familiars-core.js";
|
||||
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
|
||||
|
||||
export const maxZipDownloadBytes = 50 * 1024 * 1024;
|
||||
|
||||
const maxExtractedTotalBytes = 200 * 1024 * 1024;
|
||||
const maxFiles = 500;
|
||||
const maxIndividualFileBytes = 100 * 1024 * 1024;
|
||||
const downloadTimeoutMs = 30_000;
|
||||
|
||||
export async function downloadPetZip(zipUrl: string): Promise<Buffer> {
|
||||
validateZipUrl(zipUrl);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(zipUrl, {
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
validateZipUrl(response.url);
|
||||
if (response.url !== zipUrl) throw new Error("Zip download final URL changed.");
|
||||
if (!response.ok) throw new Error(`Zip download failed with HTTP ${response.status}.`);
|
||||
|
||||
const buffer = await readLimitedResponse(response, maxZipDownloadBytes);
|
||||
validateZipMagic(buffer);
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export 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.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.");
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes: number): Promise<Buffer> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Zip response body is unavailable for bounded reading.");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Zip download is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
export function validateZipMagic(buffer: Buffer): void {
|
||||
if (!hasSupportedZipMagic(buffer)) {
|
||||
throw new Error("Downloaded file has an unsupported zip signature.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
|
||||
const zipFile = await openZipFromBuffer(zip);
|
||||
const pathTracker = new ZipEntryPathTracker();
|
||||
const seenRequired = new Set<string>();
|
||||
let fileCount = 0;
|
||||
let extractedTotal = 0;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
let settled = false;
|
||||
|
||||
const reject = (error: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
zipFile.close();
|
||||
rejectPromise(error instanceof Error ? error : new Error("Zip extraction failed."));
|
||||
};
|
||||
|
||||
zipFile.on("error", reject);
|
||||
zipFile.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolvePromise();
|
||||
});
|
||||
|
||||
zipFile.on("entry", (entry) => {
|
||||
void processEntry(entry).then(() => {
|
||||
if (!settled) zipFile.readEntry();
|
||||
}).catch(reject);
|
||||
});
|
||||
|
||||
const processEntry = async (entry: Entry): Promise<void> => {
|
||||
validateEntryMetadata(entry);
|
||||
const safePath = pathTracker.accept(entry.fileName);
|
||||
|
||||
if (safePath.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!safePath.relativeOutputPath) {
|
||||
throw new Error("Zip file entry is missing an output path.");
|
||||
}
|
||||
|
||||
fileCount += 1;
|
||||
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
|
||||
if (safePath.relativeOutputPath === "familiar.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("familiar.json is too large.");
|
||||
if (safePath.relativeOutputPath === "spritesheet.webp" && entry.uncompressedSize > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp 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.");
|
||||
|
||||
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
|
||||
assertOutputPathInside(tempDir, outputPath);
|
||||
seenRequired.add(safePath.relativeOutputPath);
|
||||
const maxEntryBytes = safePath.relativeOutputPath === "familiar.json"
|
||||
? maxCodexPetJsonBytes
|
||||
: safePath.relativeOutputPath === "spritesheet.webp"
|
||||
? maxCodexSpritesheetBytes
|
||||
: maxIndividualFileBytes;
|
||||
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, maxEntryBytes);
|
||||
};
|
||||
|
||||
zipFile.readEntry();
|
||||
});
|
||||
} finally {
|
||||
zipFile.close();
|
||||
}
|
||||
|
||||
if (!seenRequired.has("familiar.json") || !seenRequired.has("spritesheet.webp")) {
|
||||
throw new Error("Zip must contain familiar.json and spritesheet.webp.");
|
||||
}
|
||||
}
|
||||
|
||||
function openZipFromBuffer(buffer: Buffer): Promise<ZipFile> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zipFile) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zipFile) {
|
||||
rejectPromise(new Error("Zip file could not be opened."));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise(zipFile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateEntryMetadata(entry: Entry): void {
|
||||
if (entry.isEncrypted()) throw new Error("Encrypted zip entries are not supported.");
|
||||
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
||||
throw new Error("Unsupported zip entry compression method.");
|
||||
}
|
||||
if (entry.compressedSize > maxZipDownloadBytes) throw new Error("Zip entry compressed size is too large.");
|
||||
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry uncompressed size is too large.");
|
||||
|
||||
const unixMode = getUnixMode(entry);
|
||||
if (unixMode === null) return;
|
||||
|
||||
const type = unixMode & 0o170000;
|
||||
const isKnownFileType = type !== 0;
|
||||
const isRegularFile = type === 0o100000;
|
||||
const isDirectory = type === 0o040000;
|
||||
if (isKnownFileType && !isRegularFile && !isDirectory) {
|
||||
throw new Error("Zip entry special files are not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
function getUnixMode(entry: Entry): number | null {
|
||||
if ((entry.versionMadeBy >> 8) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (entry.externalFileAttributes >> 16) & 0o177777;
|
||||
}
|
||||
|
||||
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number, maxBytes: number): Promise<void> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
zipFile.openReadStream(entry, (error, readStream) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readStream) {
|
||||
rejectPromise(new Error("Zip entry stream could not be opened."));
|
||||
return;
|
||||
}
|
||||
|
||||
let actualBytes = 0;
|
||||
const counter = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
actualBytes += chunk.byteLength;
|
||||
if (actualBytes > maxBytes) {
|
||||
callback(new Error("Zip entry exceeded individual size limit."));
|
||||
return;
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
pipeline(readStream, counter, createWriteStream(outputPath, { mode: 0o600 }))
|
||||
.then(() => {
|
||||
if (actualBytes !== expectedBytes) {
|
||||
rejectPromise(new Error("Zip entry extracted size did not match metadata."));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise();
|
||||
})
|
||||
.catch(rejectPromise);
|
||||
});
|
||||
});
|
||||
}
|
||||
221
apps/desktop/src/familiar-installation.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { constants } from "node:fs";
|
||||
import { lstat, mkdtemp, mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
|
||||
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type FamiliarOSStateV1 } from "./app-state.js";
|
||||
import { getCatalogPet } from "./catalog.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 { downloadPetZip, extractPetZip, maxZipDownloadBytes, validateZipMagic } from "./familiar-installation-archive.js";
|
||||
import { assertOutputPathInside } from "./zip-safety.js";
|
||||
|
||||
const operations = new Set<string>();
|
||||
|
||||
export async function installPet(petId: string): Promise<FamiliarOSStateV1> {
|
||||
return withPetOperation(petId, async () => {
|
||||
assertSafePetId(petId);
|
||||
|
||||
if (getAppStateSnapshot().familiars.installed.some((familiar) => familiar.id === petId)) {
|
||||
throw new Error(`Familiar is already installed: ${petId}`);
|
||||
}
|
||||
|
||||
const catalogPet = await getCatalogPet(petId);
|
||||
const zip = await downloadPetZip(catalogPet.zip);
|
||||
const petsRoot = getPetsRoot();
|
||||
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
|
||||
|
||||
const finalDir = getInstalledPetDir(petId);
|
||||
const tempDir = await mkdtemp(join(petsRoot, `.install-${petId}-`));
|
||||
|
||||
try {
|
||||
assertInsideRoot(petsRoot, tempDir);
|
||||
await extractPetZip(zip, tempDir);
|
||||
const metadata = await validateExtractedPet(tempDir);
|
||||
if (metadata.id !== petId || metadata.id !== catalogPet.id) {
|
||||
throw new Error("Catalog familiar package id does not match the requested familiar.");
|
||||
}
|
||||
await rm(finalDir, { recursive: true, force: true });
|
||||
await rename(tempDir, finalDir);
|
||||
|
||||
try {
|
||||
return installPetState({
|
||||
id: catalogPet.id,
|
||||
displayName: catalogPet.displayName,
|
||||
description: catalogPet.description,
|
||||
source: {
|
||||
catalogVersion: 2,
|
||||
zip: catalogPet.zip,
|
||||
preview: catalogPet.preview,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(finalDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPetFromZipFile(zipPath: string): Promise<FamiliarOSStateV1> {
|
||||
return withPetOperation("local-import", async () => {
|
||||
const zip = await readRegularFile(zipPath, maxZipDownloadBytes, "familiar zip");
|
||||
validateZipMagic(zip);
|
||||
const petsRoot = getPetsRoot();
|
||||
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
|
||||
const tempDir = await mkdtemp(join(petsRoot, ".local-import-"));
|
||||
try {
|
||||
assertInsideRoot(petsRoot, tempDir);
|
||||
await extractPetZip(zip, tempDir);
|
||||
const metadata = await validateExtractedPet(tempDir);
|
||||
await finalizeLocalPetInstall(metadata, tempDir);
|
||||
return installLocalPetState(metadata);
|
||||
} catch (error) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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("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().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 });
|
||||
const tempDir = await mkdtemp(join(petsRoot, `.local-import-${metadata.id}-`));
|
||||
try {
|
||||
assertInsideRoot(petsRoot, tempDir);
|
||||
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { 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) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePet(petId: string): Promise<FamiliarOSStateV1> {
|
||||
return withPetOperation(petId, async () => {
|
||||
if (petId === builtInPet.id) {
|
||||
throw new Error("Built-in familiar cannot be removed.");
|
||||
}
|
||||
assertSafePetId(petId);
|
||||
const dir = getInstalledPetDir(petId);
|
||||
const state = removePetState(petId);
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
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<FamiliarOSStateV1> {
|
||||
return withPetOperation(petId, async () => {
|
||||
if (petId !== builtInPet.id) {
|
||||
assertSafePetId(petId);
|
||||
}
|
||||
return setDefaultPet(petId);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withPetOperation<T>(key: string, callback: () => Promise<T>): Promise<T> {
|
||||
if (operations.has(key)) {
|
||||
throw new Error("An operation for this familiar is already in progress.");
|
||||
}
|
||||
|
||||
operations.add(key);
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
operations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata> {
|
||||
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, "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);
|
||||
|
||||
const spritesheet = await stat(spritesheetPath);
|
||||
if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file.");
|
||||
if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty.");
|
||||
if (spritesheet.size > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function finalizeLocalPetInstall(metadata: CodexPetMetadata, tempDir: string): Promise<void> {
|
||||
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);
|
||||
await rm(finalDir, { recursive: true, force: true });
|
||||
await rename(tempDir, finalDir);
|
||||
try {
|
||||
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
|
||||
await validateInstalledRegularFile(join(finalDir, "familiar.json"));
|
||||
} catch (error) {
|
||||
await rm(finalDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readRegularFile(path: string, maxBytes: number, label: string): Promise<Buffer> {
|
||||
const resolved = resolve(path);
|
||||
const stats = await lstat(resolved);
|
||||
if (stats.isSymbolicLink()) throw new Error(`${label} cannot be a symlink.`);
|
||||
if (!stats.isFile()) throw new Error(`${label} must be a file.`);
|
||||
if (stats.size <= 0 || stats.size > maxBytes) throw new Error(`${label} size is invalid.`);
|
||||
const file = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const openedStats = await file.stat();
|
||||
if (!openedStats.isFile() || openedStats.size !== stats.size || openedStats.size <= 0 || openedStats.size > maxBytes) throw new Error(`${label} size is invalid.`);
|
||||
return await file.readFile();
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
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 familiar file escapes familiars root.");
|
||||
const stats = await lstat(resolved);
|
||||
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<FamiliarOSStateV1> {
|
||||
try {
|
||||
return installPetState({ id: metadata.id, displayName: metadata.displayName, description: metadata.description });
|
||||
} catch (error) {
|
||||
await rm(getInstalledPetDir(metadata.id), { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||