Preparing v3

This commit is contained in:
Alvin Unreal 2026-06-11 16:54:54 +02:00
parent 7116d80953
commit 6b9b2a8e4d
96 changed files with 8555 additions and 2365 deletions

View file

@ -15,11 +15,21 @@ Catalog v2 is legacy and exists only for old app versions/fallback compatibility
For new work, migrations, and Control Center UI, do not optimize for v2 behavior.
Use catalog v3 (`thumbnail`, `spritesheet`, paginated pages, and search index) as the source of truth.
## Forward-Only Product Direction
Move the current app forward; do not keep legacy compatibility code, duplicate
paths, stale shims, or old behavior in current runtime code unless it is required
so older released app versions can still open/use versioned catalogs or existing
published data. Prefer clean migrations, versioned catalog/data boundaries, and
removing obsolete code over preserving backwards-compatible branches. The bar is:
old app versions should not break catastrophically, but the current app should
not carry legacy bloat for deprecated plugin/catalog behavior.
## Plugin Docs
Before changing plugin platform code, official plugins, plugin catalog generation, plugin packaging, plugin runtime behavior, or plugin-facing UI, read:
- `docs/plugins.md` for the current plugin platform architecture, manifest/runtime rules, local development workflow, publishing commands, and troubleshooting notes.
- `docs/new_plugins.md` for the companion-first Windows plugin direction, planned official plugin lineup, bundling defaults, and right-click plugin action strategy.
- `docs/superplugins.md` for the companion-first plugin direction, planned official plugin lineup, bundling defaults, and right-click plugin action strategy.
When plugin work is finished, update these docs if behavior, commands, manifests, plugin IDs, default bundled/enabled status, catalog workflow, permissions, or the planned plugin lineup changed. Do not leave plugin docs stale after implementation.

View file

@ -4,6 +4,7 @@ const api = {
getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"),
getDashboardSnapshot: () => ipcRenderer.invoke("openpets:get-dashboard-snapshot"),
getSettingsState: () => ipcRenderer.invoke("openpets:get-settings-state"),
getI18n: () => ipcRenderer.invoke("openpets:get-i18n"),
updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch),
getReactionAnimationSettings: () => ipcRenderer.invoke("openpets:get-reaction-animation-settings"),
getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"),
@ -16,8 +17,9 @@ const api = {
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) => ipcRenderer.invoke("openpets:plugins-execute-command", id, commandId),
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),
@ -42,6 +44,11 @@ const api = {
ipcRenderer.on("openpets:control-center-route", listener);
return () => ipcRenderer.removeListener("openpets:control-center-route", listener);
},
onPluginsRefresh: (callback) => {
const listener = () => callback();
ipcRenderer.on("openpets:plugins-refresh", listener);
return () => ipcRenderer.removeListener("openpets: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),

View file

@ -246,6 +246,15 @@ let audioContext = null;
let activeAudioNodes = [];
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}`;
if (level === "warn") console.warn(line, safeFields || {});
else console.debug(line, safeFields || {});
} catch { /* diagnostics must never affect playback */ }
};
const getAudioContext = () => {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
return audioContext;
@ -266,10 +275,12 @@ ipcRenderer.on("openpets:play-audio", (_event, payload) => {
try {
if (!payload) return;
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
audioLog("debug", "play requested", { kind: payload.kind, volume });
if (payload.kind === "named") {
const recipe = namedSoundRecipes[payload.name];
if (!recipe) return;
if (!recipe) { audioLog("warn", "named sound skipped", { name: payload.name, reason: "unknown-sound" }); return; }
const ctxAudio = getAudioContext();
if (ctxAudio.state === "suspended") void ctxAudio.resume().catch((error) => audioLog("warn", "audio context resume failed", { reason: error && error.message ? error.message : String(error) }));
const now = ctxAudio.currentTime;
for (const note of recipe) {
const osc = ctxAudio.createOscillator();
@ -284,19 +295,27 @@ ipcRenderer.on("openpets:play-audio", (_event, payload) => {
osc.stop(now + note.start + note.duration + 0.05);
activeAudioNodes.push(osc);
}
audioLog("debug", "named sound scheduled", { name: payload.name, notes: recipe.length, contextState: ctxAudio.state });
return;
}
if (payload.kind === "data" && typeof payload.dataUrl === "string" && payload.dataUrl.startsWith("data:audio/")) {
const element = new Audio(payload.dataUrl);
element.volume = volume;
activeAudioElements.push(element);
element.addEventListener("ended", () => { activeAudioElements = activeAudioElements.filter((entry) => entry !== element); });
void element.play().catch(() => undefined);
element.addEventListener("ended", () => { activeAudioElements = activeAudioElements.filter((entry) => entry !== element); audioLog("debug", "data sound ended", { remaining: activeAudioElements.length }); });
element.addEventListener("error", () => audioLog("warn", "data sound element error", { code: element.error ? element.error.code : undefined, message: element.error ? element.error.message : undefined }));
void element.play().then(() => audioLog("debug", "data sound playback started", { volume })).catch((error) => {
activeAudioElements = activeAudioElements.filter((entry) => entry !== element);
audioLog("warn", "data sound playback failed", { reason: error && error.message ? error.message : String(error), name: error && error.name ? error.name : undefined });
});
} else {
audioLog("warn", "play request ignored", { kind: payload.kind, reason: "invalid-payload" });
}
} catch { /* audio is best-effort */ }
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
});
ipcRenderer.on("openpets:stop-audio", () => {
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
activeAudioNodes = [];
for (const element of activeAudioElements) { try { element.pause(); } catch { /* noop */ } }

View file

@ -1,5 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron");
// Keep SDK route strings in sync with src/plugin-sdk-routes.ts. The desktop
// 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)}` : "";
let callbackId = 0;
@ -10,6 +14,13 @@ async function call(path, args) {
return ipcRenderer.invoke(channel, path, normalizeForIpc(args));
}
function callSync(path, args) {
if (!channel) throw new Error("OpenPets plugin SDK is unavailable.");
const result = ipcRenderer.sendSync(channel, path, normalizeForIpc(args));
if (result && typeof result === "object" && typeof result.__openPetsError === "string") throw new Error(result.__openPetsError);
return result;
}
function normalizeForIpc(value, depth = 0) {
if (depth > 20) return null;
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
@ -104,7 +115,7 @@ function makePetHandle(petId) {
react: (reaction) => call("pet.react", [petId, reaction]),
setAnimation: (state) => call("pet.setAnimation", [petId, state]),
setScale: (scale) => call("pet.setScale", [petId, scale]),
badge: (badge) => call("pet.badge", [petId, badge]),
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]),
@ -132,6 +143,11 @@ const sdk = {
},
ui: {
bubble: (spec) => call("ui.bubble", [spec]).then(makeBubbleHandle),
alert: (spec) => call("ui.alert", [spec]).then((handle) => {
const bubble = makeBubbleHandle(handle);
bubble.acknowledge = () => call("ui.bubbleDismiss", [handle && handle.bubbleId]);
return bubble;
}),
toast: (spec) => call("ui.toast", [spec]),
panel: (spec) => call("ui.panel", [spec]).then(makePanelHandle),
menu: {
@ -141,6 +157,8 @@ const sdk = {
},
audio: {
play: (sound, options) => call("audio.play", [sound, options]),
importUserSound: (file, opts) => call("audio.importUserSound", [file && file.fileId ? file.fileId : file, opts]),
forgetUserSound: (ref) => call("audio.forgetUserSound", [ref]),
stop: (handle) => call("audio.stop", [handle]),
},
events: {
@ -232,8 +250,14 @@ const sdk = {
fetch: (url, options) => call("http.fetch", [url, options]),
},
log: Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [level, (...args) => call(`log.${level}`, args)])),
t: (key, vars) => callSync("i18n.t", [key, vars]),
};
Object.defineProperty(sdk, "locale", {
enumerable: true,
get: () => callSync("i18n.locale", []),
});
contextBridge.exposeInMainWorld("__openPetsSdk", sdk);
contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => {
const callback = callbacks.get(id);

View file

@ -6,6 +6,7 @@ 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 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 { publishPluginAgentActivity } from "./plugin-events-source.js";
@ -36,6 +37,7 @@ export interface OpenPetsStateV1 {
readonly preferences: {
readonly defaultPetId: string;
readonly openDefaultPetOnLaunch: boolean;
readonly locale: LocalePreference;
readonly speechBubblesEnabled: boolean;
readonly petScale: number;
readonly reactionAnimationOverrides?: ReactionAnimationOverrides;
@ -377,6 +379,7 @@ function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): O
openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean"
? value.openDefaultPetOnLaunch
: defaultState.preferences.openDefaultPetOnLaunch,
locale: normalizeLocalePreference(value.locale),
speechBubblesEnabled: true,
petScale: normalizePetScale(value.petScale),
reactionAnimationOverrides: normalizeReactionAnimationOverrides(value.reactionAnimationOverrides),
@ -387,6 +390,11 @@ function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): O
};
}
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();
@ -444,6 +452,7 @@ function createDefaultState(): OpenPetsStateV1 {
preferences: {
defaultPetId: builtInPet.id,
openDefaultPetOnLaunch: true,
locale: "system",
speechBubblesEnabled: true,
petScale: defaultPetScale,
reactionAnimationOverrides: undefined,

View file

@ -74,6 +74,7 @@ const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messag
const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8");
const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"), "utf8");
const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8");
const enCatalogSource = readFileSync(join(appDir, "src", "i18n", "locales", "en.ts"), "utf8");
const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8");
const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8");
const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
@ -92,7 +93,8 @@ assert.match(loggerSource, /redacted-token/, "desktop logger must redact token-l
assert.match(mainSource, /initializeLogger\(\)/, "desktop startup must initialize logging before subsystem startup.");
assert.match(mainSource, /process\.platform === "linux"[\s\S]*?appendSwitch\("ozone-platform", "x11"\)/, "Linux desktop pets must prefer X11/Xwayland because GNOME Wayland blocks always-on-top and programmatic window dragging.");
assert.match(mainSource, /hasSwitch\("ozone-platform"\)/, "Linux X11 preference must let users override Electron's Ozone backend explicitly.");
assert.match(traySource, /Open Logs Folder/, "desktop tray must expose user-sendable logs for bug reports.");
assert.match(traySource, /t\("tray\.openLogsFolder"\)/, "desktop tray must expose user-sendable logs for bug reports.");
assert.match(enCatalogSource, /Open Logs Folder/, "English catalog must keep the user-sendable logs label.");
assert.match(localIpcSourceForLogging, /request received/, "desktop IPC must log request methods for diagnostics.");
assert.match(localIpcPathsSource, /OPENPETS_IPC_BIND[\s\S]*?OPENPETS_IPC_ENDPOINT[\s\S]*?validateBindHost[\s\S]*?validateAdvertisedHost/, "WSL NAT IPC must separate bind and advertised endpoints with validation.");
assert.match(localIpcPathsSource, /OPENPETS_IPC_ENDPOINT only controls the advertised discovery endpoint[\s\S]*?OPENPETS_IPC_BIND to opt into TCP IPC listening/, "OPENPETS_IPC_ENDPOINT-only mode must not start TCP listening; OPENPETS_IPC_BIND is the explicit TCP opt-in.");
@ -105,7 +107,7 @@ for (const reaction of ["idle", "thinking", "working", "editing", "running", "te
assert.match(reactionMessagesSource, new RegExp(`${reaction}:\\s*\\[`), `reaction messages must define a pool for: ${reaction}`);
}
assert.match(reactionMessagesSource, /satisfies Record<OpenPetsReaction, readonly string\[\]>/, "reaction-only bubble message pools must be exhaustive over OpenPetsReaction.");
assert.match(petWindowSource, /pickReactionMessage\(display\.reaction\)/, "reaction-only bubbles must render randomized messages instead of raw lowercase reaction ids.");
assert.match(petWindowSource, /pickReactionMessage\(display\.reaction\b/, "reaction-only bubbles must render randomized messages instead of raw lowercase reaction ids.");
assert.match(petWindowSource, /function preparePetTransientDisplay/, "reaction-only bubbles must prepare a stable random message before rerenders.");
assert.match(petWindowSource, /function mergePetTransientDisplay/, "reaction-only events must not replace an active explicit message bubble.");
assert.match(petWindowSource, /return \{ \.\.\.current, reaction: next\.reaction, dismissToken: next\.dismissToken \?\? current\.dismissToken \}/, "reaction-only updates merged into an active message must carry the latest dismiss token.");
@ -169,7 +171,8 @@ assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must repo
assert.match(updateCheckerSource, /alvinunreal\/openpets/, "GitHub release notice must check the public OpenPets repository.");
assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API.");
assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally.");
assert.match(traySource, /Update available:/, "tray menu must surface available updates.");
assert.match(traySource, /t\("tray\.updateAvailable"/, "tray menu must surface available updates.");
assert.match(enCatalogSource, /Update available:/, "English catalog must keep the update-available label.");
assert.match(windowsSource, /openpets:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(windowsSource, /openpets:get-reaction-animation-settings/, "settings window must be able to load reaction animation metadata.");
assert.match(windowsSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides.");
@ -209,10 +212,10 @@ for (const reaction of allowedReactions) {
assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected.");
assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|getOnboardingSnapshot|completeOnboarding/, "Control Center must not include the removed onboarding route.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include integrations.");
assert.match(controlCenterRendererSource, /Claude Code/, "Control Center integrations must include Claude Code.");
assert.match(controlCenterRendererSource, /OpenCode/, "Control Center integrations must include OpenCode.");
assert.match(controlCenterRendererSource, /Cursor/, "Control Center integrations must include Cursor.");
assert.match(controlCenterRendererSource, /Pi/, "Control Center integrations must include Pi.");
assert.match(enCatalogSource, /Claude Code/, "Control Center integrations must include Claude Code.");
assert.match(enCatalogSource, /OpenCode/, "Control Center integrations must include OpenCode.");
assert.match(enCatalogSource, /Cursor/, "Control Center integrations must include Cursor.");
assert.match(enCatalogSource, /Pi/, "Control Center integrations must include Pi.");
assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "pet scale preference changes must refresh default and agent pet windows.");
assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist.");
@ -356,7 +359,7 @@ function assertNonEmptyFile(path: string, message: string): void {
}
function assertBundledOfficialPlugins(resourceDir: string): void {
for (const id of ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders", "openpets.github-notifications"]) {
for (const id of ["openpets.reminders"]) {
const dir = join(resourceDir, "plugins", "official", id);
const manifestPath = join(dir, "openpets.plugin.json");
assertNonEmptyFile(manifestPath, `packaged bundled plugin manifest is missing: ${id}`);

View file

@ -8,9 +8,14 @@
* the bridge in lockstep.
*/
import type { OpenPetsContext, OpenPetsPermission } from "@open-pets/plugin-sdk";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { PluginJavascriptPermission } from "./plugin-manifest.js";
import type { PluginSdkApi } from "./plugin-sdk-bridge.js";
import type { sdkCallHandlers } from "./plugin-js-host.js";
import { pluginSdkAsyncRoutes, pluginSdkSyncRoutes, type PluginSdkRoute } from "./plugin-sdk-routes.js";
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;
@ -21,8 +26,43 @@ type _NamespacesMatch = Expect<Equal<keyof PluginSdkApi, keyof OpenPetsContext>>
// The JavaScript plugin permission union must match the published contract.
type _PermissionsMatch = Expect<Equal<PluginJavascriptPermission, OpenPetsPermission>>;
type _HostRoutesMatch = Expect<Equal<keyof typeof sdkCallHandlers, PluginSdkRoute>>;
// Reference the aliases so unused-type tooling never strips the guard.
export type PluginSdkConformance = [_NamespacesMatch, _PermissionsMatch];
export type PluginSdkConformance = [_NamespacesMatch, _PermissionsMatch, _HostRoutesMatch];
const preload = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "plugin-sdk-preload.cjs"), "utf8");
const preloadAsyncRoutes = extractPreloadAsyncRoutes(preload);
const preloadSyncRoutes = extractLiteralFirstArgs(preload, "callSync");
const hostOnlyRoutes = new Set<string>(["assets.resolve"]);
const expectedPreloadAsyncRoutes = new Set(pluginSdkAsyncRoutes.filter((route) => !hostOnlyRoutes.has(route)));
assertSetEqual("Plugin SDK preload async routes", preloadAsyncRoutes, expectedPreloadAsyncRoutes);
assertSetEqual("Plugin SDK preload sync routes", preloadSyncRoutes, new Set(pluginSdkSyncRoutes));
console.error("Plugin SDK conformance validation passed.");
function extractPreloadAsyncRoutes(source: string): Set<string> {
const routes = new Set<string>([...extractLiteralFirstArgs(source, "call"), ...extractSubscriptionRoutes(source)]);
if (/call\(`log\.\$\{level\}`/.test(source)) for (const level of ["debug", "info", "warn", "error"]) routes.add(`log.${level}`);
return routes;
}
function extractSubscriptionRoutes(source: string): Set<string> {
const routes = new Set<string>();
const pattern = /\bsubscription\(\s*"([^"]+)"\s*,\s*"([^"]+)"/g;
for (let match = pattern.exec(source); match; match = pattern.exec(source)) { routes.add(match[1] ?? ""); routes.add(match[2] ?? ""); }
return routes;
}
function extractLiteralFirstArgs(source: string, callee: string): Set<string> {
const routes = new Set<string>();
const pattern = new RegExp(`\\b${callee}\\(\\s*"([^"]+)"`, "g");
for (let match = pattern.exec(source); match; match = pattern.exec(source)) routes.add(match[1] ?? "");
return routes;
}
function assertSetEqual(label: string, actual: Set<string>, expected: Set<string>): void {
const missing = [...expected].filter((route) => !actual.has(route)).sort();
const extra = [...actual].filter((route) => !expected.has(route)).sort();
if (missing.length > 0 || extra.length > 0) throw new Error(`${label} drift. Missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"}.`);
}

View file

@ -137,6 +137,12 @@ export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction
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);
}

View file

@ -0,0 +1,85 @@
// Pure i18n catalog: types, locale tables, and resolution helpers with NO
// Electron dependency, so it is safe to import from anywhere (main process,
// tests, and — if ever bundled — the renderer). The stateful main-process
// wrapper lives in ./index.ts.
import { en } from "./locales/en.js";
import { ja } from "./locales/ja.js";
import { ko } from "./locales/ko.js";
import { zhHans } from "./locales/zh-Hans.js";
import { zhHant } from "./locales/zh-Hant.js";
import { ptBR } from "./locales/pt-BR.js";
import { es419 } from "./locales/es-419.js";
export type MessageKey = keyof typeof en;
export type Messages = Record<MessageKey, string>;
export const SUPPORTED_LOCALES = ["en", "ja", "ko", "zh-Hans", "zh-Hant", "pt-BR", "es-419"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
/** `"system"` follows the OS locale; an explicit locale pins it. */
export type LocalePreference = "system" | Locale;
// Endonyms: each language labels itself in its own script, so the picker reads
// naturally regardless of the active UI locale.
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
"pt-BR": "Português (Brasil)",
"es-419": "Español (Latinoamérica)",
};
const catalogs: Record<Locale, Partial<Messages>> = {
en,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
"pt-BR": ptBR,
"es-419": es419,
};
export function isSupportedLocale(value: unknown): value is Locale {
return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
/** Replace `{name}` placeholders; unknown placeholders are left untouched. */
export function interpolate(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match,
);
}
export function translate(locale: Locale, key: MessageKey, vars?: Record<string, string | number>): string {
const template = catalogs[locale]?.[key] ?? en[key];
return interpolate(template, vars);
}
/** Fully-resolved message map for a locale (English-backed), for the renderer. */
export function getMessages(locale: Locale): Messages {
return { ...en, ...catalogs[locale] };
}
/** Map a raw BCP-47 tag (e.g. `app.getLocale()`) to a supported locale. */
export function resolveLocale(raw: string | null | undefined): Locale {
if (isSupportedLocale(raw)) return raw;
if (!raw) return "en";
const tag = raw.toLowerCase();
if (tag.startsWith("ja")) return "ja";
if (tag.startsWith("ko")) return "ko";
if (tag.startsWith("pt")) return "pt-BR";
if (tag.startsWith("es")) return "es-419";
if (tag.startsWith("zh")) {
// Traditional script for Taiwan / Hong Kong / Macau or an explicit Hant tag.
return /hant|tw|hk|mo/.test(tag) ? "zh-Hant" : "zh-Hans";
}
return "en";
}
/** Resolve a stored preference to a concrete locale, given the OS locale. */
export function resolvePreference(preference: LocalePreference, systemLocale: string | null | undefined): Locale {
return preference === "system" ? resolveLocale(systemLocale) : preference;
}

View file

@ -0,0 +1,66 @@
// Main-process i18n facade: holds the active locale (derived from the user's
// stored preference + the OS locale) and exposes `t()` for main-process call
// sites such as the tray. Renderer windows do not import this; they receive a
// resolved message map over IPC (see windows.ts `openpets:get-i18n`).
//
// `electron` is loaded lazily (via createRequire, inside `systemLocale()`)
// rather than imported at module scope: this facade is pulled into the plugin
// runtime graph (plugin-i18n -> i18n/index) and must be importable under plain
// Node in the test suite, where the `electron` shim has no named `app` export.
import { createRequire } from "node:module";
import {
getMessages,
resolvePreference,
translate,
type Locale,
type LocalePreference,
type MessageKey,
type Messages,
} from "./catalog.js";
export type { Locale, LocalePreference, MessageKey, Messages } from "./catalog.js";
export { LOCALE_LABELS, SUPPORTED_LOCALES, isSupportedLocale } from "./catalog.js";
let activeLocale: Locale = "en";
const require = createRequire(import.meta.url);
function systemLocale(): string {
try {
const { app } = require("electron") as typeof import("electron");
return app.getLocale() || "en";
} catch {
// `electron` is unavailable outside the Electron runtime (e.g. tests), and
// app.getLocale() throws before `ready`; callers re-apply after startup.
return "en";
}
}
/** Apply a stored preference, resolving `"system"` against the OS locale. */
export function setLocaleFromPreference(preference: LocalePreference): Locale {
activeLocale = resolvePreference(preference, systemLocale());
return activeLocale;
}
export function getActiveLocale(): Locale {
return activeLocale;
}
/**
* BCP-47 tag for the HTML `lang` attribute. Our locale ids are already valid
* language tags (e.g. `zh-Hans`, `pt-BR`), so this returns the active locale
* but the indirection lets locale and lang diverge later without churn.
*/
export function getActiveLocaleLang(): string {
return activeLocale;
}
export function t(key: MessageKey, vars?: Record<string, string | number>): string {
return translate(activeLocale, key, vars);
}
/** Resolved message map for the active locale (for sending to the renderer). */
export function getActiveMessages(): Messages {
return getMessages(activeLocale);
}

View file

@ -0,0 +1,520 @@
// English is the source-of-truth catalog: its keys define the message contract
// every other locale fills in. Other locales are `Partial<Messages>` and fall
// back to these strings per-key, so a missing translation degrades to English
// rather than to a raw key.
//
// Placeholder syntax is `{name}` (see `interpolate` in ../catalog.ts).
export const en = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Update available: {version}...",
"tray.defaultPet": "Default Pet: {name}",
"tray.showDefaultPet": "Show Default Pet",
"tray.hideDefaultPet": "Hide Default Pet",
"tray.pauseAllPets": "Pause All Pets",
"tray.resumeAllPets": "Resume All Pets",
"tray.managePets": "Manage Pets...",
"tray.controlCenter": "Control Center...",
"tray.website": "Website...",
"tray.integrations": "Integrations...",
"tray.plugins": "Plugins...",
"tray.settings": "Settings...",
"tray.openLogsFolder": "Open Logs Folder...",
"tray.quit": "Quit OpenPets",
// --- Shared ---
"common.latest": "latest",
"common.builtInPet": "Built-in Pet",
"common.cancel": "Cancel",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "Paused",
"pet.status.thinking": "Thinking",
"pet.status.working": "Working",
"pet.status.editing": "Editing",
"pet.status.testing": "Testing",
"pet.status.waiting": "Waiting",
"pet.status.done": "Done",
"pet.status.oops": "Oops",
"pet.status.hi": "Hi",
"pet.menu.hidePet": "Hide pet",
"pet.menu.closePet": "Close pet",
"pet.menu.openControlCenter": "Open Control Center",
// --- Common (renderer, shared across views) ---
"common.retry": "Retry",
"common.close": "Close",
"common.save": "Save",
"common.to": "to",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Dashboard",
"nav.pets": "Pets",
"nav.settings": "Settings",
"nav.plugins": "Plugins",
"nav.integrations": "Integrations",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Dashboard",
"route.dashboard.description": "Overview of your active companions, status, and system metrics.",
"route.pets.title": "Pets",
"route.pets.description": "Install, import, preview, and choose your default desktop companion.",
"route.settings.title": "Settings",
"route.settings.description": "Configure startup behaviors, scale preferences, and animation settings.",
"route.plugins.title": "Plugins",
"route.plugins.description": "Extend your desktop experience with custom tools and behaviors.",
"route.integrations.title": "Integrations",
"route.integrations.description": "Connect your companions to Claude Code, VS Code, Cursor, and more.",
// --- App shell (renderer) ---
"app.controlCenter": "Control Center",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "Gathering companion metrics...",
"dashboard.hero.eyebrow": "Primary Companion",
"dashboard.hero.desc": "Ready for your next coding session.",
"dashboard.hero.changePet": "Change Pet",
"dashboard.lastActive.none": "No activity yet",
"dashboard.update.available": "Update available",
"dashboard.update.error": "Check failed",
"dashboard.update.checking": "Checking",
"dashboard.update.current": "Current",
"dashboard.update.notChecked": "Not checked",
"dashboard.stat.messages": "Messages",
"dashboard.stat.messages.footer": "Total speech bubbles sent",
"dashboard.stat.reactions": "Reactions",
"dashboard.stat.reactions.footer": "Total animations triggered",
"dashboard.stat.topCompanion": "Top Companion",
"dashboard.stat.topCompanion.footer": "Most active pet lately",
"dashboard.activity.title": "Activity Overview",
"dashboard.activity.topReactions": "Top Reactions",
"dashboard.activity.noReactions": "No reactions recorded yet. Start coding!",
"dashboard.reactionMix.title": "Reaction Mix",
"dashboard.reactionMix.total": "{count} total",
"dashboard.reactionMix.waiting": "Waiting for activity",
"dashboard.reactionMix.chartLabel": "Reaction mix chart",
"dashboard.reactionMix.reactions": "reactions",
"dashboard.reactionMix.empty": "No reaction mix yet.",
"dashboard.companions.title": "Top Companions",
"dashboard.companions.subtitle": "Most active pets",
"dashboard.companions.empty": "No companion activity yet.",
"dashboard.lastActive.label": "Last active: ",
"dashboard.system.title": "System Health",
"dashboard.system.pets": "Pets",
"dashboard.system.pets.value": "{count} installed",
"dashboard.system.plugins": "Plugins",
"dashboard.system.plugins.enabled": "{count} enabled",
"dashboard.system.catalog": "Catalog",
"dashboard.system.catalog.offline": "Offline",
"dashboard.system.catalog.pets": "{count} pets",
"dashboard.system.catalog.ready": "Ready",
"dashboard.system.updates": "Updates",
"dashboard.system.version": "Version",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Coming Soon • Next Migration Target",
// --- Pets filters (renderer) ---
"pets.filter.all": "All",
"pets.filter.installed": "Installed",
"pets.filter.featured": "Featured",
"pets.filter.originals": "Originals",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Search pets...",
"pets.import": "Import pet",
"pets.gallery": "Gallery",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Default",
"pets.badge.original": "Original",
"pets.badge.featured": "Featured",
"pets.badge.installed": "Installed",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Broken",
"pets.badge.ready": "Ready",
"pets.badge.originals": "Originals",
"pets.action.viewPet": "View pet",
"pets.action.install": "Install",
"pets.action.import": "Import",
"pets.action.default": "Default",
"pets.action.remove": "Remove",
"pets.action.refresh": "Refresh",
"pets.aria.view": "View {name}",
"pets.aria.install": "Install {name}",
"pets.aria.import": "Import {name} from Codex",
"pets.aria.setDefault": "Set {name} as default",
"pets.aria.remove": "Remove {name}",
"pets.busy.installing": "Installing",
"pets.busy.importing": "Importing",
"pets.busy.settingDefault": "Setting default",
"pets.busy.removing": "Removing",
"pets.busy.loadingPage": "Loading page",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Prev",
"pets.pager.next": "Next",
"pets.pager.count": "{count} pets",
"pets.pager.page": " · Page {page} of {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} pet details",
"pets.detail.closeAria": "Close pet details",
"pets.detail.eyebrow": "Pet detail",
"pets.detail.previewAnimations": "Preview Animations",
"pets.detail.preview.idle": "Idle",
"pets.detail.preview.thinking": "Thinking",
"pets.detail.preview.happy": "Happy",
"pets.detail.preview.wave": "Wave",
"pets.detail.installPet": "Install Pet",
"pets.detail.importCodexPet": "Import Codex Pet",
"pets.detail.setDefaultPet": "Set Default Pet",
"pets.detail.remove": "Remove",
"pets.detail.refresh": "Refresh",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "This installed pet is broken and cannot be selected as default.",
"pets.status.defaultProtected": "Default built-in pet. Protected from removal.",
"pets.status.default": "Default pet.",
"pets.status.installedCodex": "Installed and ready to become your default pet. Also found in ~/.codex/pets.",
"pets.status.installed": "Installed and ready to become your default pet.",
"pets.status.availableCodex": "Available to import from ~/.codex/pets.",
"pets.status.availableCatalog": "Available to install from the catalog.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} thumbnail",
"pets.spriteLabel.thumb": "{name} thumb",
"pets.spriteLabel.animatedPreview": "{name} animated preview",
"pets.spriteLabel.statePreview": "{name} {state} preview",
// --- Settings: general (renderer) ---
"settings.nav.general": "General",
"settings.nav.reactions": "Reaction Mapping",
"settings.nav.plugins": "Plugin Platform",
"settings.general.eyebrow": "Environment",
"settings.general.title": "General Settings",
"settings.general.showOnLaunch.title": "Show pet on launch",
"settings.general.showOnLaunch.description": "Keep OpenPets in the tray but hide the pet until requested.",
"settings.general.launchAtLogin.title": "Launch at login",
"settings.general.launchAtLogin.supported": "Start OpenPets automatically when your computer starts.",
"settings.general.launchAtLogin.unsupported": "Not supported on this platform.",
"settings.general.petScale.title": "Pet scale",
"settings.general.petScale.description": "Adjust how large the default desktop pet appears.",
"settings.general.resetPosition": "Reset Pet Position",
"settings.general.systemStatus": "System Status",
"settings.general.updateAvailable": "Update Available",
"settings.general.checking": "Checking…",
"settings.general.checkForUpdates": "Check for Updates",
"settings.toast.startupSaved": "Startup preference saved.",
"settings.toast.loginStartupSaved": "Login startup preference saved.",
"settings.toast.petScaleSaved": "Pet scale saved.",
"settings.toast.positionReset": "Default pet position reset.",
"settings.busy.saving": "Saving",
"settings.busy.resetting": "Resetting",
"settings.busy.opening": "Opening",
"settings.busy.checking": "Checking",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "Update status has not loaded yet.",
"settings.update.checking": "Checking for updates…",
"settings.update.available": "Version {version} is available.",
"settings.update.current": "Up to date.",
"settings.update.failed": "Update check failed.",
"settings.update.version": "Version: {version}.",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "Behavior",
"settings.reactions.title": "Reaction Mapping",
"settings.reactions.resetDefaults": "Reset to Defaults",
"settings.reactions.description": "Customize which animation plays for each agent reaction. Previews use the default pet.",
"settings.reactions.previewAria": "Animation: {state}",
"settings.toast.reactionsReset": "Reaction animations reset.",
"settings.toast.reactionSaved": "Reaction animation saved.",
"settings.animation.idle.label": "Idle",
"settings.animation.idle.description": "Neutral/no special movement.",
"settings.animation.review.label": "Review",
"settings.animation.review.description": "Thinking, reading, reviewing.",
"settings.animation.running.label": "Running",
"settings.animation.running.description": "Active work, editing, executing.",
"settings.animation.waiting.label": "Waiting",
"settings.animation.waiting.description": "Waiting, blocked, testing, permission pending.",
"settings.animation.waving.label": "Waving",
"settings.animation.waving.description": "Attention, greeting, notification.",
"settings.animation.jumping.label": "Jumping",
"settings.animation.jumping.description": "Success, celebration.",
"settings.animation.failed.label": "Failed",
"settings.animation.failed.description": "Error or failure.",
"settings.reaction.idle.label": "Idle",
"settings.reaction.idle.description": "Explicit neutral reaction.",
"settings.reaction.thinking.label": "Thinking",
"settings.reaction.thinking.description": "Agent is reasoning or reviewing.",
"settings.reaction.working.label": "Working",
"settings.reaction.working.description": "Agent is doing general tool work.",
"settings.reaction.editing.label": "Editing",
"settings.reaction.editing.description": "Agent is changing files.",
"settings.reaction.running.label": "Running",
"settings.reaction.running.description": "Agent is running a command.",
"settings.reaction.testing.label": "Testing",
"settings.reaction.testing.description": "Agent is running checks.",
"settings.reaction.waiting.label": "Waiting",
"settings.reaction.waiting.description": "Agent is blocked or waiting for permission.",
"settings.reaction.waving.label": "Waving",
"settings.reaction.waving.description": "Pet is greeting or getting attention.",
"settings.reaction.success.label": "Success",
"settings.reaction.success.description": "Task completed successfully.",
"settings.reaction.error.label": "Error",
"settings.reaction.error.description": "Something failed.",
"settings.reaction.celebrating.label": "Celebrating",
"settings.reaction.celebrating.description": "Positive manual reaction.",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "Plugin Platform",
"settings.plugins.title": "Plugin Permissions & AI",
"settings.plugins.description": "Global gates for what plugins may do. Sensitive capabilities stay off until you enable them here.",
"settings.plugins.audio.title": "Plugins may play sound",
"settings.plugins.audio.description": "Allow plugin chimes, alerts, and bundled sounds.",
"settings.plugins.voice.title": "Plugins may speak (voice)",
"settings.plugins.voice.description": "Allow text-to-speech through the system voice.",
"settings.plugins.dynamicSpeech.title": "Allow AI-generated pet speech",
"settings.plugins.dynamicSpeech.description": "Sensitive: lets approved plugins show model-generated bubbles.",
"settings.plugins.microphone.title": "Allow microphone (push-to-talk)",
"settings.plugins.microphone.description": "Sensitive: lets approved plugins capture one-shot voice input.",
"settings.plugins.quietHours.title": "Quiet hours",
"settings.plugins.quietHours.description": "Silence plugin speech, sound, and voice during this window.",
"settings.plugins.quietWindow.title": "Quiet window",
"settings.plugins.quietWindow.description": "Start and end of the quiet-hours window.",
"settings.plugins.aiProvider.title": "AI provider",
"settings.plugins.aiProvider.description": "One provider serves every plugin through the host AI gateway. Keys are encrypted and never shared with plugin code.",
"settings.plugins.aiProvider.disabled": "Disabled",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama (local)",
"settings.plugins.model.title": "Model",
"settings.plugins.model.description": "Leave empty for the provider default.",
"settings.plugins.model.placeholder": "provider default",
"settings.plugins.apiKey.title": "API key",
"settings.plugins.apiKey.stored": "A key is stored (encrypted).",
"settings.plugins.apiKey.none": "No key stored. Ollama needs no key.",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "Paste key",
"settings.plugins.apiKey.save": "Save",
"settings.plugins.apiKey.remove": "Remove",
"settings.toast.audioSaved": "Plugin sound preference saved.",
"settings.toast.voiceSaved": "Plugin voice preference saved.",
"settings.toast.dynamicSpeechSaved": "AI speech preference saved.",
"settings.toast.microphoneSaved": "Microphone preference saved.",
"settings.toast.quietHoursSaved": "Quiet hours saved.",
"settings.toast.aiProviderSaved": "AI provider saved.",
"settings.toast.aiModelSaved": "AI model saved.",
"settings.toast.aiKeySaved": "AI key saved.",
"settings.toast.aiKeyRemoved": "AI key removed.",
// --- Plugins view (renderer) ---
"plugins.filter.all": "All",
"plugins.filter.installed": "Installed",
"plugins.filter.catalog": "Catalog",
"plugins.filter.local": "Local / Dev",
"plugins.filter.broken": "Broken",
"plugins.status.broken": "Broken",
"plugins.status.catalogDisabled": "Catalog disabled",
"plugins.status.active": "Active",
"plugins.status.disabled": "Disabled",
"plugins.status.available": "Available",
"plugins.description.installedReady": "Installed plugin ready for configuration.",
"plugins.description.availableCatalog": "Available from the plugin catalog.",
"plugins.badge.bundled": "Bundled",
"plugins.badge.local": "Local",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "Declarative",
"plugins.badge.deprecated": "Deprecated",
"plugins.card.active": "Active",
"plugins.card.off": "Off",
"plugins.card.configure": "Configure",
"plugins.card.installPlugin": "Install Plugin",
"plugins.empty.title": "No plugins found",
"plugins.empty.description": "Try a different filter, refresh the catalog, or load a local plugin folder.",
"plugins.footer.installed": "installed",
"plugins.footer.catalog": "catalog",
"plugins.footer.refresh": "Refresh",
"plugins.footer.loadLocal": "Load Local Plugin",
"plugins.inspector.configAria": "{name} configuration",
"plugins.inspector.closeAria": "Close plugin configuration",
"plugins.inspector.details": "Plugin Details",
"plugins.inspector.close": "Close",
"plugins.inspector.runtime": "Runtime",
"plugins.inspector.statePermissions": "State & permissions",
"plugins.inspector.enabled": "Enabled",
"plugins.inspector.disabled": "Disabled",
"plugins.inspector.catalogDisabledNote": "This plugin is disabled by the catalog.",
"plugins.inspector.toggleNote": "Toggle this plugin without leaving the Control Center.",
"plugins.inspector.noPermissions": "No permissions",
"plugins.inspector.configuration": "Configuration",
"plugins.inspector.needsAttention": "Needs attention",
"plugins.inspector.settings": "Settings",
"plugins.inspector.saveConfiguration": "Save Configuration",
"plugins.inspector.commands": "Commands",
"plugins.inspector.quickActions": "Quick actions",
"plugins.inspector.reload": "Reload",
"plugins.inspector.update": "Update",
"plugins.inspector.uninstall": "Uninstall",
"plugins.inspector.uninstallConfirm": "Uninstall {name}?",
"plugins.inspector.catalog": "Catalog",
"plugins.inspector.readyToInstall": "Ready to install",
"plugins.inspector.catalogDescription": "Install this plugin to approve its permissions and make it available in your desktop companion.",
"plugins.inspector.installPlugin": "Install Plugin",
"plugins.emptyDetail.title": "No plugin selected",
"plugins.emptyDetail.description": "Install a catalog plugin or load a local folder to begin.",
"plugins.config.addReminder": "Add reminder",
"plugins.config.addItem": "Add item",
"plugins.config.item": "Item {index}",
"plugins.config.remove": "Remove",
"plugins.config.defaultSound": "Default alert sound",
"plugins.config.browseSound": "Browse…",
"plugins.config.useDefaultSound": "Use default",
"plugins.config.clearSound": "Clear",
"plugins.config.removeReminder": "Remove reminder",
"plugins.config.reminder": "Reminder",
"plugins.config.dailyAt": "{id} · Daily at {time}",
"plugins.config.everyMin": "{id} · Every {mins} min",
"plugins.config.group.identity": "Identity & Behavior",
"plugins.config.group.message": "Message",
"plugins.config.group.schedule": "Schedule",
"plugins.toast.pluginEnabled": "Plugin enabled.",
"plugins.toast.pluginDisabled": "Plugin disabled.",
"plugins.toast.noPluginInstalled": "No plugin installed.",
"plugins.toast.pluginInstalled": "Plugin installed.",
"plugins.toast.pluginUpdated": "Plugin updated.",
"plugins.toast.noPluginUpdate": "No plugin update applied.",
"plugins.toast.configSaved": "Plugin configuration saved.",
"plugins.toast.soundImported": "Sound imported.",
"plugins.toast.commandRan": "Plugin command ran.",
"plugins.toast.pluginReloaded": "Plugin reloaded.",
"plugins.toast.pluginUninstalled": "Plugin uninstalled.",
"plugins.toast.catalogRefreshed": "Plugin catalog refreshed.",
"plugins.toast.localLoaded": "Local plugin loaded.",
"plugins.toast.noLocalLoaded": "No local plugin loaded.",
"plugins.busy.saving": "Saving",
"plugins.busy.installing": "Installing",
"plugins.busy.refreshing": "Refreshing",
"plugins.busy.loading": "Loading",
"plugins.busy.running": "Running",
"plugins.busy.reloading": "Reloading",
"plugins.busy.updating": "Updating",
"plugins.busy.uninstalling": "Uninstalling",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Speech",
"plugins.permission.pet:reaction": "Reactions",
"plugins.permission.pet:move": "Movement",
"plugins.permission.timer": "Timers",
"plugins.permission.schedule": "Schedule",
"plugins.permission.storage": "Storage",
"plugins.permission.status": "Status",
"plugins.permission.commands": "Commands",
"plugins.permission.network": "Network",
"plugins.permission.pet:interact": "Bubble buttons",
"plugins.permission.pet:pin": "Pinned bubble",
"plugins.permission.pet:animate": "Custom animation",
"plugins.permission.pet:speak:dynamic": "AI speech",
"plugins.permission.pet:drop": "Drag & drop",
"plugins.permission.pets:read": "Read pets",
"plugins.permission.pets:manage": "Manage pets",
"plugins.permission.audio": "Sound",
"plugins.permission.events": "Events",
"plugins.permission.ui:toast": "Toasts",
"plugins.permission.ui:panel": "Panels",
"plugins.permission.notify": "Notifications",
"plugins.permission.bus": "Plugin bus",
"plugins.permission.ai": "AI gateway",
"plugins.permission.secrets": "Secrets",
"plugins.permission.voice:speak": "Voice",
"plugins.permission.voice:listen": "Microphone",
"plugins.permission.auth": "Sign-in",
"plugins.permission.files": "Files",
"plugins.permission.system:openExternal": "Open links",
"plugins.permission.system:metrics": "System metrics",
"plugins.permission.clipboard": "Clipboard",
"plugins.permission.network:write": "Network write",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "Published package",
"integrations.commandMode.bundled": "Bundled desktop CLI",
"integrations.commandMode.local": "Local development",
"integrations.loading": "Loading integrations…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Connect Claude Code to your OpenPets companion.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Connect OpenCode globally to your OpenPets companion.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Connect Cursor to your OpenPets companion via global MCP config.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Connect Pi coding-agent activity through the OpenPets Pi extension package.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "Soon",
"integrations.soon.description": "Coming soon.",
"integrations.soon.button": "Coming soon",
"integrations.install": "Install",
"integrations.viewSetup": "View Setup",
"integrations.configure": "Configure",
"integrations.closeAria": "Close integration detail",
"integrations.detail": "Integration Detail",
"integrations.close": "Close",
"integrations.commandSource": "Command Source",
"integrations.cliMode": "CLI mode",
"integrations.localUnavailable": " unavailable",
"integrations.commandModeHelp": "Use the published package for normal setup, bundled for the desktop app build, or local while developing OpenPets.",
"integrations.connection": "Connection",
"integrations.statusRouting": "Status & Routing",
"integrations.globalSetup": "Global Setup",
"integrations.globalMcp": "Global MCP",
"integrations.petRouting": "Pet Routing",
"integrations.defaultPet": "Default Pet",
"integrations.configuration": "Configuration",
"integrations.commandPaths": "Command Paths",
"integrations.claudeCommand": "Claude Command",
"integrations.nodeCommand": "Node.js Command",
"integrations.opencodeCommand": "OpenCode Command",
"integrations.optional": "Optional",
"integrations.claudeHooks": "Claude Hooks",
"integrations.installHooks": "Install Hooks",
"integrations.removeHooks": "Remove Hooks",
"integrations.included": "Included",
"integrations.instructions": "Instructions",
"integrations.updateInstructions": "Update Instructions",
"integrations.actions": "Actions",
"integrations.management": "Management",
"integrations.installMcp": "Install MCP",
"integrations.replaceMcp": "Replace MCP",
"integrations.removeMcp": "Remove MCP",
"integrations.refreshStatus": "Refresh Status",
"integrations.installGlobal": "Install Global",
"integrations.removeGlobal": "Remove Global",
"integrations.advanced": "Advanced",
"integrations.mcpJsonPreview": "MCP JSON Preview",
"integrations.configPreview": "Config Preview",
"integrations.mcpEntryPreview": "MCP Entry Preview",
"integrations.rulesPreview": "Rules Preview",
"integrations.pi.manualSetup": "Manual Setup",
"integrations.pi.extension": "Pi Extension",
"integrations.pi.intro": "Install the OpenPets Pi extension from Pi, then use the slash commands inside a Pi session.",
"integrations.pi.globalInstall": "Global install",
"integrations.pi.projectInstall": "Project install",
"integrations.pi.remove": "Remove",
"integrations.pi.slashCommands": "Slash commands",
"integrations.pi.outro": "Use global install for all Pi workspaces, or project install when you only want OpenPets in the current project.",
"integrations.toast.pathSaved": "Path saved.",
"integrations.busy.installing": "Installing",
"integrations.busy.replacing": "Replacing",
"integrations.busy.removing": "Removing",
"integrations.busy.installingHooks": "Installing hooks",
"integrations.busy.removingHooks": "Removing hooks",
"integrations.busy.updatingInstructions": "Updating instructions",
"integrations.busy.savingPath": "Saving path",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Language",
"settings.language.description": "Display language for OpenPets menus and windows.",
"settings.language.system": "System default",
} as const;

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// Español (Latinoamérica) — Latin American Spanish (Argentina, etc.)
export const es419: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Actualización disponible: {version}...",
"tray.defaultPet": "Mascota predeterminada: {name}",
"tray.showDefaultPet": "Mostrar mascota predeterminada",
"tray.hideDefaultPet": "Ocultar mascota predeterminada",
"tray.pauseAllPets": "Pausar todas las mascotas",
"tray.resumeAllPets": "Reanudar todas las mascotas",
"tray.managePets": "Administrar mascotas...",
"tray.controlCenter": "Centro de control...",
"tray.website": "Sitio web...",
"tray.integrations": "Integraciones...",
"tray.plugins": "Complementos...",
"tray.settings": "Configuración...",
"tray.openLogsFolder": "Abrir carpeta de registros...",
"tray.quit": "Salir de OpenPets",
// --- Shared ---
"common.latest": "más reciente",
"common.builtInPet": "Mascota integrada",
"common.cancel": "Cancelar",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "En pausa",
"pet.status.thinking": "Pensando",
"pet.status.working": "Trabajando",
"pet.status.editing": "Editando",
"pet.status.testing": "Probando",
"pet.status.waiting": "Esperando",
"pet.status.done": "Listo",
"pet.status.oops": "Ups",
"pet.status.hi": "Hola",
"pet.menu.hidePet": "Ocultar mascota",
"pet.menu.closePet": "Cerrar mascota",
"pet.menu.openControlCenter": "Abrir Centro de control",
// --- Common (renderer, shared across views) ---
"common.retry": "Reintentar",
"common.close": "Cerrar",
"common.save": "Guardar",
"common.to": "a",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Panel",
"nav.pets": "Mascotas",
"nav.settings": "Configuración",
"nav.plugins": "Complementos",
"nav.integrations": "Integraciones",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Panel",
"route.dashboard.description": "Resumen de tus compañeros activos, su estado y las métricas del sistema.",
"route.pets.title": "Mascotas",
"route.pets.description": "Instala, importa, previsualiza y elige tu compañero de escritorio predeterminado.",
"route.settings.title": "Configuración",
"route.settings.description": "Configura los comportamientos de inicio, las preferencias de escala y los ajustes de animación.",
"route.plugins.title": "Complementos",
"route.plugins.description": "Amplía tu experiencia de escritorio con herramientas y comportamientos personalizados.",
"route.integrations.title": "Integraciones",
"route.integrations.description": "Conecta tus compañeros con Claude Code, VS Code, Cursor y más.",
// --- App shell (renderer) ---
"app.controlCenter": "Centro de control",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "Recopilando métricas del compañero...",
"dashboard.hero.eyebrow": "Compañero principal",
"dashboard.hero.desc": "Listo para tu próxima sesión de programación.",
"dashboard.hero.changePet": "Cambiar mascota",
"dashboard.lastActive.none": "Aún sin actividad",
"dashboard.update.available": "Actualización disponible",
"dashboard.update.error": "Falló la verificación",
"dashboard.update.checking": "Verificando",
"dashboard.update.current": "Actual",
"dashboard.update.notChecked": "Sin verificar",
"dashboard.stat.messages": "Mensajes",
"dashboard.stat.messages.footer": "Total de globos de diálogo enviados",
"dashboard.stat.reactions": "Reacciones",
"dashboard.stat.reactions.footer": "Total de animaciones activadas",
"dashboard.stat.topCompanion": "Compañero destacado",
"dashboard.stat.topCompanion.footer": "Mascota más activa últimamente",
"dashboard.activity.title": "Resumen de actividad",
"dashboard.activity.topReactions": "Reacciones principales",
"dashboard.activity.noReactions": "Aún no se registran reacciones. ¡Empieza a programar!",
"dashboard.reactionMix.title": "Mezcla de reacciones",
"dashboard.reactionMix.total": "{count} en total",
"dashboard.reactionMix.waiting": "Esperando actividad",
"dashboard.reactionMix.chartLabel": "Gráfico de mezcla de reacciones",
"dashboard.reactionMix.reactions": "reacciones",
"dashboard.reactionMix.empty": "Aún no hay mezcla de reacciones.",
"dashboard.companions.title": "Compañeros principales",
"dashboard.companions.subtitle": "Mascotas más activas",
"dashboard.companions.empty": "Aún no hay actividad de compañeros.",
"dashboard.lastActive.label": "Última actividad: ",
"dashboard.system.title": "Estado del sistema",
"dashboard.system.pets": "Mascotas",
"dashboard.system.pets.value": "{count} instaladas",
"dashboard.system.plugins": "Complementos",
"dashboard.system.plugins.enabled": "{count} habilitados",
"dashboard.system.catalog": "Catálogo",
"dashboard.system.catalog.offline": "Sin conexión",
"dashboard.system.catalog.pets": "{count} mascotas",
"dashboard.system.catalog.ready": "Listo",
"dashboard.system.updates": "Actualizaciones",
"dashboard.system.version": "Versión",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Próximamente • Siguiente objetivo de migración",
// --- Pets filters (renderer) ---
"pets.filter.all": "Todas",
"pets.filter.installed": "Instaladas",
"pets.filter.featured": "Destacadas",
"pets.filter.originals": "Originales",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Buscar mascotas...",
"pets.import": "Importar mascota",
"pets.gallery": "Galería",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Predeterminada",
"pets.badge.original": "Original",
"pets.badge.featured": "Destacada",
"pets.badge.installed": "Instalada",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Dañada",
"pets.badge.ready": "Lista",
"pets.badge.originals": "Originales",
"pets.action.viewPet": "Ver mascota",
"pets.action.install": "Instalar",
"pets.action.import": "Importar",
"pets.action.default": "Predeterminada",
"pets.action.remove": "Quitar",
"pets.action.refresh": "Actualizar",
"pets.aria.view": "Ver {name}",
"pets.aria.install": "Instalar {name}",
"pets.aria.import": "Importar {name} desde Codex",
"pets.aria.setDefault": "Establecer {name} como predeterminada",
"pets.aria.remove": "Quitar {name}",
"pets.busy.installing": "Instalando",
"pets.busy.importing": "Importando",
"pets.busy.settingDefault": "Estableciendo predeterminada",
"pets.busy.removing": "Quitando",
"pets.busy.loadingPage": "Cargando página",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Anterior",
"pets.pager.next": "Siguiente",
"pets.pager.count": "{count} mascotas",
"pets.pager.page": " · Página {page} de {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "Detalles de la mascota {name}",
"pets.detail.closeAria": "Cerrar detalles de la mascota",
"pets.detail.eyebrow": "Detalle de la mascota",
"pets.detail.previewAnimations": "Previsualizar animaciones",
"pets.detail.preview.idle": "Inactiva",
"pets.detail.preview.thinking": "Pensando",
"pets.detail.preview.happy": "Feliz",
"pets.detail.preview.wave": "Saludo",
"pets.detail.installPet": "Instalar mascota",
"pets.detail.importCodexPet": "Importar mascota de Codex",
"pets.detail.setDefaultPet": "Establecer como predeterminada",
"pets.detail.remove": "Quitar",
"pets.detail.refresh": "Actualizar",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "Esta mascota instalada está dañada y no se puede seleccionar como predeterminada.",
"pets.status.defaultProtected": "Mascota integrada predeterminada. Protegida contra eliminación.",
"pets.status.default": "Mascota predeterminada.",
"pets.status.installedCodex": "Instalada y lista para convertirse en tu mascota predeterminada. También se encuentra en ~/.codex/pets.",
"pets.status.installed": "Instalada y lista para convertirse en tu mascota predeterminada.",
"pets.status.availableCodex": "Disponible para importar desde ~/.codex/pets.",
"pets.status.availableCatalog": "Disponible para instalar desde el catálogo.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "Miniatura de {name}",
"pets.spriteLabel.thumb": "Miniatura de {name}",
"pets.spriteLabel.animatedPreview": "Vista previa animada de {name}",
"pets.spriteLabel.statePreview": "Vista previa de {name} en estado {state}",
// --- Settings: general (renderer) ---
"settings.nav.general": "General",
"settings.nav.reactions": "Asignación de reacciones",
"settings.nav.plugins": "Plataforma de complementos",
"settings.general.eyebrow": "Entorno",
"settings.general.title": "Configuración general",
"settings.general.showOnLaunch.title": "Mostrar la mascota al iniciar",
"settings.general.showOnLaunch.description": "Mantén OpenPets en la bandeja pero oculta la mascota hasta que la solicites.",
"settings.general.launchAtLogin.title": "Iniciar al iniciar sesión",
"settings.general.launchAtLogin.supported": "Inicia OpenPets automáticamente cuando se enciende tu computadora.",
"settings.general.launchAtLogin.unsupported": "No es compatible con esta plataforma.",
"settings.general.petScale.title": "Escala de la mascota",
"settings.general.petScale.description": "Ajusta el tamaño con el que aparece la mascota de escritorio predeterminada.",
"settings.general.resetPosition": "Restablecer posición de la mascota",
"settings.general.systemStatus": "Estado del sistema",
"settings.general.updateAvailable": "Actualización disponible",
"settings.general.checking": "Verificando…",
"settings.general.checkForUpdates": "Buscar actualizaciones",
"settings.toast.startupSaved": "Preferencia de inicio guardada.",
"settings.toast.loginStartupSaved": "Preferencia de inicio de sesión guardada.",
"settings.toast.petScaleSaved": "Escala de la mascota guardada.",
"settings.toast.positionReset": "Posición de la mascota predeterminada restablecida.",
"settings.busy.saving": "Guardando",
"settings.busy.resetting": "Restableciendo",
"settings.busy.opening": "Abriendo",
"settings.busy.checking": "Verificando",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "El estado de la actualización aún no se ha cargado.",
"settings.update.checking": "Buscando actualizaciones…",
"settings.update.available": "La versión {version} está disponible.",
"settings.update.current": "Está actualizado.",
"settings.update.failed": "Falló la verificación de actualizaciones.",
"settings.update.version": "Versión: {version}.",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "Comportamiento",
"settings.reactions.title": "Asignación de reacciones",
"settings.reactions.resetDefaults": "Restablecer valores predeterminados",
"settings.reactions.description": "Personaliza qué animación se reproduce para cada reacción del agente. Las vistas previas usan la mascota predeterminada.",
"settings.reactions.previewAria": "Animación: {state}",
"settings.toast.reactionsReset": "Animaciones de reacción restablecidas.",
"settings.toast.reactionSaved": "Animación de reacción guardada.",
"settings.animation.idle.label": "Inactivo",
"settings.animation.idle.description": "Neutral/sin movimiento especial.",
"settings.animation.review.label": "Revisión",
"settings.animation.review.description": "Pensar, leer, revisar.",
"settings.animation.running.label": "En ejecución",
"settings.animation.running.description": "Trabajo activo, edición, ejecución.",
"settings.animation.waiting.label": "Esperando",
"settings.animation.waiting.description": "Esperando, bloqueado, probando o pendiente de permisos.",
"settings.animation.waving.label": "Saludando",
"settings.animation.waving.description": "Atención, saludo, notificación.",
"settings.animation.jumping.label": "Saltando",
"settings.animation.jumping.description": "Éxito, celebración.",
"settings.animation.failed.label": "Falló",
"settings.animation.failed.description": "Error o falla.",
"settings.reaction.idle.label": "Inactivo",
"settings.reaction.idle.description": "Reacción neutral explícita.",
"settings.reaction.thinking.label": "Pensando",
"settings.reaction.thinking.description": "El agente está razonando o revisando.",
"settings.reaction.working.label": "Trabajando",
"settings.reaction.working.description": "El agente está usando herramientas.",
"settings.reaction.editing.label": "Editando",
"settings.reaction.editing.description": "El agente está cambiando archivos.",
"settings.reaction.running.label": "Ejecutando",
"settings.reaction.running.description": "El agente está ejecutando un comando.",
"settings.reaction.testing.label": "Probando",
"settings.reaction.testing.description": "El agente está ejecutando verificaciones.",
"settings.reaction.waiting.label": "Esperando",
"settings.reaction.waiting.description": "El agente está bloqueado o esperando permisos.",
"settings.reaction.waving.label": "Saludando",
"settings.reaction.waving.description": "La mascota saluda o llama la atención.",
"settings.reaction.success.label": "Éxito",
"settings.reaction.success.description": "La tarea se completó correctamente.",
"settings.reaction.error.label": "Error",
"settings.reaction.error.description": "Algo falló.",
"settings.reaction.celebrating.label": "Celebrando",
"settings.reaction.celebrating.description": "Reacción manual positiva.",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "Plataforma de complementos",
"settings.plugins.title": "Permisos de complementos e IA",
"settings.plugins.description": "Controles globales de lo que pueden hacer los complementos. Las funciones sensibles permanecen desactivadas hasta que las habilites aquí.",
"settings.plugins.audio.title": "Los complementos pueden reproducir sonido",
"settings.plugins.audio.description": "Permite tonos, alertas y sonidos incluidos de los complementos.",
"settings.plugins.voice.title": "Los complementos pueden hablar (voz)",
"settings.plugins.voice.description": "Permite la conversión de texto a voz mediante la voz del sistema.",
"settings.plugins.dynamicSpeech.title": "Permitir habla de la mascota generada por IA",
"settings.plugins.dynamicSpeech.description": "Sensible: permite que los complementos aprobados muestren globos generados por el modelo.",
"settings.plugins.microphone.title": "Permitir micrófono (presionar para hablar)",
"settings.plugins.microphone.description": "Sensible: permite que los complementos aprobados capturen una entrada de voz puntual.",
"settings.plugins.quietHours.title": "Horas de silencio",
"settings.plugins.quietHours.description": "Silencia el habla, el sonido y la voz de los complementos durante este intervalo.",
"settings.plugins.quietWindow.title": "Intervalo de silencio",
"settings.plugins.quietWindow.description": "Inicio y fin del intervalo de horas de silencio.",
"settings.plugins.aiProvider.title": "Proveedor de IA",
"settings.plugins.aiProvider.description": "Un solo proveedor atiende a todos los complementos a través de la puerta de enlace de IA del host. Las claves se cifran y nunca se comparten con el código de los complementos.",
"settings.plugins.aiProvider.disabled": "Deshabilitado",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama (local)",
"settings.plugins.model.title": "Modelo",
"settings.plugins.model.description": "Déjalo vacío para usar el predeterminado del proveedor.",
"settings.plugins.model.placeholder": "predeterminado del proveedor",
"settings.plugins.apiKey.title": "Clave de API",
"settings.plugins.apiKey.stored": "Hay una clave almacenada (cifrada).",
"settings.plugins.apiKey.none": "No hay clave almacenada. Ollama no necesita clave.",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "Pega la clave",
"settings.plugins.apiKey.save": "Guardar",
"settings.plugins.apiKey.remove": "Quitar",
"settings.toast.audioSaved": "Preferencia de sonido de complementos guardada.",
"settings.toast.voiceSaved": "Preferencia de voz de complementos guardada.",
"settings.toast.dynamicSpeechSaved": "Preferencia de habla por IA guardada.",
"settings.toast.microphoneSaved": "Preferencia de micrófono guardada.",
"settings.toast.quietHoursSaved": "Horas de silencio guardadas.",
"settings.toast.aiProviderSaved": "Proveedor de IA guardado.",
"settings.toast.aiModelSaved": "Modelo de IA guardado.",
"settings.toast.aiKeySaved": "Clave de IA guardada.",
"settings.toast.aiKeyRemoved": "Clave de IA eliminada.",
// --- Plugins view (renderer) ---
"plugins.filter.all": "Todos",
"plugins.filter.installed": "Instalados",
"plugins.filter.catalog": "Catálogo",
"plugins.filter.local": "Local / Desarrollo",
"plugins.filter.broken": "Dañados",
"plugins.status.broken": "Dañado",
"plugins.status.catalogDisabled": "Deshabilitado por el catálogo",
"plugins.status.active": "Activo",
"plugins.status.disabled": "Deshabilitado",
"plugins.status.available": "Disponible",
"plugins.description.installedReady": "Complemento instalado listo para configurar.",
"plugins.description.availableCatalog": "Disponible en el catálogo de complementos.",
"plugins.badge.bundled": "Incluido",
"plugins.badge.local": "Local",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "Declarativo",
"plugins.badge.deprecated": "Obsoleto",
"plugins.card.active": "Activo",
"plugins.card.off": "Desactivado",
"plugins.card.configure": "Configurar",
"plugins.card.installPlugin": "Instalar complemento",
"plugins.empty.title": "No se encontraron complementos",
"plugins.empty.description": "Prueba con otro filtro, actualiza el catálogo o carga una carpeta de complemento local.",
"plugins.footer.installed": "instalados",
"plugins.footer.catalog": "catálogo",
"plugins.footer.refresh": "Actualizar",
"plugins.footer.loadLocal": "Cargar complemento local",
"plugins.inspector.configAria": "Configuración de {name}",
"plugins.inspector.closeAria": "Cerrar configuración del complemento",
"plugins.inspector.details": "Detalles del complemento",
"plugins.inspector.close": "Cerrar",
"plugins.inspector.runtime": "Entorno de ejecución",
"plugins.inspector.statePermissions": "Estado y permisos",
"plugins.inspector.enabled": "Habilitado",
"plugins.inspector.disabled": "Deshabilitado",
"plugins.inspector.catalogDisabledNote": "Este complemento está deshabilitado por el catálogo.",
"plugins.inspector.toggleNote": "Activa o desactiva este complemento sin salir del Centro de control.",
"plugins.inspector.noPermissions": "Sin permisos",
"plugins.inspector.configuration": "Configuración",
"plugins.inspector.needsAttention": "Requiere atención",
"plugins.inspector.settings": "Ajustes",
"plugins.inspector.saveConfiguration": "Guardar configuración",
"plugins.inspector.commands": "Comandos",
"plugins.inspector.quickActions": "Acciones rápidas",
"plugins.inspector.reload": "Recargar",
"plugins.inspector.update": "Actualizar",
"plugins.inspector.uninstall": "Desinstalar",
"plugins.inspector.uninstallConfirm": "¿Desinstalar {name}?",
"plugins.inspector.catalog": "Catálogo",
"plugins.inspector.readyToInstall": "Listo para instalar",
"plugins.inspector.catalogDescription": "Instala este complemento para aprobar sus permisos y hacerlo disponible en tu compañero de escritorio.",
"plugins.inspector.installPlugin": "Instalar complemento",
"plugins.emptyDetail.title": "Ningún complemento seleccionado",
"plugins.emptyDetail.description": "Instala un complemento del catálogo o carga una carpeta local para comenzar.",
"plugins.config.addReminder": "Agregar recordatorio",
"plugins.config.addItem": "Agregar elemento",
"plugins.config.item": "Elemento {index}",
"plugins.config.remove": "Quitar",
"plugins.config.removeReminder": "Quitar recordatorio",
"plugins.config.reminder": "Recordatorio",
"plugins.config.dailyAt": "{id} · Todos los días a las {time}",
"plugins.config.everyMin": "{id} · Cada {mins} min",
"plugins.config.group.identity": "Identidad y comportamiento",
"plugins.config.group.message": "Mensaje",
"plugins.config.group.schedule": "Programación",
"plugins.toast.pluginEnabled": "Complemento habilitado.",
"plugins.toast.pluginDisabled": "Complemento deshabilitado.",
"plugins.toast.noPluginInstalled": "No se instaló ningún complemento.",
"plugins.toast.pluginInstalled": "Complemento instalado.",
"plugins.toast.pluginUpdated": "Complemento actualizado.",
"plugins.toast.noPluginUpdate": "No se aplicó ninguna actualización de complemento.",
"plugins.toast.configSaved": "Configuración del complemento guardada.",
"plugins.toast.commandRan": "Se ejecutó el comando del complemento.",
"plugins.toast.pluginReloaded": "Complemento recargado.",
"plugins.toast.pluginUninstalled": "Complemento desinstalado.",
"plugins.toast.catalogRefreshed": "Catálogo de complementos actualizado.",
"plugins.toast.localLoaded": "Complemento local cargado.",
"plugins.toast.noLocalLoaded": "No se cargó ningún complemento local.",
"plugins.busy.saving": "Guardando",
"plugins.busy.installing": "Instalando",
"plugins.busy.refreshing": "Actualizando",
"plugins.busy.loading": "Cargando",
"plugins.busy.running": "Ejecutando",
"plugins.busy.reloading": "Recargando",
"plugins.busy.updating": "Actualizando",
"plugins.busy.uninstalling": "Desinstalando",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Habla",
"plugins.permission.pet:reaction": "Reacciones",
"plugins.permission.pet:move": "Movimiento",
"plugins.permission.timer": "Temporizadores",
"plugins.permission.schedule": "Programación",
"plugins.permission.storage": "Almacenamiento",
"plugins.permission.status": "Estado",
"plugins.permission.commands": "Comandos",
"plugins.permission.network": "Red",
"plugins.permission.pet:interact": "Botones de globo",
"plugins.permission.pet:pin": "Globo fijado",
"plugins.permission.pet:animate": "Animación personalizada",
"plugins.permission.pet:speak:dynamic": "Habla por IA",
"plugins.permission.pet:drop": "Arrastrar y soltar",
"plugins.permission.pets:read": "Leer mascotas",
"plugins.permission.pets:manage": "Administrar mascotas",
"plugins.permission.audio": "Sonido",
"plugins.permission.events": "Eventos",
"plugins.permission.ui:toast": "Notificaciones emergentes",
"plugins.permission.ui:panel": "Paneles",
"plugins.permission.notify": "Notificaciones",
"plugins.permission.bus": "Bus de complementos",
"plugins.permission.ai": "Puerta de enlace de IA",
"plugins.permission.secrets": "Secretos",
"plugins.permission.voice:speak": "Voz",
"plugins.permission.voice:listen": "Micrófono",
"plugins.permission.auth": "Inicio de sesión",
"plugins.permission.files": "Archivos",
"plugins.permission.system:openExternal": "Abrir enlaces",
"plugins.permission.system:metrics": "Métricas del sistema",
"plugins.permission.clipboard": "Portapapeles",
"plugins.permission.network:write": "Escritura en red",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "Paquete publicado",
"integrations.commandMode.bundled": "CLI de escritorio incluida",
"integrations.commandMode.local": "Desarrollo local",
"integrations.loading": "Cargando integraciones…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Conecta Claude Code a tu compañero de OpenPets.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Conecta OpenCode globalmente a tu compañero de OpenPets.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Conecta Cursor a tu compañero de OpenPets mediante la configuración global de MCP.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Conecta la actividad del agente de programación Pi mediante el paquete de extensión Pi de OpenPets.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "Pronto",
"integrations.soon.description": "Próximamente.",
"integrations.soon.button": "Próximamente",
"integrations.install": "Instalar",
"integrations.viewSetup": "Ver configuración",
"integrations.configure": "Configurar",
"integrations.closeAria": "Cerrar detalle de la integración",
"integrations.detail": "Detalle de la integración",
"integrations.close": "Cerrar",
"integrations.commandSource": "Origen del comando",
"integrations.cliMode": "Modo de CLI",
"integrations.localUnavailable": " no disponible",
"integrations.commandModeHelp": "Usa el paquete publicado para una configuración normal, el incluido para la compilación de la app de escritorio, o el local mientras desarrollas OpenPets.",
"integrations.connection": "Conexión",
"integrations.statusRouting": "Estado y enrutamiento",
"integrations.globalSetup": "Configuración global",
"integrations.globalMcp": "MCP global",
"integrations.petRouting": "Enrutamiento de mascotas",
"integrations.defaultPet": "Mascota predeterminada",
"integrations.configuration": "Configuración",
"integrations.commandPaths": "Rutas de comandos",
"integrations.claudeCommand": "Comando de Claude",
"integrations.nodeCommand": "Comando de Node.js",
"integrations.opencodeCommand": "Comando de OpenCode",
"integrations.optional": "Opcional",
"integrations.claudeHooks": "Hooks de Claude",
"integrations.installHooks": "Instalar hooks",
"integrations.removeHooks": "Quitar hooks",
"integrations.included": "Incluido",
"integrations.instructions": "Instrucciones",
"integrations.updateInstructions": "Actualizar instrucciones",
"integrations.actions": "Acciones",
"integrations.management": "Administración",
"integrations.installMcp": "Instalar MCP",
"integrations.replaceMcp": "Reemplazar MCP",
"integrations.removeMcp": "Quitar MCP",
"integrations.refreshStatus": "Actualizar estado",
"integrations.installGlobal": "Instalar global",
"integrations.removeGlobal": "Quitar global",
"integrations.advanced": "Avanzado",
"integrations.mcpJsonPreview": "Vista previa del JSON de MCP",
"integrations.configPreview": "Vista previa de la configuración",
"integrations.mcpEntryPreview": "Vista previa de la entrada de MCP",
"integrations.rulesPreview": "Vista previa de reglas",
"integrations.pi.manualSetup": "Configuración manual",
"integrations.pi.extension": "Extensión Pi",
"integrations.pi.intro": "Instala la extensión Pi de OpenPets desde Pi y luego usa los comandos de barra dentro de una sesión de Pi.",
"integrations.pi.globalInstall": "Instalación global",
"integrations.pi.projectInstall": "Instalación del proyecto",
"integrations.pi.remove": "Quitar",
"integrations.pi.slashCommands": "Comandos de barra",
"integrations.pi.outro": "Usa la instalación global para todos los espacios de trabajo de Pi, o la instalación del proyecto cuando solo quieras OpenPets en el proyecto actual.",
"integrations.toast.pathSaved": "Ruta guardada.",
"integrations.busy.installing": "Instalando",
"integrations.busy.replacing": "Reemplazando",
"integrations.busy.removing": "Quitando",
"integrations.busy.installingHooks": "Instalando hooks",
"integrations.busy.removingHooks": "Quitando hooks",
"integrations.busy.updatingInstructions": "Actualizando instrucciones",
"integrations.busy.savingPath": "Guardando ruta",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Idioma",
"settings.language.description": "Idioma de visualización de los menús y ventanas de OpenPets.",
"settings.language.system": "Predeterminado del sistema",
};

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// 日本語 (Japanese)
export const ja: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "アップデートがあります: {version}...",
"tray.defaultPet": "デフォルトのペット: {name}",
"tray.showDefaultPet": "デフォルトのペットを表示",
"tray.hideDefaultPet": "デフォルトのペットを非表示",
"tray.pauseAllPets": "すべてのペットを一時停止",
"tray.resumeAllPets": "すべてのペットを再開",
"tray.managePets": "ペットを管理...",
"tray.controlCenter": "コントロールセンター...",
"tray.website": "ウェブサイト...",
"tray.integrations": "連携...",
"tray.plugins": "プラグイン...",
"tray.settings": "設定...",
"tray.openLogsFolder": "ログフォルダを開く...",
"tray.quit": "OpenPets を終了",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "組み込みペット",
"common.cancel": "キャンセル",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "一時停止中",
"pet.status.thinking": "思考中",
"pet.status.working": "作業中",
"pet.status.editing": "編集中",
"pet.status.testing": "テスト中",
"pet.status.waiting": "待機中",
"pet.status.done": "完了",
"pet.status.oops": "エラー",
"pet.status.hi": "こんにちは",
"pet.menu.hidePet": "ペットを隠す",
"pet.menu.closePet": "ペットを閉じる",
"pet.menu.openControlCenter": "コントロールセンターを開く",
// --- Common (renderer, shared across views) ---
"common.retry": "再試行",
"common.close": "閉じる",
"common.save": "保存",
"common.to": "〜",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "ダッシュボード",
"nav.pets": "ペット",
"nav.settings": "設定",
"nav.plugins": "プラグイン",
"nav.integrations": "連携",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "ダッシュボード",
"route.dashboard.description": "アクティブな相棒、ステータス、システム指標の概要。",
"route.pets.title": "ペット",
"route.pets.description": "デスクトップの相棒をインストール、インポート、プレビューして選びましょう。",
"route.settings.title": "設定",
"route.settings.description": "起動時の動作、サイズ設定、アニメーション設定を構成します。",
"route.plugins.title": "プラグイン",
"route.plugins.description": "カスタムツールや動作でデスクトップ体験を拡張します。",
"route.integrations.title": "連携",
"route.integrations.description": "相棒を Claude Code、VS Code、Cursor などに接続します。",
// --- App shell (renderer) ---
"app.controlCenter": "コントロールセンター",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "相棒の指標を収集中...",
"dashboard.hero.eyebrow": "メインの相棒",
"dashboard.hero.desc": "次のコーディングセッションの準備は万全です。",
"dashboard.hero.changePet": "ペットを変更",
"dashboard.lastActive.none": "まだ活動がありません",
"dashboard.update.available": "アップデートあり",
"dashboard.update.error": "確認に失敗",
"dashboard.update.checking": "確認中",
"dashboard.update.current": "最新",
"dashboard.update.notChecked": "未確認",
"dashboard.stat.messages": "メッセージ",
"dashboard.stat.messages.footer": "送信した吹き出しの合計",
"dashboard.stat.reactions": "リアクション",
"dashboard.stat.reactions.footer": "発生したアニメーションの合計",
"dashboard.stat.topCompanion": "トップの相棒",
"dashboard.stat.topCompanion.footer": "最近もっとも活発なペット",
"dashboard.activity.title": "アクティビティ概要",
"dashboard.activity.topReactions": "トップリアクション",
"dashboard.activity.noReactions": "まだリアクションがありません。コーディングを始めましょう!",
"dashboard.reactionMix.title": "リアクションの内訳",
"dashboard.reactionMix.total": "合計 {count} 件",
"dashboard.reactionMix.waiting": "アクティビティを待機中",
"dashboard.reactionMix.chartLabel": "リアクション内訳チャート",
"dashboard.reactionMix.reactions": "リアクション",
"dashboard.reactionMix.empty": "まだリアクションの内訳がありません。",
"dashboard.companions.title": "トップの相棒",
"dashboard.companions.subtitle": "もっとも活発なペット",
"dashboard.companions.empty": "まだ相棒の活動がありません。",
"dashboard.lastActive.label": "最終アクティブ: ",
"dashboard.system.title": "システムの状態",
"dashboard.system.pets": "ペット",
"dashboard.system.pets.value": "{count} 個インストール済み",
"dashboard.system.plugins": "プラグイン",
"dashboard.system.plugins.enabled": "{count} 個有効",
"dashboard.system.catalog": "カタログ",
"dashboard.system.catalog.offline": "オフライン",
"dashboard.system.catalog.pets": "{count} 個のペット",
"dashboard.system.catalog.ready": "準備完了",
"dashboard.system.updates": "アップデート",
"dashboard.system.version": "バージョン",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "近日公開 • 次の移行対象",
// --- Pets filters (renderer) ---
"pets.filter.all": "すべて",
"pets.filter.installed": "インストール済み",
"pets.filter.featured": "おすすめ",
"pets.filter.originals": "オリジナル",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "ペットを検索...",
"pets.import": "ペットをインポート",
"pets.gallery": "ギャラリー",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "デフォルト",
"pets.badge.original": "オリジナル",
"pets.badge.featured": "おすすめ",
"pets.badge.installed": "インストール済み",
"pets.badge.codex": "Codex",
"pets.badge.broken": "破損",
"pets.badge.ready": "準備完了",
"pets.badge.originals": "オリジナル",
"pets.action.viewPet": "ペットを表示",
"pets.action.install": "インストール",
"pets.action.import": "インポート",
"pets.action.default": "デフォルト",
"pets.action.remove": "削除",
"pets.action.refresh": "更新",
"pets.aria.view": "{name} を表示",
"pets.aria.install": "{name} をインストール",
"pets.aria.import": "{name} を Codex からインポート",
"pets.aria.setDefault": "{name} をデフォルトに設定",
"pets.aria.remove": "{name} を削除",
"pets.busy.installing": "インストール中",
"pets.busy.importing": "インポート中",
"pets.busy.settingDefault": "デフォルトに設定中",
"pets.busy.removing": "削除中",
"pets.busy.loadingPage": "ページを読み込み中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "前へ",
"pets.pager.next": "次へ",
"pets.pager.count": "{count} 個のペット",
"pets.pager.page": " · {pageCount} ページ中 {page} ページ目",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} のペット詳細",
"pets.detail.closeAria": "ペット詳細を閉じる",
"pets.detail.eyebrow": "ペット詳細",
"pets.detail.previewAnimations": "アニメーションをプレビュー",
"pets.detail.preview.idle": "待機",
"pets.detail.preview.thinking": "思考中",
"pets.detail.preview.happy": "うれしい",
"pets.detail.preview.wave": "手を振る",
"pets.detail.installPet": "ペットをインストール",
"pets.detail.importCodexPet": "Codex ペットをインポート",
"pets.detail.setDefaultPet": "デフォルトのペットに設定",
"pets.detail.remove": "削除",
"pets.detail.refresh": "更新",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "このインストール済みペットは破損しており、デフォルトに設定できません。",
"pets.status.defaultProtected": "デフォルトの組み込みペット。削除から保護されています。",
"pets.status.default": "デフォルトのペット。",
"pets.status.installedCodex": "インストール済みで、デフォルトのペットに設定できます。~/.codex/pets にもあります。",
"pets.status.installed": "インストール済みで、デフォルトのペットに設定できます。",
"pets.status.availableCodex": "~/.codex/pets からインポートできます。",
"pets.status.availableCatalog": "カタログからインストールできます。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} のサムネイル",
"pets.spriteLabel.thumb": "{name} のサムネイル",
"pets.spriteLabel.animatedPreview": "{name} のアニメーションプレビュー",
"pets.spriteLabel.statePreview": "{name} の {state} プレビュー",
// --- Settings: general (renderer) ---
"settings.nav.general": "一般",
"settings.nav.reactions": "リアクション設定",
"settings.nav.plugins": "プラグインプラットフォーム",
"settings.general.eyebrow": "環境",
"settings.general.title": "一般設定",
"settings.general.showOnLaunch.title": "起動時にペットを表示",
"settings.general.showOnLaunch.description": "OpenPets をトレイに常駐させつつ、要求があるまでペットを非表示にします。",
"settings.general.launchAtLogin.title": "ログイン時に起動",
"settings.general.launchAtLogin.supported": "コンピューターの起動時に OpenPets を自動的に開始します。",
"settings.general.launchAtLogin.unsupported": "このプラットフォームではサポートされていません。",
"settings.general.petScale.title": "ペットのサイズ",
"settings.general.petScale.description": "デフォルトのデスクトップペットの表示サイズを調整します。",
"settings.general.resetPosition": "ペットの位置をリセット",
"settings.general.systemStatus": "システムステータス",
"settings.general.updateAvailable": "アップデートあり",
"settings.general.checking": "確認中…",
"settings.general.checkForUpdates": "アップデートを確認",
"settings.toast.startupSaved": "起動時の設定を保存しました。",
"settings.toast.loginStartupSaved": "ログイン起動の設定を保存しました。",
"settings.toast.petScaleSaved": "ペットのサイズを保存しました。",
"settings.toast.positionReset": "デフォルトペットの位置をリセットしました。",
"settings.busy.saving": "保存中",
"settings.busy.resetting": "リセット中",
"settings.busy.opening": "開いています",
"settings.busy.checking": "確認中",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "アップデート状況はまだ読み込まれていません。",
"settings.update.checking": "アップデートを確認中…",
"settings.update.available": "バージョン {version} が利用可能です。",
"settings.update.current": "最新です。",
"settings.update.failed": "アップデートの確認に失敗しました。",
"settings.update.version": "バージョン: {version}。",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "動作",
"settings.reactions.title": "リアクション設定",
"settings.reactions.resetDefaults": "デフォルトに戻す",
"settings.reactions.description": "各エージェントのリアクションで再生するアニメーションをカスタマイズします。プレビューはデフォルトのペットを使用します。",
"settings.reactions.previewAria": "アニメーション: {state}",
"settings.toast.reactionsReset": "リアクションアニメーションをリセットしました。",
"settings.toast.reactionSaved": "リアクションアニメーションを保存しました。",
"settings.animation.idle.label": "待機",
"settings.animation.idle.description": "ニュートラルで特別な動きなし。",
"settings.animation.review.label": "確認",
"settings.animation.review.description": "考える、読む、確認する動き。",
"settings.animation.running.label": "実行中",
"settings.animation.running.description": "作業、編集、実行中の動き。",
"settings.animation.waiting.label": "待機中",
"settings.animation.waiting.description": "待機、ブロック、テスト、許可待ち。",
"settings.animation.waving.label": "手を振る",
"settings.animation.waving.description": "注目、あいさつ、通知。",
"settings.animation.jumping.label": "ジャンプ",
"settings.animation.jumping.description": "成功やお祝い。",
"settings.animation.failed.label": "失敗",
"settings.animation.failed.description": "エラーまたは失敗。",
"settings.reaction.idle.label": "待機",
"settings.reaction.idle.description": "明示的なニュートラル反応。",
"settings.reaction.thinking.label": "思考中",
"settings.reaction.thinking.description": "エージェントが推論または確認中。",
"settings.reaction.working.label": "作業中",
"settings.reaction.working.description": "エージェントが一般的なツール作業中。",
"settings.reaction.editing.label": "編集中",
"settings.reaction.editing.description": "エージェントがファイルを変更中。",
"settings.reaction.running.label": "実行中",
"settings.reaction.running.description": "エージェントがコマンドを実行中。",
"settings.reaction.testing.label": "テスト中",
"settings.reaction.testing.description": "エージェントがチェックを実行中。",
"settings.reaction.waiting.label": "待機中",
"settings.reaction.waiting.description": "エージェントがブロック中または許可待ち。",
"settings.reaction.waving.label": "手を振る",
"settings.reaction.waving.description": "ペットがあいさつまたは注目を促しています。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "タスクが正常に完了しました。",
"settings.reaction.error.label": "エラー",
"settings.reaction.error.description": "何かが失敗しました。",
"settings.reaction.celebrating.label": "お祝い",
"settings.reaction.celebrating.description": "ポジティブな手動リアクション。",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "プラグインプラットフォーム",
"settings.plugins.title": "プラグインの権限と AI",
"settings.plugins.description": "プラグインが行える操作の全体的なゲートです。機微な機能はここで有効にするまでオフのままです。",
"settings.plugins.audio.title": "プラグインによる音の再生を許可",
"settings.plugins.audio.description": "プラグインのチャイム、アラート、付属サウンドを許可します。",
"settings.plugins.voice.title": "プラグインによる音声発話を許可",
"settings.plugins.voice.description": "システム音声によるテキスト読み上げを許可します。",
"settings.plugins.dynamicSpeech.title": "AI 生成のペット発話を許可",
"settings.plugins.dynamicSpeech.description": "機微: 承認済みプラグインがモデル生成の吹き出しを表示できるようにします。",
"settings.plugins.microphone.title": "マイクを許可(プッシュトゥトーク)",
"settings.plugins.microphone.description": "機微: 承認済みプラグインが単発の音声入力を取得できるようにします。",
"settings.plugins.quietHours.title": "サイレント時間",
"settings.plugins.quietHours.description": "この時間帯はプラグインの発話、音、音声を無音にします。",
"settings.plugins.quietWindow.title": "サイレント時間帯",
"settings.plugins.quietWindow.description": "サイレント時間帯の開始と終了。",
"settings.plugins.aiProvider.title": "AI プロバイダー",
"settings.plugins.aiProvider.description": "1 つのプロバイダーがホストの AI ゲートウェイを通じてすべてのプラグインに対応します。キーは暗号化され、プラグインのコードに共有されることはありません。",
"settings.plugins.aiProvider.disabled": "無効",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollamaローカル",
"settings.plugins.model.title": "モデル",
"settings.plugins.model.description": "プロバイダーのデフォルトを使う場合は空のままにします。",
"settings.plugins.model.placeholder": "プロバイダーのデフォルト",
"settings.plugins.apiKey.title": "API キー",
"settings.plugins.apiKey.stored": "キーが保存されています(暗号化済み)。",
"settings.plugins.apiKey.none": "キーは保存されていません。Ollama にキーは不要です。",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "キーを貼り付け",
"settings.plugins.apiKey.save": "保存",
"settings.plugins.apiKey.remove": "削除",
"settings.toast.audioSaved": "プラグインの音の設定を保存しました。",
"settings.toast.voiceSaved": "プラグインの音声設定を保存しました。",
"settings.toast.dynamicSpeechSaved": "AI 発話の設定を保存しました。",
"settings.toast.microphoneSaved": "マイクの設定を保存しました。",
"settings.toast.quietHoursSaved": "サイレント時間を保存しました。",
"settings.toast.aiProviderSaved": "AI プロバイダーを保存しました。",
"settings.toast.aiModelSaved": "AI モデルを保存しました。",
"settings.toast.aiKeySaved": "AI キーを保存しました。",
"settings.toast.aiKeyRemoved": "AI キーを削除しました。",
// --- Plugins view (renderer) ---
"plugins.filter.all": "すべて",
"plugins.filter.installed": "インストール済み",
"plugins.filter.catalog": "カタログ",
"plugins.filter.local": "ローカル / 開発",
"plugins.filter.broken": "破損",
"plugins.status.broken": "破損",
"plugins.status.catalogDisabled": "カタログ無効",
"plugins.status.active": "アクティブ",
"plugins.status.disabled": "無効",
"plugins.status.available": "利用可能",
"plugins.description.installedReady": "インストール済みで設定可能なプラグイン。",
"plugins.description.availableCatalog": "プラグインカタログから利用可能。",
"plugins.badge.bundled": "同梱",
"plugins.badge.local": "ローカル",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "宣言的",
"plugins.badge.deprecated": "非推奨",
"plugins.card.active": "アクティブ",
"plugins.card.off": "オフ",
"plugins.card.configure": "設定",
"plugins.card.installPlugin": "プラグインをインストール",
"plugins.empty.title": "プラグインが見つかりません",
"plugins.empty.description": "別のフィルターを試すか、カタログを更新するか、ローカルのプラグインフォルダを読み込んでください。",
"plugins.footer.installed": "インストール済み",
"plugins.footer.catalog": "カタログ",
"plugins.footer.refresh": "更新",
"plugins.footer.loadLocal": "ローカルプラグインを読み込む",
"plugins.inspector.configAria": "{name} の設定",
"plugins.inspector.closeAria": "プラグイン設定を閉じる",
"plugins.inspector.details": "プラグインの詳細",
"plugins.inspector.close": "閉じる",
"plugins.inspector.runtime": "ランタイム",
"plugins.inspector.statePermissions": "状態と権限",
"plugins.inspector.enabled": "有効",
"plugins.inspector.disabled": "無効",
"plugins.inspector.catalogDisabledNote": "このプラグインはカタログによって無効化されています。",
"plugins.inspector.toggleNote": "コントロールセンターを離れずにこのプラグインを切り替えます。",
"plugins.inspector.noPermissions": "権限なし",
"plugins.inspector.configuration": "設定",
"plugins.inspector.needsAttention": "要対応",
"plugins.inspector.settings": "設定",
"plugins.inspector.saveConfiguration": "設定を保存",
"plugins.inspector.commands": "コマンド",
"plugins.inspector.quickActions": "クイックアクション",
"plugins.inspector.reload": "再読み込み",
"plugins.inspector.update": "アップデート",
"plugins.inspector.uninstall": "アンインストール",
"plugins.inspector.uninstallConfirm": "{name} をアンインストールしますか?",
"plugins.inspector.catalog": "カタログ",
"plugins.inspector.readyToInstall": "インストール準備完了",
"plugins.inspector.catalogDescription": "このプラグインをインストールして権限を承認し、デスクトップの相棒で使えるようにします。",
"plugins.inspector.installPlugin": "プラグインをインストール",
"plugins.emptyDetail.title": "プラグインが選択されていません",
"plugins.emptyDetail.description": "カタログのプラグインをインストールするか、ローカルフォルダを読み込んで始めましょう。",
"plugins.config.addReminder": "リマインダーを追加",
"plugins.config.addItem": "項目を追加",
"plugins.config.item": "項目 {index}",
"plugins.config.remove": "削除",
"plugins.config.removeReminder": "リマインダーを削除",
"plugins.config.reminder": "リマインダー",
"plugins.config.dailyAt": "{id} · 毎日 {time}",
"plugins.config.everyMin": "{id} · {mins} 分ごと",
"plugins.config.group.identity": "アイデンティティと動作",
"plugins.config.group.message": "メッセージ",
"plugins.config.group.schedule": "スケジュール",
"plugins.toast.pluginEnabled": "プラグインを有効にしました。",
"plugins.toast.pluginDisabled": "プラグインを無効にしました。",
"plugins.toast.noPluginInstalled": "インストールされたプラグインはありません。",
"plugins.toast.pluginInstalled": "プラグインをインストールしました。",
"plugins.toast.pluginUpdated": "プラグインを更新しました。",
"plugins.toast.noPluginUpdate": "適用するプラグインの更新はありませんでした。",
"plugins.toast.configSaved": "プラグインの設定を保存しました。",
"plugins.toast.commandRan": "プラグインコマンドを実行しました。",
"plugins.toast.pluginReloaded": "プラグインを再読み込みしました。",
"plugins.toast.pluginUninstalled": "プラグインをアンインストールしました。",
"plugins.toast.catalogRefreshed": "プラグインカタログを更新しました。",
"plugins.toast.localLoaded": "ローカルプラグインを読み込みました。",
"plugins.toast.noLocalLoaded": "読み込まれたローカルプラグインはありません。",
"plugins.busy.saving": "保存中",
"plugins.busy.installing": "インストール中",
"plugins.busy.refreshing": "更新中",
"plugins.busy.loading": "読み込み中",
"plugins.busy.running": "実行中",
"plugins.busy.reloading": "再読み込み中",
"plugins.busy.updating": "更新中",
"plugins.busy.uninstalling": "アンインストール中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "発話",
"plugins.permission.pet:reaction": "リアクション",
"plugins.permission.pet:move": "移動",
"plugins.permission.timer": "タイマー",
"plugins.permission.schedule": "スケジュール",
"plugins.permission.storage": "ストレージ",
"plugins.permission.status": "ステータス",
"plugins.permission.commands": "コマンド",
"plugins.permission.network": "ネットワーク",
"plugins.permission.pet:interact": "吹き出しボタン",
"plugins.permission.pet:pin": "固定吹き出し",
"plugins.permission.pet:animate": "カスタムアニメーション",
"plugins.permission.pet:speak:dynamic": "AI 発話",
"plugins.permission.pet:drop": "ドラッグ&ドロップ",
"plugins.permission.pets:read": "ペットの読み取り",
"plugins.permission.pets:manage": "ペットの管理",
"plugins.permission.audio": "サウンド",
"plugins.permission.events": "イベント",
"plugins.permission.ui:toast": "トースト",
"plugins.permission.ui:panel": "パネル",
"plugins.permission.notify": "通知",
"plugins.permission.bus": "プラグインバス",
"plugins.permission.ai": "AI ゲートウェイ",
"plugins.permission.secrets": "シークレット",
"plugins.permission.voice:speak": "音声",
"plugins.permission.voice:listen": "マイク",
"plugins.permission.auth": "サインイン",
"plugins.permission.files": "ファイル",
"plugins.permission.system:openExternal": "リンクを開く",
"plugins.permission.system:metrics": "システム指標",
"plugins.permission.clipboard": "クリップボード",
"plugins.permission.network:write": "ネットワーク書き込み",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "公開パッケージ",
"integrations.commandMode.bundled": "同梱デスクトップ CLI",
"integrations.commandMode.local": "ローカル開発",
"integrations.loading": "連携を読み込み中…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Claude Code を OpenPets の相棒に接続します。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "OpenCode をグローバルに OpenPets の相棒に接続します。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "グローバル MCP 設定を通じて Cursor を OpenPets の相棒に接続します。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手動",
"integrations.pi.description": "OpenPets Pi 拡張パッケージを通じて Pi コーディングエージェントの活動を接続します。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "近日",
"integrations.soon.description": "近日公開。",
"integrations.soon.button": "近日公開",
"integrations.install": "インストール",
"integrations.viewSetup": "セットアップを表示",
"integrations.configure": "設定",
"integrations.closeAria": "連携詳細を閉じる",
"integrations.detail": "連携の詳細",
"integrations.close": "閉じる",
"integrations.commandSource": "コマンドソース",
"integrations.cliMode": "CLI モード",
"integrations.localUnavailable": " は利用できません",
"integrations.commandModeHelp": "通常のセットアップには公開パッケージ、デスクトップアプリのビルドには同梱、OpenPets の開発時にはローカルを使用します。",
"integrations.connection": "接続",
"integrations.statusRouting": "ステータスとルーティング",
"integrations.globalSetup": "グローバルセットアップ",
"integrations.globalMcp": "グローバル MCP",
"integrations.petRouting": "ペットのルーティング",
"integrations.defaultPet": "デフォルトのペット",
"integrations.configuration": "設定",
"integrations.commandPaths": "コマンドパス",
"integrations.claudeCommand": "Claude コマンド",
"integrations.nodeCommand": "Node.js コマンド",
"integrations.opencodeCommand": "OpenCode コマンド",
"integrations.optional": "任意",
"integrations.claudeHooks": "Claude フック",
"integrations.installHooks": "フックをインストール",
"integrations.removeHooks": "フックを削除",
"integrations.included": "含まれています",
"integrations.instructions": "手順",
"integrations.updateInstructions": "手順を更新",
"integrations.actions": "アクション",
"integrations.management": "管理",
"integrations.installMcp": "MCP をインストール",
"integrations.replaceMcp": "MCP を置き換え",
"integrations.removeMcp": "MCP を削除",
"integrations.refreshStatus": "ステータスを更新",
"integrations.installGlobal": "グローバルにインストール",
"integrations.removeGlobal": "グローバルから削除",
"integrations.advanced": "詳細設定",
"integrations.mcpJsonPreview": "MCP JSON プレビュー",
"integrations.configPreview": "設定プレビュー",
"integrations.mcpEntryPreview": "MCP エントリプレビュー",
"integrations.rulesPreview": "ルールプレビュー",
"integrations.pi.manualSetup": "手動セットアップ",
"integrations.pi.extension": "Pi 拡張機能",
"integrations.pi.intro": "Pi から OpenPets Pi 拡張機能をインストールし、Pi セッション内でスラッシュコマンドを使用します。",
"integrations.pi.globalInstall": "グローバルインストール",
"integrations.pi.projectInstall": "プロジェクトインストール",
"integrations.pi.remove": "削除",
"integrations.pi.slashCommands": "スラッシュコマンド",
"integrations.pi.outro": "すべての Pi ワークスペースに使うにはグローバルインストール、現在のプロジェクトだけで OpenPets を使うにはプロジェクトインストールを選びます。",
"integrations.toast.pathSaved": "パスを保存しました。",
"integrations.busy.installing": "インストール中",
"integrations.busy.replacing": "置き換え中",
"integrations.busy.removing": "削除中",
"integrations.busy.installingHooks": "フックをインストール中",
"integrations.busy.removingHooks": "フックを削除中",
"integrations.busy.updatingInstructions": "手順を更新中",
"integrations.busy.savingPath": "パスを保存中",
// --- Settings: Language section (renderer) ---
"settings.language.title": "言語",
"settings.language.description": "OpenPets のメニューとウィンドウの表示言語。",
"settings.language.system": "システムのデフォルト",
};

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// 한국어 (Korean)
export const ko: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "업데이트 사용 가능: {version}...",
"tray.defaultPet": "기본 펫: {name}",
"tray.showDefaultPet": "기본 펫 표시",
"tray.hideDefaultPet": "기본 펫 숨기기",
"tray.pauseAllPets": "모든 펫 일시정지",
"tray.resumeAllPets": "모든 펫 재개",
"tray.managePets": "펫 관리...",
"tray.controlCenter": "컨트롤 센터...",
"tray.website": "웹사이트...",
"tray.integrations": "연동...",
"tray.plugins": "플러그인...",
"tray.settings": "설정...",
"tray.openLogsFolder": "로그 폴더 열기...",
"tray.quit": "OpenPets 종료",
// --- Shared ---
"common.latest": "최신",
"common.builtInPet": "기본 내장 펫",
"common.cancel": "취소",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "일시정지됨",
"pet.status.thinking": "생각 중",
"pet.status.working": "작업 중",
"pet.status.editing": "편집 중",
"pet.status.testing": "테스트 중",
"pet.status.waiting": "기다리는 중",
"pet.status.done": "완료",
"pet.status.oops": "오류",
"pet.status.hi": "안녕",
"pet.menu.hidePet": "펫 숨기기",
"pet.menu.closePet": "펫 닫기",
"pet.menu.openControlCenter": "컨트롤 센터 열기",
// --- Common (renderer, shared across views) ---
"common.retry": "다시 시도",
"common.close": "닫기",
"common.save": "저장",
"common.to": "~",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "대시보드",
"nav.pets": "펫",
"nav.settings": "설정",
"nav.plugins": "플러그인",
"nav.integrations": "연동",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "대시보드",
"route.dashboard.description": "활성 동반자, 상태, 시스템 지표를 한눈에 확인하세요.",
"route.pets.title": "펫",
"route.pets.description": "기본 데스크톱 동반자를 설치, 가져오기, 미리보기하고 선택하세요.",
"route.settings.title": "설정",
"route.settings.description": "시작 동작, 크기 설정, 애니메이션 설정을 구성하세요.",
"route.plugins.title": "플러그인",
"route.plugins.description": "맞춤 도구와 동작으로 데스크톱 경험을 확장하세요.",
"route.integrations.title": "연동",
"route.integrations.description": "동반자를 Claude Code, VS Code, Cursor 등에 연결하세요.",
// --- App shell (renderer) ---
"app.controlCenter": "컨트롤 센터",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "동반자 지표를 수집하는 중...",
"dashboard.hero.eyebrow": "주요 동반자",
"dashboard.hero.desc": "다음 코딩 세션을 위한 준비 완료.",
"dashboard.hero.changePet": "펫 변경",
"dashboard.lastActive.none": "아직 활동 없음",
"dashboard.update.available": "업데이트 사용 가능",
"dashboard.update.error": "확인 실패",
"dashboard.update.checking": "확인 중",
"dashboard.update.current": "최신 상태",
"dashboard.update.notChecked": "확인 안 함",
"dashboard.stat.messages": "메시지",
"dashboard.stat.messages.footer": "보낸 말풍선 총수",
"dashboard.stat.reactions": "반응",
"dashboard.stat.reactions.footer": "실행된 애니메이션 총수",
"dashboard.stat.topCompanion": "최고 동반자",
"dashboard.stat.topCompanion.footer": "최근 가장 활발한 펫",
"dashboard.activity.title": "활동 개요",
"dashboard.activity.topReactions": "주요 반응",
"dashboard.activity.noReactions": "아직 기록된 반응이 없습니다. 코딩을 시작하세요!",
"dashboard.reactionMix.title": "반응 구성",
"dashboard.reactionMix.total": "총 {count}개",
"dashboard.reactionMix.waiting": "활동 대기 중",
"dashboard.reactionMix.chartLabel": "반응 구성 차트",
"dashboard.reactionMix.reactions": "반응",
"dashboard.reactionMix.empty": "아직 반응 구성이 없습니다.",
"dashboard.companions.title": "최고 동반자",
"dashboard.companions.subtitle": "가장 활발한 펫",
"dashboard.companions.empty": "아직 동반자 활동이 없습니다.",
"dashboard.lastActive.label": "마지막 활동: ",
"dashboard.system.title": "시스템 상태",
"dashboard.system.pets": "펫",
"dashboard.system.pets.value": "{count}개 설치됨",
"dashboard.system.plugins": "플러그인",
"dashboard.system.plugins.enabled": "{count}개 활성화됨",
"dashboard.system.catalog": "카탈로그",
"dashboard.system.catalog.offline": "오프라인",
"dashboard.system.catalog.pets": "펫 {count}개",
"dashboard.system.catalog.ready": "준비됨",
"dashboard.system.updates": "업데이트",
"dashboard.system.version": "버전",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "출시 예정 • 다음 마이그레이션 대상",
// --- Pets filters (renderer) ---
"pets.filter.all": "전체",
"pets.filter.installed": "설치됨",
"pets.filter.featured": "추천",
"pets.filter.originals": "오리지널",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "펫 검색...",
"pets.import": "펫 가져오기",
"pets.gallery": "갤러리",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "기본",
"pets.badge.original": "오리지널",
"pets.badge.featured": "추천",
"pets.badge.installed": "설치됨",
"pets.badge.codex": "Codex",
"pets.badge.broken": "손상됨",
"pets.badge.ready": "준비됨",
"pets.badge.originals": "오리지널",
"pets.action.viewPet": "펫 보기",
"pets.action.install": "설치",
"pets.action.import": "가져오기",
"pets.action.default": "기본",
"pets.action.remove": "제거",
"pets.action.refresh": "새로고침",
"pets.aria.view": "{name} 보기",
"pets.aria.install": "{name} 설치",
"pets.aria.import": "Codex에서 {name} 가져오기",
"pets.aria.setDefault": "{name}을(를) 기본으로 설정",
"pets.aria.remove": "{name} 제거",
"pets.busy.installing": "설치 중",
"pets.busy.importing": "가져오는 중",
"pets.busy.settingDefault": "기본 설정 중",
"pets.busy.removing": "제거 중",
"pets.busy.loadingPage": "페이지 불러오는 중",
// --- Pets pager (renderer) ---
"pets.pager.prev": "이전",
"pets.pager.next": "다음",
"pets.pager.count": "펫 {count}개",
"pets.pager.page": " · {pageCount}페이지 중 {page}페이지",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 펫 상세 정보",
"pets.detail.closeAria": "펫 상세 정보 닫기",
"pets.detail.eyebrow": "펫 상세 정보",
"pets.detail.previewAnimations": "애니메이션 미리보기",
"pets.detail.preview.idle": "대기",
"pets.detail.preview.thinking": "생각 중",
"pets.detail.preview.happy": "행복",
"pets.detail.preview.wave": "손 흔들기",
"pets.detail.installPet": "펫 설치",
"pets.detail.importCodexPet": "Codex 펫 가져오기",
"pets.detail.setDefaultPet": "기본 펫으로 설정",
"pets.detail.remove": "제거",
"pets.detail.refresh": "새로고침",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "이 설치된 펫은 손상되어 기본 펫으로 선택할 수 없습니다.",
"pets.status.defaultProtected": "기본 내장 펫입니다. 제거할 수 없도록 보호됩니다.",
"pets.status.default": "기본 펫입니다.",
"pets.status.installedCodex": "설치되어 기본 펫으로 설정할 준비가 되었습니다. ~/.codex/pets에서도 찾을 수 있습니다.",
"pets.status.installed": "설치되어 기본 펫으로 설정할 준비가 되었습니다.",
"pets.status.availableCodex": "~/.codex/pets에서 가져올 수 있습니다.",
"pets.status.availableCatalog": "카탈로그에서 설치할 수 있습니다.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 썸네일",
"pets.spriteLabel.thumb": "{name} 썸네일",
"pets.spriteLabel.animatedPreview": "{name} 애니메이션 미리보기",
"pets.spriteLabel.statePreview": "{name} {state} 미리보기",
// --- Settings: general (renderer) ---
"settings.nav.general": "일반",
"settings.nav.reactions": "반응 매핑",
"settings.nav.plugins": "플러그인 플랫폼",
"settings.general.eyebrow": "환경",
"settings.general.title": "일반 설정",
"settings.general.showOnLaunch.title": "실행 시 펫 표시",
"settings.general.showOnLaunch.description": "OpenPets를 트레이에 유지하되 요청하기 전까지 펫을 숨깁니다.",
"settings.general.launchAtLogin.title": "로그인 시 실행",
"settings.general.launchAtLogin.supported": "컴퓨터가 시작될 때 OpenPets를 자동으로 실행합니다.",
"settings.general.launchAtLogin.unsupported": "이 플랫폼에서는 지원되지 않습니다.",
"settings.general.petScale.title": "펫 크기",
"settings.general.petScale.description": "기본 데스크톱 펫이 표시되는 크기를 조정합니다.",
"settings.general.resetPosition": "펫 위치 초기화",
"settings.general.systemStatus": "시스템 상태",
"settings.general.updateAvailable": "업데이트 사용 가능",
"settings.general.checking": "확인 중…",
"settings.general.checkForUpdates": "업데이트 확인",
"settings.toast.startupSaved": "시작 설정이 저장되었습니다.",
"settings.toast.loginStartupSaved": "로그인 시작 설정이 저장되었습니다.",
"settings.toast.petScaleSaved": "펫 크기가 저장되었습니다.",
"settings.toast.positionReset": "기본 펫 위치가 초기화되었습니다.",
"settings.busy.saving": "저장 중",
"settings.busy.resetting": "초기화 중",
"settings.busy.opening": "여는 중",
"settings.busy.checking": "확인 중",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "업데이트 상태가 아직 로드되지 않았습니다.",
"settings.update.checking": "업데이트를 확인하는 중…",
"settings.update.available": "버전 {version}을(를) 사용할 수 있습니다.",
"settings.update.current": "최신 상태입니다.",
"settings.update.failed": "업데이트 확인에 실패했습니다.",
"settings.update.version": "버전: {version}.",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "동작",
"settings.reactions.title": "반응 매핑",
"settings.reactions.resetDefaults": "기본값으로 초기화",
"settings.reactions.description": "각 에이전트 반응에 재생할 애니메이션을 맞춤 설정하세요. 미리보기는 기본 펫을 사용합니다.",
"settings.reactions.previewAria": "애니메이션: {state}",
"settings.toast.reactionsReset": "반응 애니메이션이 초기화되었습니다.",
"settings.toast.reactionSaved": "반응 애니메이션이 저장되었습니다.",
"settings.animation.idle.label": "대기",
"settings.animation.idle.description": "중립/특별한 움직임 없음.",
"settings.animation.review.label": "검토",
"settings.animation.review.description": "생각, 읽기, 검토 동작.",
"settings.animation.running.label": "실행 중",
"settings.animation.running.description": "활성 작업, 편집, 실행.",
"settings.animation.waiting.label": "기다리는 중",
"settings.animation.waiting.description": "대기, 차단, 테스트, 권한 대기.",
"settings.animation.waving.label": "손 흔들기",
"settings.animation.waving.description": "주의, 인사, 알림.",
"settings.animation.jumping.label": "점프",
"settings.animation.jumping.description": "성공, 축하.",
"settings.animation.failed.label": "실패",
"settings.animation.failed.description": "오류 또는 실패.",
"settings.reaction.idle.label": "대기",
"settings.reaction.idle.description": "명시적인 중립 반응.",
"settings.reaction.thinking.label": "생각 중",
"settings.reaction.thinking.description": "에이전트가 추론하거나 검토 중입니다.",
"settings.reaction.working.label": "작업 중",
"settings.reaction.working.description": "에이전트가 일반 도구 작업 중입니다.",
"settings.reaction.editing.label": "편집 중",
"settings.reaction.editing.description": "에이전트가 파일을 변경 중입니다.",
"settings.reaction.running.label": "실행 중",
"settings.reaction.running.description": "에이전트가 명령을 실행 중입니다.",
"settings.reaction.testing.label": "테스트 중",
"settings.reaction.testing.description": "에이전트가 검사를 실행 중입니다.",
"settings.reaction.waiting.label": "기다리는 중",
"settings.reaction.waiting.description": "에이전트가 차단되었거나 권한을 기다리는 중입니다.",
"settings.reaction.waving.label": "손 흔들기",
"settings.reaction.waving.description": "펫이 인사하거나 주의를 끕니다.",
"settings.reaction.success.label": "성공",
"settings.reaction.success.description": "작업이 성공적으로 완료되었습니다.",
"settings.reaction.error.label": "오류",
"settings.reaction.error.description": "문제가 발생했습니다.",
"settings.reaction.celebrating.label": "축하",
"settings.reaction.celebrating.description": "긍정적인 수동 반응.",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "플러그인 플랫폼",
"settings.plugins.title": "플러그인 권한 및 AI",
"settings.plugins.description": "플러그인이 할 수 있는 작업에 대한 전역 제어입니다. 민감한 기능은 여기에서 활성화하기 전까지 꺼져 있습니다.",
"settings.plugins.audio.title": "플러그인 소리 재생 허용",
"settings.plugins.audio.description": "플러그인 알림음, 경고음, 번들 사운드를 허용합니다.",
"settings.plugins.voice.title": "플러그인 음성 말하기 허용",
"settings.plugins.voice.description": "시스템 음성을 통한 텍스트 음성 변환을 허용합니다.",
"settings.plugins.dynamicSpeech.title": "AI 생성 펫 말하기 허용",
"settings.plugins.dynamicSpeech.description": "민감: 승인된 플러그인이 모델 생성 말풍선을 표시할 수 있도록 합니다.",
"settings.plugins.microphone.title": "마이크 허용 (푸시 투 토크)",
"settings.plugins.microphone.description": "민감: 승인된 플러그인이 일회성 음성 입력을 캡처할 수 있도록 합니다.",
"settings.plugins.quietHours.title": "방해 금지 시간",
"settings.plugins.quietHours.description": "이 시간 동안 플러그인의 말하기, 소리, 음성을 음소거합니다.",
"settings.plugins.quietWindow.title": "방해 금지 구간",
"settings.plugins.quietWindow.description": "방해 금지 시간의 시작과 끝입니다.",
"settings.plugins.aiProvider.title": "AI 제공자",
"settings.plugins.aiProvider.description": "하나의 제공자가 호스트 AI 게이트웨이를 통해 모든 플러그인에 서비스를 제공합니다. 키는 암호화되며 플러그인 코드와 절대 공유되지 않습니다.",
"settings.plugins.aiProvider.disabled": "비활성화됨",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama (로컬)",
"settings.plugins.model.title": "모델",
"settings.plugins.model.description": "제공자 기본값을 사용하려면 비워 두세요.",
"settings.plugins.model.placeholder": "제공자 기본값",
"settings.plugins.apiKey.title": "API 키",
"settings.plugins.apiKey.stored": "키가 저장되어 있습니다 (암호화됨).",
"settings.plugins.apiKey.none": "저장된 키가 없습니다. Ollama는 키가 필요하지 않습니다.",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "키 붙여넣기",
"settings.plugins.apiKey.save": "저장",
"settings.plugins.apiKey.remove": "제거",
"settings.toast.audioSaved": "플러그인 소리 설정이 저장되었습니다.",
"settings.toast.voiceSaved": "플러그인 음성 설정이 저장되었습니다.",
"settings.toast.dynamicSpeechSaved": "AI 말하기 설정이 저장되었습니다.",
"settings.toast.microphoneSaved": "마이크 설정이 저장되었습니다.",
"settings.toast.quietHoursSaved": "방해 금지 시간이 저장되었습니다.",
"settings.toast.aiProviderSaved": "AI 제공자가 저장되었습니다.",
"settings.toast.aiModelSaved": "AI 모델이 저장되었습니다.",
"settings.toast.aiKeySaved": "AI 키가 저장되었습니다.",
"settings.toast.aiKeyRemoved": "AI 키가 제거되었습니다.",
// --- Plugins view (renderer) ---
"plugins.filter.all": "전체",
"plugins.filter.installed": "설치됨",
"plugins.filter.catalog": "카탈로그",
"plugins.filter.local": "로컬 / 개발",
"plugins.filter.broken": "손상됨",
"plugins.status.broken": "손상됨",
"plugins.status.catalogDisabled": "카탈로그에서 비활성화됨",
"plugins.status.active": "활성",
"plugins.status.disabled": "비활성화됨",
"plugins.status.available": "사용 가능",
"plugins.description.installedReady": "설치된 플러그인이 구성할 준비가 되었습니다.",
"plugins.description.availableCatalog": "플러그인 카탈로그에서 사용할 수 있습니다.",
"plugins.badge.bundled": "번들",
"plugins.badge.local": "로컬",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "선언형",
"plugins.badge.deprecated": "지원 중단",
"plugins.card.active": "활성",
"plugins.card.off": "끔",
"plugins.card.configure": "구성",
"plugins.card.installPlugin": "플러그인 설치",
"plugins.empty.title": "플러그인을 찾을 수 없음",
"plugins.empty.description": "다른 필터를 시도하거나 카탈로그를 새로고침하거나 로컬 플러그인 폴더를 불러오세요.",
"plugins.footer.installed": "설치됨",
"plugins.footer.catalog": "카탈로그",
"plugins.footer.refresh": "새로고침",
"plugins.footer.loadLocal": "로컬 플러그인 불러오기",
"plugins.inspector.configAria": "{name} 구성",
"plugins.inspector.closeAria": "플러그인 구성 닫기",
"plugins.inspector.details": "플러그인 상세 정보",
"plugins.inspector.close": "닫기",
"plugins.inspector.runtime": "런타임",
"plugins.inspector.statePermissions": "상태 및 권한",
"plugins.inspector.enabled": "활성화됨",
"plugins.inspector.disabled": "비활성화됨",
"plugins.inspector.catalogDisabledNote": "이 플러그인은 카탈로그에서 비활성화되었습니다.",
"plugins.inspector.toggleNote": "컨트롤 센터를 벗어나지 않고 이 플러그인을 전환하세요.",
"plugins.inspector.noPermissions": "권한 없음",
"plugins.inspector.configuration": "구성",
"plugins.inspector.needsAttention": "주의 필요",
"plugins.inspector.settings": "설정",
"plugins.inspector.saveConfiguration": "구성 저장",
"plugins.inspector.commands": "명령",
"plugins.inspector.quickActions": "빠른 작업",
"plugins.inspector.reload": "다시 불러오기",
"plugins.inspector.update": "업데이트",
"plugins.inspector.uninstall": "제거",
"plugins.inspector.uninstallConfirm": "{name}을(를) 제거할까요?",
"plugins.inspector.catalog": "카탈로그",
"plugins.inspector.readyToInstall": "설치 준비됨",
"plugins.inspector.catalogDescription": "이 플러그인을 설치하여 권한을 승인하고 데스크톱 동반자에서 사용할 수 있도록 하세요.",
"plugins.inspector.installPlugin": "플러그인 설치",
"plugins.emptyDetail.title": "선택된 플러그인 없음",
"plugins.emptyDetail.description": "시작하려면 카탈로그 플러그인을 설치하거나 로컬 폴더를 불러오세요.",
"plugins.config.addReminder": "리마인더 추가",
"plugins.config.addItem": "항목 추가",
"plugins.config.item": "항목 {index}",
"plugins.config.remove": "제거",
"plugins.config.removeReminder": "리마인더 제거",
"plugins.config.reminder": "리마인더",
"plugins.config.dailyAt": "{id} · 매일 {time}",
"plugins.config.everyMin": "{id} · {mins}분마다",
"plugins.config.group.identity": "정체성 및 동작",
"plugins.config.group.message": "메시지",
"plugins.config.group.schedule": "일정",
"plugins.toast.pluginEnabled": "플러그인이 활성화되었습니다.",
"plugins.toast.pluginDisabled": "플러그인이 비활성화되었습니다.",
"plugins.toast.noPluginInstalled": "설치된 플러그인이 없습니다.",
"plugins.toast.pluginInstalled": "플러그인이 설치되었습니다.",
"plugins.toast.pluginUpdated": "플러그인이 업데이트되었습니다.",
"plugins.toast.noPluginUpdate": "적용된 플러그인 업데이트가 없습니다.",
"plugins.toast.configSaved": "플러그인 구성이 저장되었습니다.",
"plugins.toast.commandRan": "플러그인 명령이 실행되었습니다.",
"plugins.toast.pluginReloaded": "플러그인이 다시 불러와졌습니다.",
"plugins.toast.pluginUninstalled": "플러그인이 제거되었습니다.",
"plugins.toast.catalogRefreshed": "플러그인 카탈로그가 새로고침되었습니다.",
"plugins.toast.localLoaded": "로컬 플러그인이 불러와졌습니다.",
"plugins.toast.noLocalLoaded": "불러온 로컬 플러그인이 없습니다.",
"plugins.busy.saving": "저장 중",
"plugins.busy.installing": "설치 중",
"plugins.busy.refreshing": "새로고침 중",
"plugins.busy.loading": "불러오는 중",
"plugins.busy.running": "실행 중",
"plugins.busy.reloading": "다시 불러오는 중",
"plugins.busy.updating": "업데이트 중",
"plugins.busy.uninstalling": "제거 중",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "말하기",
"plugins.permission.pet:reaction": "반응",
"plugins.permission.pet:move": "이동",
"plugins.permission.timer": "타이머",
"plugins.permission.schedule": "일정",
"plugins.permission.storage": "저장소",
"plugins.permission.status": "상태",
"plugins.permission.commands": "명령",
"plugins.permission.network": "네트워크",
"plugins.permission.pet:interact": "말풍선 버튼",
"plugins.permission.pet:pin": "고정 말풍선",
"plugins.permission.pet:animate": "맞춤 애니메이션",
"plugins.permission.pet:speak:dynamic": "AI 말하기",
"plugins.permission.pet:drop": "드래그 앤 드롭",
"plugins.permission.pets:read": "펫 읽기",
"plugins.permission.pets:manage": "펫 관리",
"plugins.permission.audio": "소리",
"plugins.permission.events": "이벤트",
"plugins.permission.ui:toast": "토스트",
"plugins.permission.ui:panel": "패널",
"plugins.permission.notify": "알림",
"plugins.permission.bus": "플러그인 버스",
"plugins.permission.ai": "AI 게이트웨이",
"plugins.permission.secrets": "시크릿",
"plugins.permission.voice:speak": "음성",
"plugins.permission.voice:listen": "마이크",
"plugins.permission.auth": "로그인",
"plugins.permission.files": "파일",
"plugins.permission.system:openExternal": "링크 열기",
"plugins.permission.system:metrics": "시스템 지표",
"plugins.permission.clipboard": "클립보드",
"plugins.permission.network:write": "네트워크 쓰기",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "게시된 패키지",
"integrations.commandMode.bundled": "번들 데스크톱 CLI",
"integrations.commandMode.local": "로컬 개발",
"integrations.loading": "연동을 불러오는 중…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Claude Code를 OpenPets 동반자에 연결하세요.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "OpenCode를 OpenPets 동반자에 전역으로 연결하세요.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "전역 MCP 구성을 통해 Cursor를 OpenPets 동반자에 연결하세요.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "수동",
"integrations.pi.description": "OpenPets Pi 확장 패키지를 통해 Pi 코딩 에이전트 활동을 연결하세요.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "곧 출시",
"integrations.soon.description": "곧 출시됩니다.",
"integrations.soon.button": "곧 출시",
"integrations.install": "설치",
"integrations.viewSetup": "설정 보기",
"integrations.configure": "구성",
"integrations.closeAria": "연동 상세 정보 닫기",
"integrations.detail": "연동 상세 정보",
"integrations.close": "닫기",
"integrations.commandSource": "명령 소스",
"integrations.cliMode": "CLI 모드",
"integrations.localUnavailable": " 사용 불가",
"integrations.commandModeHelp": "일반 설정에는 게시된 패키지를, 데스크톱 앱 빌드에는 번들을, OpenPets 개발 중에는 로컬을 사용하세요.",
"integrations.connection": "연결",
"integrations.statusRouting": "상태 및 라우팅",
"integrations.globalSetup": "전역 설정",
"integrations.globalMcp": "전역 MCP",
"integrations.petRouting": "펫 라우팅",
"integrations.defaultPet": "기본 펫",
"integrations.configuration": "구성",
"integrations.commandPaths": "명령 경로",
"integrations.claudeCommand": "Claude 명령",
"integrations.nodeCommand": "Node.js 명령",
"integrations.opencodeCommand": "OpenCode 명령",
"integrations.optional": "선택 사항",
"integrations.claudeHooks": "Claude 훅",
"integrations.installHooks": "훅 설치",
"integrations.removeHooks": "훅 제거",
"integrations.included": "포함됨",
"integrations.instructions": "지침",
"integrations.updateInstructions": "지침 업데이트",
"integrations.actions": "작업",
"integrations.management": "관리",
"integrations.installMcp": "MCP 설치",
"integrations.replaceMcp": "MCP 교체",
"integrations.removeMcp": "MCP 제거",
"integrations.refreshStatus": "상태 새로고침",
"integrations.installGlobal": "전역 설치",
"integrations.removeGlobal": "전역 제거",
"integrations.advanced": "고급",
"integrations.mcpJsonPreview": "MCP JSON 미리보기",
"integrations.configPreview": "구성 미리보기",
"integrations.mcpEntryPreview": "MCP 항목 미리보기",
"integrations.rulesPreview": "규칙 미리보기",
"integrations.pi.manualSetup": "수동 설정",
"integrations.pi.extension": "Pi 확장",
"integrations.pi.intro": "Pi에서 OpenPets Pi 확장을 설치한 다음 Pi 세션 안에서 슬래시 명령을 사용하세요.",
"integrations.pi.globalInstall": "전역 설치",
"integrations.pi.projectInstall": "프로젝트 설치",
"integrations.pi.remove": "제거",
"integrations.pi.slashCommands": "슬래시 명령",
"integrations.pi.outro": "모든 Pi 워크스페이스에는 전역 설치를, 현재 프로젝트에서만 OpenPets를 사용하려면 프로젝트 설치를 사용하세요.",
"integrations.toast.pathSaved": "경로가 저장되었습니다.",
"integrations.busy.installing": "설치 중",
"integrations.busy.replacing": "교체 중",
"integrations.busy.removing": "제거 중",
"integrations.busy.installingHooks": "훅 설치 중",
"integrations.busy.removingHooks": "훅 제거 중",
"integrations.busy.updatingInstructions": "지침 업데이트 중",
"integrations.busy.savingPath": "경로 저장 중",
// --- Settings: Language section (renderer) ---
"settings.language.title": "언어",
"settings.language.description": "OpenPets 메뉴와 창의 표시 언어입니다.",
"settings.language.system": "시스템 기본값",
};

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// Português (Brasil) — Brazilian Portuguese
export const ptBR: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "Atualização disponível: {version}...",
"tray.defaultPet": "Pet padrão: {name}",
"tray.showDefaultPet": "Mostrar pet padrão",
"tray.hideDefaultPet": "Ocultar pet padrão",
"tray.pauseAllPets": "Pausar todos os pets",
"tray.resumeAllPets": "Retomar todos os pets",
"tray.managePets": "Gerenciar pets...",
"tray.controlCenter": "Central de controle...",
"tray.website": "Site...",
"tray.integrations": "Integrações...",
"tray.plugins": "Plugins...",
"tray.settings": "Configurações...",
"tray.openLogsFolder": "Abrir pasta de logs...",
"tray.quit": "Sair do OpenPets",
// --- Shared ---
"common.latest": "mais recente",
"common.builtInPet": "Pet integrado",
"common.cancel": "Cancelar",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "Pausado",
"pet.status.thinking": "Pensando",
"pet.status.working": "Trabalhando",
"pet.status.editing": "Editando",
"pet.status.testing": "Testando",
"pet.status.waiting": "Aguardando",
"pet.status.done": "Concluído",
"pet.status.oops": "Ops",
"pet.status.hi": "Oi",
"pet.menu.hidePet": "Ocultar pet",
"pet.menu.closePet": "Fechar pet",
"pet.menu.openControlCenter": "Abrir Central de controle",
// --- Common (renderer, shared across views) ---
"common.retry": "Tentar novamente",
"common.close": "Fechar",
"common.save": "Salvar",
"common.to": "até",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "Painel",
"nav.pets": "Pets",
"nav.settings": "Configurações",
"nav.plugins": "Plugins",
"nav.integrations": "Integrações",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "Painel",
"route.dashboard.description": "Visão geral dos seus companheiros ativos, status e métricas do sistema.",
"route.pets.title": "Pets",
"route.pets.description": "Instale, importe, visualize e escolha o seu companheiro de desktop padrão.",
"route.settings.title": "Configurações",
"route.settings.description": "Configure comportamentos de inicialização, preferências de escala e ajustes de animação.",
"route.plugins.title": "Plugins",
"route.plugins.description": "Amplie a sua experiência no desktop com ferramentas e comportamentos personalizados.",
"route.integrations.title": "Integrações",
"route.integrations.description": "Conecte seus companheiros ao Claude Code, VS Code, Cursor e muito mais.",
// --- App shell (renderer) ---
"app.controlCenter": "Central de controle",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "Coletando métricas dos companheiros...",
"dashboard.hero.eyebrow": "Companheiro principal",
"dashboard.hero.desc": "Pronto para a sua próxima sessão de programação.",
"dashboard.hero.changePet": "Trocar pet",
"dashboard.lastActive.none": "Nenhuma atividade ainda",
"dashboard.update.available": "Atualização disponível",
"dashboard.update.error": "Falha na verificação",
"dashboard.update.checking": "Verificando",
"dashboard.update.current": "Atual",
"dashboard.update.notChecked": "Não verificado",
"dashboard.stat.messages": "Mensagens",
"dashboard.stat.messages.footer": "Total de balões de fala enviados",
"dashboard.stat.reactions": "Reações",
"dashboard.stat.reactions.footer": "Total de animações acionadas",
"dashboard.stat.topCompanion": "Companheiro favorito",
"dashboard.stat.topCompanion.footer": "Pet mais ativo recentemente",
"dashboard.activity.title": "Visão geral da atividade",
"dashboard.activity.topReactions": "Principais reações",
"dashboard.activity.noReactions": "Nenhuma reação registrada ainda. Comece a programar!",
"dashboard.reactionMix.title": "Mix de reações",
"dashboard.reactionMix.total": "{count} no total",
"dashboard.reactionMix.waiting": "Aguardando atividade",
"dashboard.reactionMix.chartLabel": "Gráfico de mix de reações",
"dashboard.reactionMix.reactions": "reações",
"dashboard.reactionMix.empty": "Nenhum mix de reações ainda.",
"dashboard.companions.title": "Principais companheiros",
"dashboard.companions.subtitle": "Pets mais ativos",
"dashboard.companions.empty": "Nenhuma atividade de companheiro ainda.",
"dashboard.lastActive.label": "Última atividade: ",
"dashboard.system.title": "Saúde do sistema",
"dashboard.system.pets": "Pets",
"dashboard.system.pets.value": "{count} instalados",
"dashboard.system.plugins": "Plugins",
"dashboard.system.plugins.enabled": "{count} ativados",
"dashboard.system.catalog": "Catálogo",
"dashboard.system.catalog.offline": "Offline",
"dashboard.system.catalog.pets": "{count} pets",
"dashboard.system.catalog.ready": "Pronto",
"dashboard.system.updates": "Atualizações",
"dashboard.system.version": "Versão",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "Em breve • Próximo alvo de migração",
// --- Pets filters (renderer) ---
"pets.filter.all": "Todos",
"pets.filter.installed": "Instalados",
"pets.filter.featured": "Destaques",
"pets.filter.originals": "Originais",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "Buscar pets...",
"pets.import": "Importar pet",
"pets.gallery": "Galeria",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "Padrão",
"pets.badge.original": "Original",
"pets.badge.featured": "Destaque",
"pets.badge.installed": "Instalado",
"pets.badge.codex": "Codex",
"pets.badge.broken": "Com defeito",
"pets.badge.ready": "Pronto",
"pets.badge.originals": "Originais",
"pets.action.viewPet": "Ver pet",
"pets.action.install": "Instalar",
"pets.action.import": "Importar",
"pets.action.default": "Padrão",
"pets.action.remove": "Remover",
"pets.action.refresh": "Atualizar",
"pets.aria.view": "Ver {name}",
"pets.aria.install": "Instalar {name}",
"pets.aria.import": "Importar {name} do Codex",
"pets.aria.setDefault": "Definir {name} como padrão",
"pets.aria.remove": "Remover {name}",
"pets.busy.installing": "Instalando",
"pets.busy.importing": "Importando",
"pets.busy.settingDefault": "Definindo padrão",
"pets.busy.removing": "Removendo",
"pets.busy.loadingPage": "Carregando página",
// --- Pets pager (renderer) ---
"pets.pager.prev": "Anterior",
"pets.pager.next": "Próxima",
"pets.pager.count": "{count} pets",
"pets.pager.page": " · Página {page} de {pageCount}",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "Detalhes do pet {name}",
"pets.detail.closeAria": "Fechar detalhes do pet",
"pets.detail.eyebrow": "Detalhe do pet",
"pets.detail.previewAnimations": "Pré-visualizar animações",
"pets.detail.preview.idle": "Ocioso",
"pets.detail.preview.thinking": "Pensando",
"pets.detail.preview.happy": "Feliz",
"pets.detail.preview.wave": "Aceno",
"pets.detail.installPet": "Instalar pet",
"pets.detail.importCodexPet": "Importar pet do Codex",
"pets.detail.setDefaultPet": "Definir pet padrão",
"pets.detail.remove": "Remover",
"pets.detail.refresh": "Atualizar",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "Este pet instalado está com defeito e não pode ser definido como padrão.",
"pets.status.defaultProtected": "Pet integrado padrão. Protegido contra remoção.",
"pets.status.default": "Pet padrão.",
"pets.status.installedCodex": "Instalado e pronto para se tornar o seu pet padrão. Também encontrado em ~/.codex/pets.",
"pets.status.installed": "Instalado e pronto para se tornar o seu pet padrão.",
"pets.status.availableCodex": "Disponível para importar de ~/.codex/pets.",
"pets.status.availableCatalog": "Disponível para instalar a partir do catálogo.",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "Miniatura de {name}",
"pets.spriteLabel.thumb": "Miniatura de {name}",
"pets.spriteLabel.animatedPreview": "Pré-visualização animada de {name}",
"pets.spriteLabel.statePreview": "Pré-visualização de {name} {state}",
// --- Settings: general (renderer) ---
"settings.nav.general": "Geral",
"settings.nav.reactions": "Mapeamento de reações",
"settings.nav.plugins": "Plataforma de plugins",
"settings.general.eyebrow": "Ambiente",
"settings.general.title": "Configurações gerais",
"settings.general.showOnLaunch.title": "Mostrar pet ao iniciar",
"settings.general.showOnLaunch.description": "Mantenha o OpenPets na bandeja, mas oculte o pet até que seja solicitado.",
"settings.general.launchAtLogin.title": "Iniciar ao fazer login",
"settings.general.launchAtLogin.supported": "Inicie o OpenPets automaticamente quando o seu computador ligar.",
"settings.general.launchAtLogin.unsupported": "Não compatível com esta plataforma.",
"settings.general.petScale.title": "Escala do pet",
"settings.general.petScale.description": "Ajuste o tamanho com que o pet padrão aparece no desktop.",
"settings.general.resetPosition": "Redefinir posição do pet",
"settings.general.systemStatus": "Status do sistema",
"settings.general.updateAvailable": "Atualização disponível",
"settings.general.checking": "Verificando…",
"settings.general.checkForUpdates": "Verificar atualizações",
"settings.toast.startupSaved": "Preferência de inicialização salva.",
"settings.toast.loginStartupSaved": "Preferência de inicialização no login salva.",
"settings.toast.petScaleSaved": "Escala do pet salva.",
"settings.toast.positionReset": "Posição do pet padrão redefinida.",
"settings.busy.saving": "Salvando",
"settings.busy.resetting": "Redefinindo",
"settings.busy.opening": "Abrindo",
"settings.busy.checking": "Verificando",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "O status de atualização ainda não foi carregado.",
"settings.update.checking": "Verificando atualizações…",
"settings.update.available": "A versão {version} está disponível.",
"settings.update.current": "Tudo atualizado.",
"settings.update.failed": "Falha na verificação de atualizações.",
"settings.update.version": "Versão: {version}.",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "Comportamento",
"settings.reactions.title": "Mapeamento de reações",
"settings.reactions.resetDefaults": "Restaurar padrões",
"settings.reactions.description": "Personalize qual animação é reproduzida para cada reação do agente. As pré-visualizações usam o pet padrão.",
"settings.reactions.previewAria": "Animação: {state}",
"settings.toast.reactionsReset": "Animações de reação redefinidas.",
"settings.toast.reactionSaved": "Animação de reação salva.",
"settings.animation.idle.label": "Ocioso",
"settings.animation.idle.description": "Neutro/sem movimento especial.",
"settings.animation.review.label": "Revisão",
"settings.animation.review.description": "Pensando, lendo, revisando.",
"settings.animation.running.label": "Executando",
"settings.animation.running.description": "Trabalho ativo, edição, execução.",
"settings.animation.waiting.label": "Aguardando",
"settings.animation.waiting.description": "Aguardando, bloqueado, testando ou esperando permissão.",
"settings.animation.waving.label": "Acenando",
"settings.animation.waving.description": "Atenção, saudação, notificação.",
"settings.animation.jumping.label": "Pulando",
"settings.animation.jumping.description": "Sucesso, celebração.",
"settings.animation.failed.label": "Falhou",
"settings.animation.failed.description": "Erro ou falha.",
"settings.reaction.idle.label": "Ocioso",
"settings.reaction.idle.description": "Reação neutra explícita.",
"settings.reaction.thinking.label": "Pensando",
"settings.reaction.thinking.description": "O agente está raciocinando ou revisando.",
"settings.reaction.working.label": "Trabalhando",
"settings.reaction.working.description": "O agente está fazendo trabalho geral com ferramentas.",
"settings.reaction.editing.label": "Editando",
"settings.reaction.editing.description": "O agente está alterando arquivos.",
"settings.reaction.running.label": "Executando",
"settings.reaction.running.description": "O agente está executando um comando.",
"settings.reaction.testing.label": "Testando",
"settings.reaction.testing.description": "O agente está executando verificações.",
"settings.reaction.waiting.label": "Aguardando",
"settings.reaction.waiting.description": "O agente está bloqueado ou aguardando permissão.",
"settings.reaction.waving.label": "Acenando",
"settings.reaction.waving.description": "O pet está cumprimentando ou chamando atenção.",
"settings.reaction.success.label": "Sucesso",
"settings.reaction.success.description": "A tarefa foi concluída com sucesso.",
"settings.reaction.error.label": "Erro",
"settings.reaction.error.description": "Algo falhou.",
"settings.reaction.celebrating.label": "Celebrando",
"settings.reaction.celebrating.description": "Reação manual positiva.",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "Plataforma de plugins",
"settings.plugins.title": "Permissões de plugins e IA",
"settings.plugins.description": "Controles globais do que os plugins podem fazer. Recursos sensíveis ficam desativados até que você os ative aqui.",
"settings.plugins.audio.title": "Plugins podem reproduzir som",
"settings.plugins.audio.description": "Permitir sinos, alertas e sons incluídos dos plugins.",
"settings.plugins.voice.title": "Plugins podem falar (voz)",
"settings.plugins.voice.description": "Permitir conversão de texto em fala pela voz do sistema.",
"settings.plugins.dynamicSpeech.title": "Permitir fala do pet gerada por IA",
"settings.plugins.dynamicSpeech.description": "Sensível: permite que plugins aprovados exibam balões gerados por modelo.",
"settings.plugins.microphone.title": "Permitir microfone (apertar para falar)",
"settings.plugins.microphone.description": "Sensível: permite que plugins aprovados capturem entrada de voz pontual.",
"settings.plugins.quietHours.title": "Horário de silêncio",
"settings.plugins.quietHours.description": "Silencie a fala, o som e a voz dos plugins durante este período.",
"settings.plugins.quietWindow.title": "Janela de silêncio",
"settings.plugins.quietWindow.description": "Início e fim do período de horário de silêncio.",
"settings.plugins.aiProvider.title": "Provedor de IA",
"settings.plugins.aiProvider.description": "Um único provedor atende a todos os plugins através do gateway de IA do host. As chaves são criptografadas e nunca compartilhadas com o código dos plugins.",
"settings.plugins.aiProvider.disabled": "Desativado",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama (local)",
"settings.plugins.model.title": "Modelo",
"settings.plugins.model.description": "Deixe em branco para usar o padrão do provedor.",
"settings.plugins.model.placeholder": "padrão do provedor",
"settings.plugins.apiKey.title": "Chave de API",
"settings.plugins.apiKey.stored": "Há uma chave armazenada (criptografada).",
"settings.plugins.apiKey.none": "Nenhuma chave armazenada. O Ollama não precisa de chave.",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "Colar chave",
"settings.plugins.apiKey.save": "Salvar",
"settings.plugins.apiKey.remove": "Remover",
"settings.toast.audioSaved": "Preferência de som dos plugins salva.",
"settings.toast.voiceSaved": "Preferência de voz dos plugins salva.",
"settings.toast.dynamicSpeechSaved": "Preferência de fala por IA salva.",
"settings.toast.microphoneSaved": "Preferência de microfone salva.",
"settings.toast.quietHoursSaved": "Horário de silêncio salvo.",
"settings.toast.aiProviderSaved": "Provedor de IA salvo.",
"settings.toast.aiModelSaved": "Modelo de IA salvo.",
"settings.toast.aiKeySaved": "Chave de IA salva.",
"settings.toast.aiKeyRemoved": "Chave de IA removida.",
// --- Plugins view (renderer) ---
"plugins.filter.all": "Todos",
"plugins.filter.installed": "Instalados",
"plugins.filter.catalog": "Catálogo",
"plugins.filter.local": "Local / Dev",
"plugins.filter.broken": "Com defeito",
"plugins.status.broken": "Com defeito",
"plugins.status.catalogDisabled": "Desativado no catálogo",
"plugins.status.active": "Ativo",
"plugins.status.disabled": "Desativado",
"plugins.status.available": "Disponível",
"plugins.description.installedReady": "Plugin instalado pronto para configuração.",
"plugins.description.availableCatalog": "Disponível no catálogo de plugins.",
"plugins.badge.bundled": "Incluído",
"plugins.badge.local": "Local",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "Declarativo",
"plugins.badge.deprecated": "Obsoleto",
"plugins.card.active": "Ativo",
"plugins.card.off": "Desligado",
"plugins.card.configure": "Configurar",
"plugins.card.installPlugin": "Instalar plugin",
"plugins.empty.title": "Nenhum plugin encontrado",
"plugins.empty.description": "Tente um filtro diferente, atualize o catálogo ou carregue uma pasta de plugin local.",
"plugins.footer.installed": "instalados",
"plugins.footer.catalog": "catálogo",
"plugins.footer.refresh": "Atualizar",
"plugins.footer.loadLocal": "Carregar plugin local",
"plugins.inspector.configAria": "Configuração de {name}",
"plugins.inspector.closeAria": "Fechar configuração do plugin",
"plugins.inspector.details": "Detalhes do plugin",
"plugins.inspector.close": "Fechar",
"plugins.inspector.runtime": "Runtime",
"plugins.inspector.statePermissions": "Estado e permissões",
"plugins.inspector.enabled": "Ativado",
"plugins.inspector.disabled": "Desativado",
"plugins.inspector.catalogDisabledNote": "Este plugin está desativado pelo catálogo.",
"plugins.inspector.toggleNote": "Ative ou desative este plugin sem sair da Central de controle.",
"plugins.inspector.noPermissions": "Sem permissões",
"plugins.inspector.configuration": "Configuração",
"plugins.inspector.needsAttention": "Requer atenção",
"plugins.inspector.settings": "Configurações",
"plugins.inspector.saveConfiguration": "Salvar configuração",
"plugins.inspector.commands": "Comandos",
"plugins.inspector.quickActions": "Ações rápidas",
"plugins.inspector.reload": "Recarregar",
"plugins.inspector.update": "Atualizar",
"plugins.inspector.uninstall": "Desinstalar",
"plugins.inspector.uninstallConfirm": "Desinstalar {name}?",
"plugins.inspector.catalog": "Catálogo",
"plugins.inspector.readyToInstall": "Pronto para instalar",
"plugins.inspector.catalogDescription": "Instale este plugin para aprovar suas permissões e disponibilizá-lo no seu companheiro de desktop.",
"plugins.inspector.installPlugin": "Instalar plugin",
"plugins.emptyDetail.title": "Nenhum plugin selecionado",
"plugins.emptyDetail.description": "Instale um plugin do catálogo ou carregue uma pasta local para começar.",
"plugins.config.addReminder": "Adicionar lembrete",
"plugins.config.addItem": "Adicionar item",
"plugins.config.item": "Item {index}",
"plugins.config.remove": "Remover",
"plugins.config.removeReminder": "Remover lembrete",
"plugins.config.reminder": "Lembrete",
"plugins.config.dailyAt": "{id} · Diariamente às {time}",
"plugins.config.everyMin": "{id} · A cada {mins} min",
"plugins.config.group.identity": "Identidade e comportamento",
"plugins.config.group.message": "Mensagem",
"plugins.config.group.schedule": "Agenda",
"plugins.toast.pluginEnabled": "Plugin ativado.",
"plugins.toast.pluginDisabled": "Plugin desativado.",
"plugins.toast.noPluginInstalled": "Nenhum plugin instalado.",
"plugins.toast.pluginInstalled": "Plugin instalado.",
"plugins.toast.pluginUpdated": "Plugin atualizado.",
"plugins.toast.noPluginUpdate": "Nenhuma atualização de plugin aplicada.",
"plugins.toast.configSaved": "Configuração do plugin salva.",
"plugins.toast.commandRan": "Comando do plugin executado.",
"plugins.toast.pluginReloaded": "Plugin recarregado.",
"plugins.toast.pluginUninstalled": "Plugin desinstalado.",
"plugins.toast.catalogRefreshed": "Catálogo de plugins atualizado.",
"plugins.toast.localLoaded": "Plugin local carregado.",
"plugins.toast.noLocalLoaded": "Nenhum plugin local carregado.",
"plugins.busy.saving": "Salvando",
"plugins.busy.installing": "Instalando",
"plugins.busy.refreshing": "Atualizando",
"plugins.busy.loading": "Carregando",
"plugins.busy.running": "Executando",
"plugins.busy.reloading": "Recarregando",
"plugins.busy.updating": "Atualizando",
"plugins.busy.uninstalling": "Desinstalando",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "Fala",
"plugins.permission.pet:reaction": "Reações",
"plugins.permission.pet:move": "Movimento",
"plugins.permission.timer": "Temporizadores",
"plugins.permission.schedule": "Agenda",
"plugins.permission.storage": "Armazenamento",
"plugins.permission.status": "Status",
"plugins.permission.commands": "Comandos",
"plugins.permission.network": "Rede",
"plugins.permission.pet:interact": "Botões de balão",
"plugins.permission.pet:pin": "Balão fixado",
"plugins.permission.pet:animate": "Animação personalizada",
"plugins.permission.pet:speak:dynamic": "Fala por IA",
"plugins.permission.pet:drop": "Arrastar e soltar",
"plugins.permission.pets:read": "Ler pets",
"plugins.permission.pets:manage": "Gerenciar pets",
"plugins.permission.audio": "Som",
"plugins.permission.events": "Eventos",
"plugins.permission.ui:toast": "Avisos",
"plugins.permission.ui:panel": "Painéis",
"plugins.permission.notify": "Notificações",
"plugins.permission.bus": "Barramento de plugins",
"plugins.permission.ai": "Gateway de IA",
"plugins.permission.secrets": "Segredos",
"plugins.permission.voice:speak": "Voz",
"plugins.permission.voice:listen": "Microfone",
"plugins.permission.auth": "Login",
"plugins.permission.files": "Arquivos",
"plugins.permission.system:openExternal": "Abrir links",
"plugins.permission.system:metrics": "Métricas do sistema",
"plugins.permission.clipboard": "Área de transferência",
"plugins.permission.network:write": "Gravação na rede",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "Pacote publicado",
"integrations.commandMode.bundled": "CLI de desktop incluída",
"integrations.commandMode.local": "Desenvolvimento local",
"integrations.loading": "Carregando integrações…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "Conecte o Claude Code ao seu companheiro OpenPets.",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "Conecte o OpenCode globalmente ao seu companheiro OpenPets.",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "Conecte o Cursor ao seu companheiro OpenPets via configuração global de MCP.",
"integrations.pi.name": "Pi",
"integrations.pi.status": "Manual",
"integrations.pi.description": "Conecte a atividade do agente de programação Pi através do pacote de extensão Pi do OpenPets.",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "Em breve",
"integrations.soon.description": "Em breve.",
"integrations.soon.button": "Em breve",
"integrations.install": "Instalar",
"integrations.viewSetup": "Ver configuração",
"integrations.configure": "Configurar",
"integrations.closeAria": "Fechar detalhe da integração",
"integrations.detail": "Detalhe da integração",
"integrations.close": "Fechar",
"integrations.commandSource": "Origem do comando",
"integrations.cliMode": "Modo CLI",
"integrations.localUnavailable": " indisponível",
"integrations.commandModeHelp": "Use o pacote publicado para configuração normal, o incluído para o build do app de desktop, ou o local enquanto desenvolve o OpenPets.",
"integrations.connection": "Conexão",
"integrations.statusRouting": "Status e roteamento",
"integrations.globalSetup": "Configuração global",
"integrations.globalMcp": "MCP global",
"integrations.petRouting": "Roteamento de pet",
"integrations.defaultPet": "Pet padrão",
"integrations.configuration": "Configuração",
"integrations.commandPaths": "Caminhos de comando",
"integrations.claudeCommand": "Comando do Claude",
"integrations.nodeCommand": "Comando do Node.js",
"integrations.opencodeCommand": "Comando do OpenCode",
"integrations.optional": "Opcional",
"integrations.claudeHooks": "Hooks do Claude",
"integrations.installHooks": "Instalar hooks",
"integrations.removeHooks": "Remover hooks",
"integrations.included": "Incluído",
"integrations.instructions": "Instruções",
"integrations.updateInstructions": "Atualizar instruções",
"integrations.actions": "Ações",
"integrations.management": "Gerenciamento",
"integrations.installMcp": "Instalar MCP",
"integrations.replaceMcp": "Substituir MCP",
"integrations.removeMcp": "Remover MCP",
"integrations.refreshStatus": "Atualizar status",
"integrations.installGlobal": "Instalar global",
"integrations.removeGlobal": "Remover global",
"integrations.advanced": "Avançado",
"integrations.mcpJsonPreview": "Pré-visualização do JSON do MCP",
"integrations.configPreview": "Pré-visualização da configuração",
"integrations.mcpEntryPreview": "Pré-visualização da entrada do MCP",
"integrations.rulesPreview": "Pré-visualização das regras",
"integrations.pi.manualSetup": "Configuração manual",
"integrations.pi.extension": "Extensão Pi",
"integrations.pi.intro": "Instale a extensão Pi do OpenPets a partir do Pi e use os comandos de barra dentro de uma sessão do Pi.",
"integrations.pi.globalInstall": "Instalação global",
"integrations.pi.projectInstall": "Instalação no projeto",
"integrations.pi.remove": "Remover",
"integrations.pi.slashCommands": "Comandos de barra",
"integrations.pi.outro": "Use a instalação global para todos os workspaces do Pi, ou a instalação no projeto quando quiser o OpenPets apenas no projeto atual.",
"integrations.toast.pathSaved": "Caminho salvo.",
"integrations.busy.installing": "Instalando",
"integrations.busy.replacing": "Substituindo",
"integrations.busy.removing": "Removendo",
"integrations.busy.installingHooks": "Instalando hooks",
"integrations.busy.removingHooks": "Removendo hooks",
"integrations.busy.updatingInstructions": "Atualizando instruções",
"integrations.busy.savingPath": "Salvando caminho",
// --- Settings: Language section (renderer) ---
"settings.language.title": "Idioma",
"settings.language.description": "Idioma de exibição dos menus e janelas do OpenPets.",
"settings.language.system": "Padrão do sistema",
};

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// 简体中文 (Simplified Chinese — mainland, Singapore)
export const zhHans: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "有可用更新:{version}...",
"tray.defaultPet": "默认宠物:{name}",
"tray.showDefaultPet": "显示默认宠物",
"tray.hideDefaultPet": "隐藏默认宠物",
"tray.pauseAllPets": "暂停所有宠物",
"tray.resumeAllPets": "恢复所有宠物",
"tray.managePets": "管理宠物...",
"tray.controlCenter": "控制中心...",
"tray.website": "网站...",
"tray.integrations": "集成...",
"tray.plugins": "插件...",
"tray.settings": "设置...",
"tray.openLogsFolder": "打开日志文件夹...",
"tray.quit": "退出 OpenPets",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "内置宠物",
"common.cancel": "取消",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "已暂停",
"pet.status.thinking": "思考中",
"pet.status.working": "工作中",
"pet.status.editing": "编辑中",
"pet.status.testing": "测试中",
"pet.status.waiting": "等待中",
"pet.status.done": "完成",
"pet.status.oops": "出错了",
"pet.status.hi": "你好",
"pet.menu.hidePet": "隐藏宠物",
"pet.menu.closePet": "关闭宠物",
"pet.menu.openControlCenter": "打开控制中心",
// --- Common (renderer, shared across views) ---
"common.retry": "重试",
"common.close": "关闭",
"common.save": "保存",
"common.to": "至",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "仪表盘",
"nav.pets": "宠物",
"nav.settings": "设置",
"nav.plugins": "插件",
"nav.integrations": "集成",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "仪表盘",
"route.dashboard.description": "查看活跃伙伴、状态及系统指标的概览。",
"route.pets.title": "宠物",
"route.pets.description": "安装、导入、预览并选择你的默认桌面伙伴。",
"route.settings.title": "设置",
"route.settings.description": "配置启动行为、缩放偏好和动画设置。",
"route.plugins.title": "插件",
"route.plugins.description": "用自定义工具和行为扩展你的桌面体验。",
"route.integrations.title": "集成",
"route.integrations.description": "将你的伙伴连接到 Claude Code、VS Code、Cursor 等。",
// --- App shell (renderer) ---
"app.controlCenter": "控制中心",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "正在收集伙伴指标...",
"dashboard.hero.eyebrow": "主要伙伴",
"dashboard.hero.desc": "已准备好开始你的下一次编程。",
"dashboard.hero.changePet": "更换宠物",
"dashboard.lastActive.none": "暂无活动",
"dashboard.update.available": "有可用更新",
"dashboard.update.error": "检查失败",
"dashboard.update.checking": "检查中",
"dashboard.update.current": "当前版本",
"dashboard.update.notChecked": "未检查",
"dashboard.stat.messages": "消息",
"dashboard.stat.messages.footer": "发送的气泡总数",
"dashboard.stat.reactions": "反应",
"dashboard.stat.reactions.footer": "触发的动画总数",
"dashboard.stat.topCompanion": "最活跃伙伴",
"dashboard.stat.topCompanion.footer": "近期最活跃的宠物",
"dashboard.activity.title": "活动概览",
"dashboard.activity.topReactions": "热门反应",
"dashboard.activity.noReactions": "暂无反应记录。开始编程吧!",
"dashboard.reactionMix.title": "反应分布",
"dashboard.reactionMix.total": "共 {count} 次",
"dashboard.reactionMix.waiting": "等待活动中",
"dashboard.reactionMix.chartLabel": "反应分布图",
"dashboard.reactionMix.reactions": "次反应",
"dashboard.reactionMix.empty": "暂无反应分布。",
"dashboard.companions.title": "最活跃伙伴",
"dashboard.companions.subtitle": "最活跃的宠物",
"dashboard.companions.empty": "暂无伙伴活动。",
"dashboard.lastActive.label": "最近活动:",
"dashboard.system.title": "系统健康",
"dashboard.system.pets": "宠物",
"dashboard.system.pets.value": "已安装 {count} 个",
"dashboard.system.plugins": "插件",
"dashboard.system.plugins.enabled": "已启用 {count} 个",
"dashboard.system.catalog": "目录",
"dashboard.system.catalog.offline": "离线",
"dashboard.system.catalog.pets": "{count} 个宠物",
"dashboard.system.catalog.ready": "就绪",
"dashboard.system.updates": "更新",
"dashboard.system.version": "版本",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "敬请期待 • 下一个迁移目标",
// --- Pets filters (renderer) ---
"pets.filter.all": "全部",
"pets.filter.installed": "已安装",
"pets.filter.featured": "精选",
"pets.filter.originals": "原创",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "搜索宠物...",
"pets.import": "导入宠物",
"pets.gallery": "图库",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "默认",
"pets.badge.original": "原创",
"pets.badge.featured": "精选",
"pets.badge.installed": "已安装",
"pets.badge.codex": "Codex",
"pets.badge.broken": "已损坏",
"pets.badge.ready": "就绪",
"pets.badge.originals": "原创",
"pets.action.viewPet": "查看宠物",
"pets.action.install": "安装",
"pets.action.import": "导入",
"pets.action.default": "设为默认",
"pets.action.remove": "移除",
"pets.action.refresh": "刷新",
"pets.aria.view": "查看 {name}",
"pets.aria.install": "安装 {name}",
"pets.aria.import": "从 Codex 导入 {name}",
"pets.aria.setDefault": "将 {name} 设为默认",
"pets.aria.remove": "移除 {name}",
"pets.busy.installing": "安装中",
"pets.busy.importing": "导入中",
"pets.busy.settingDefault": "设置默认中",
"pets.busy.removing": "移除中",
"pets.busy.loadingPage": "加载页面中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "上一页",
"pets.pager.next": "下一页",
"pets.pager.count": "{count} 个宠物",
"pets.pager.page": " · 第 {page} / {pageCount} 页",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 宠物详情",
"pets.detail.closeAria": "关闭宠物详情",
"pets.detail.eyebrow": "宠物详情",
"pets.detail.previewAnimations": "预览动画",
"pets.detail.preview.idle": "待机",
"pets.detail.preview.thinking": "思考",
"pets.detail.preview.happy": "开心",
"pets.detail.preview.wave": "挥手",
"pets.detail.installPet": "安装宠物",
"pets.detail.importCodexPet": "导入 Codex 宠物",
"pets.detail.setDefaultPet": "设为默认宠物",
"pets.detail.remove": "移除",
"pets.detail.refresh": "刷新",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "此已安装的宠物已损坏,无法设为默认。",
"pets.status.defaultProtected": "默认内置宠物,受保护无法移除。",
"pets.status.default": "默认宠物。",
"pets.status.installedCodex": "已安装,可设为默认宠物。也可在 ~/.codex/pets 中找到。",
"pets.status.installed": "已安装,可设为默认宠物。",
"pets.status.availableCodex": "可从 ~/.codex/pets 导入。",
"pets.status.availableCatalog": "可从目录安装。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 缩略图",
"pets.spriteLabel.thumb": "{name} 缩略图",
"pets.spriteLabel.animatedPreview": "{name} 动画预览",
"pets.spriteLabel.statePreview": "{name} {state} 预览",
// --- Settings: general (renderer) ---
"settings.nav.general": "常规",
"settings.nav.reactions": "反应映射",
"settings.nav.plugins": "插件平台",
"settings.general.eyebrow": "环境",
"settings.general.title": "常规设置",
"settings.general.showOnLaunch.title": "启动时显示宠物",
"settings.general.showOnLaunch.description": "将 OpenPets 保留在托盘中,但在需要前隐藏宠物。",
"settings.general.launchAtLogin.title": "开机自启动",
"settings.general.launchAtLogin.supported": "在电脑启动时自动启动 OpenPets。",
"settings.general.launchAtLogin.unsupported": "此平台不支持。",
"settings.general.petScale.title": "宠物大小",
"settings.general.petScale.description": "调整默认桌面宠物的显示大小。",
"settings.general.resetPosition": "重置宠物位置",
"settings.general.systemStatus": "系统状态",
"settings.general.updateAvailable": "有可用更新",
"settings.general.checking": "检查中…",
"settings.general.checkForUpdates": "检查更新",
"settings.toast.startupSaved": "启动偏好已保存。",
"settings.toast.loginStartupSaved": "登录启动偏好已保存。",
"settings.toast.petScaleSaved": "宠物大小已保存。",
"settings.toast.positionReset": "默认宠物位置已重置。",
"settings.busy.saving": "保存中",
"settings.busy.resetting": "重置中",
"settings.busy.opening": "打开中",
"settings.busy.checking": "检查中",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "更新状态尚未加载。",
"settings.update.checking": "正在检查更新…",
"settings.update.available": "版本 {version} 可用。",
"settings.update.current": "已是最新。",
"settings.update.failed": "更新检查失败。",
"settings.update.version": "版本:{version}。",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "行为",
"settings.reactions.title": "反应映射",
"settings.reactions.resetDefaults": "恢复默认",
"settings.reactions.description": "自定义每个代理反应所播放的动画。预览使用默认宠物。",
"settings.reactions.previewAria": "动画:{state}",
"settings.toast.reactionsReset": "反应动画已重置。",
"settings.toast.reactionSaved": "反应动画已保存。",
"settings.animation.idle.label": "空闲",
"settings.animation.idle.description": "中性/无特殊动作。",
"settings.animation.review.label": "审阅",
"settings.animation.review.description": "思考、阅读、审阅。",
"settings.animation.running.label": "运行中",
"settings.animation.running.description": "活动工作、编辑、执行。",
"settings.animation.waiting.label": "等待中",
"settings.animation.waiting.description": "等待、受阻、测试、等待权限。",
"settings.animation.waving.label": "挥手",
"settings.animation.waving.description": "提醒、问候、通知。",
"settings.animation.jumping.label": "跳跃",
"settings.animation.jumping.description": "成功、庆祝。",
"settings.animation.failed.label": "失败",
"settings.animation.failed.description": "错误或失败。",
"settings.reaction.idle.label": "空闲",
"settings.reaction.idle.description": "明确的中性反应。",
"settings.reaction.thinking.label": "思考中",
"settings.reaction.thinking.description": "代理正在推理或审阅。",
"settings.reaction.working.label": "工作中",
"settings.reaction.working.description": "代理正在执行常规工具工作。",
"settings.reaction.editing.label": "编辑中",
"settings.reaction.editing.description": "代理正在修改文件。",
"settings.reaction.running.label": "运行中",
"settings.reaction.running.description": "代理正在运行命令。",
"settings.reaction.testing.label": "测试中",
"settings.reaction.testing.description": "代理正在运行检查。",
"settings.reaction.waiting.label": "等待中",
"settings.reaction.waiting.description": "代理被阻塞或正在等待权限。",
"settings.reaction.waving.label": "挥手",
"settings.reaction.waving.description": "宠物正在问候或引起注意。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "任务已成功完成。",
"settings.reaction.error.label": "错误",
"settings.reaction.error.description": "出现了问题。",
"settings.reaction.celebrating.label": "庆祝",
"settings.reaction.celebrating.description": "积极的手动反应。",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "插件平台",
"settings.plugins.title": "插件权限与 AI",
"settings.plugins.description": "插件行为的全局开关。敏感能力在你于此启用前保持关闭。",
"settings.plugins.audio.title": "允许插件播放声音",
"settings.plugins.audio.description": "允许插件播放提示音、警报和内置音效。",
"settings.plugins.voice.title": "允许插件说话(语音)",
"settings.plugins.voice.description": "允许通过系统语音进行文字转语音。",
"settings.plugins.dynamicSpeech.title": "允许 AI 生成宠物语音",
"settings.plugins.dynamicSpeech.description": "敏感:允许已批准的插件显示模型生成的气泡。",
"settings.plugins.microphone.title": "允许麦克风(按键说话)",
"settings.plugins.microphone.description": "敏感:允许已批准的插件捕获一次性语音输入。",
"settings.plugins.quietHours.title": "免打扰时段",
"settings.plugins.quietHours.description": "在此时段内静音插件的语音、声音和说话。",
"settings.plugins.quietWindow.title": "免打扰区间",
"settings.plugins.quietWindow.description": "免打扰时段的开始和结束时间。",
"settings.plugins.aiProvider.title": "AI 提供商",
"settings.plugins.aiProvider.description": "一个提供商通过主机 AI 网关为所有插件服务。密钥经过加密,绝不与插件代码共享。",
"settings.plugins.aiProvider.disabled": "已禁用",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama本地",
"settings.plugins.model.title": "模型",
"settings.plugins.model.description": "留空则使用提供商默认值。",
"settings.plugins.model.placeholder": "提供商默认值",
"settings.plugins.apiKey.title": "API 密钥",
"settings.plugins.apiKey.stored": "已存储密钥(加密)。",
"settings.plugins.apiKey.none": "未存储密钥。Ollama 无需密钥。",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "粘贴密钥",
"settings.plugins.apiKey.save": "保存",
"settings.plugins.apiKey.remove": "移除",
"settings.toast.audioSaved": "插件声音偏好已保存。",
"settings.toast.voiceSaved": "插件语音偏好已保存。",
"settings.toast.dynamicSpeechSaved": "AI 语音偏好已保存。",
"settings.toast.microphoneSaved": "麦克风偏好已保存。",
"settings.toast.quietHoursSaved": "免打扰时段已保存。",
"settings.toast.aiProviderSaved": "AI 提供商已保存。",
"settings.toast.aiModelSaved": "AI 模型已保存。",
"settings.toast.aiKeySaved": "AI 密钥已保存。",
"settings.toast.aiKeyRemoved": "AI 密钥已移除。",
// --- Plugins view (renderer) ---
"plugins.filter.all": "全部",
"plugins.filter.installed": "已安装",
"plugins.filter.catalog": "目录",
"plugins.filter.local": "本地 / 开发",
"plugins.filter.broken": "已损坏",
"plugins.status.broken": "已损坏",
"plugins.status.catalogDisabled": "目录已禁用",
"plugins.status.active": "运行中",
"plugins.status.disabled": "已禁用",
"plugins.status.available": "可用",
"plugins.description.installedReady": "已安装的插件可供配置。",
"plugins.description.availableCatalog": "可从插件目录获取。",
"plugins.badge.bundled": "内置",
"plugins.badge.local": "本地",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "声明式",
"plugins.badge.deprecated": "已弃用",
"plugins.card.active": "运行中",
"plugins.card.off": "关闭",
"plugins.card.configure": "配置",
"plugins.card.installPlugin": "安装插件",
"plugins.empty.title": "未找到插件",
"plugins.empty.description": "请尝试其他筛选条件、刷新目录,或加载本地插件文件夹。",
"plugins.footer.installed": "已安装",
"plugins.footer.catalog": "目录",
"plugins.footer.refresh": "刷新",
"plugins.footer.loadLocal": "加载本地插件",
"plugins.inspector.configAria": "{name} 配置",
"plugins.inspector.closeAria": "关闭插件配置",
"plugins.inspector.details": "插件详情",
"plugins.inspector.close": "关闭",
"plugins.inspector.runtime": "运行时",
"plugins.inspector.statePermissions": "状态与权限",
"plugins.inspector.enabled": "已启用",
"plugins.inspector.disabled": "已禁用",
"plugins.inspector.catalogDisabledNote": "此插件已被目录禁用。",
"plugins.inspector.toggleNote": "无需离开控制中心即可切换此插件。",
"plugins.inspector.noPermissions": "无权限",
"plugins.inspector.configuration": "配置",
"plugins.inspector.needsAttention": "需要处理",
"plugins.inspector.settings": "设置",
"plugins.inspector.saveConfiguration": "保存配置",
"plugins.inspector.commands": "命令",
"plugins.inspector.quickActions": "快捷操作",
"plugins.inspector.reload": "重新加载",
"plugins.inspector.update": "更新",
"plugins.inspector.uninstall": "卸载",
"plugins.inspector.uninstallConfirm": "卸载 {name}",
"plugins.inspector.catalog": "目录",
"plugins.inspector.readyToInstall": "可以安装",
"plugins.inspector.catalogDescription": "安装此插件以批准其权限,并使其在你的桌面伙伴中可用。",
"plugins.inspector.installPlugin": "安装插件",
"plugins.emptyDetail.title": "未选择插件",
"plugins.emptyDetail.description": "安装目录中的插件或加载本地文件夹即可开始。",
"plugins.config.addReminder": "添加提醒",
"plugins.config.addItem": "添加项目",
"plugins.config.item": "项目 {index}",
"plugins.config.remove": "移除",
"plugins.config.removeReminder": "移除提醒",
"plugins.config.reminder": "提醒",
"plugins.config.dailyAt": "{id} · 每天 {time}",
"plugins.config.everyMin": "{id} · 每 {mins} 分钟",
"plugins.config.group.identity": "身份与行为",
"plugins.config.group.message": "消息",
"plugins.config.group.schedule": "日程",
"plugins.toast.pluginEnabled": "插件已启用。",
"plugins.toast.pluginDisabled": "插件已禁用。",
"plugins.toast.noPluginInstalled": "未安装插件。",
"plugins.toast.pluginInstalled": "插件已安装。",
"plugins.toast.pluginUpdated": "插件已更新。",
"plugins.toast.noPluginUpdate": "未应用插件更新。",
"plugins.toast.configSaved": "插件配置已保存。",
"plugins.toast.commandRan": "插件命令已运行。",
"plugins.toast.pluginReloaded": "插件已重新加载。",
"plugins.toast.pluginUninstalled": "插件已卸载。",
"plugins.toast.catalogRefreshed": "插件目录已刷新。",
"plugins.toast.localLoaded": "本地插件已加载。",
"plugins.toast.noLocalLoaded": "未加载本地插件。",
"plugins.busy.saving": "保存中",
"plugins.busy.installing": "安装中",
"plugins.busy.refreshing": "刷新中",
"plugins.busy.loading": "加载中",
"plugins.busy.running": "运行中",
"plugins.busy.reloading": "重新加载中",
"plugins.busy.updating": "更新中",
"plugins.busy.uninstalling": "卸载中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "说话",
"plugins.permission.pet:reaction": "反应",
"plugins.permission.pet:move": "移动",
"plugins.permission.timer": "计时器",
"plugins.permission.schedule": "日程",
"plugins.permission.storage": "存储",
"plugins.permission.status": "状态",
"plugins.permission.commands": "命令",
"plugins.permission.network": "网络",
"plugins.permission.pet:interact": "气泡按钮",
"plugins.permission.pet:pin": "固定气泡",
"plugins.permission.pet:animate": "自定义动画",
"plugins.permission.pet:speak:dynamic": "AI 语音",
"plugins.permission.pet:drop": "拖放",
"plugins.permission.pets:read": "读取宠物",
"plugins.permission.pets:manage": "管理宠物",
"plugins.permission.audio": "声音",
"plugins.permission.events": "事件",
"plugins.permission.ui:toast": "提示消息",
"plugins.permission.ui:panel": "面板",
"plugins.permission.notify": "通知",
"plugins.permission.bus": "插件总线",
"plugins.permission.ai": "AI 网关",
"plugins.permission.secrets": "密钥",
"plugins.permission.voice:speak": "语音",
"plugins.permission.voice:listen": "麦克风",
"plugins.permission.auth": "登录",
"plugins.permission.files": "文件",
"plugins.permission.system:openExternal": "打开链接",
"plugins.permission.system:metrics": "系统指标",
"plugins.permission.clipboard": "剪贴板",
"plugins.permission.network:write": "网络写入",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "已发布的包",
"integrations.commandMode.bundled": "内置桌面 CLI",
"integrations.commandMode.local": "本地开发",
"integrations.loading": "正在加载集成…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "将 Claude Code 连接到你的 OpenPets 伙伴。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "将 OpenCode 全局连接到你的 OpenPets 伙伴。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "通过全局 MCP 配置将 Cursor 连接到你的 OpenPets 伙伴。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手动",
"integrations.pi.description": "通过 OpenPets Pi 扩展包连接 Pi 编程代理活动。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "即将推出",
"integrations.soon.description": "敬请期待。",
"integrations.soon.button": "即将推出",
"integrations.install": "安装",
"integrations.viewSetup": "查看设置",
"integrations.configure": "配置",
"integrations.closeAria": "关闭集成详情",
"integrations.detail": "集成详情",
"integrations.close": "关闭",
"integrations.commandSource": "命令来源",
"integrations.cliMode": "CLI 模式",
"integrations.localUnavailable": " 不可用",
"integrations.commandModeHelp": "常规设置请使用已发布的包,桌面应用构建请使用内置版,开发 OpenPets 时请使用本地版。",
"integrations.connection": "连接",
"integrations.statusRouting": "状态与路由",
"integrations.globalSetup": "全局设置",
"integrations.globalMcp": "全局 MCP",
"integrations.petRouting": "宠物路由",
"integrations.defaultPet": "默认宠物",
"integrations.configuration": "配置",
"integrations.commandPaths": "命令路径",
"integrations.claudeCommand": "Claude 命令",
"integrations.nodeCommand": "Node.js 命令",
"integrations.opencodeCommand": "OpenCode 命令",
"integrations.optional": "可选",
"integrations.claudeHooks": "Claude 钩子",
"integrations.installHooks": "安装钩子",
"integrations.removeHooks": "移除钩子",
"integrations.included": "已包含",
"integrations.instructions": "说明",
"integrations.updateInstructions": "更新说明",
"integrations.actions": "操作",
"integrations.management": "管理",
"integrations.installMcp": "安装 MCP",
"integrations.replaceMcp": "替换 MCP",
"integrations.removeMcp": "移除 MCP",
"integrations.refreshStatus": "刷新状态",
"integrations.installGlobal": "全局安装",
"integrations.removeGlobal": "移除全局",
"integrations.advanced": "高级",
"integrations.mcpJsonPreview": "MCP JSON 预览",
"integrations.configPreview": "配置预览",
"integrations.mcpEntryPreview": "MCP 条目预览",
"integrations.rulesPreview": "规则预览",
"integrations.pi.manualSetup": "手动设置",
"integrations.pi.extension": "Pi 扩展",
"integrations.pi.intro": "从 Pi 安装 OpenPets Pi 扩展,然后在 Pi 会话中使用斜杠命令。",
"integrations.pi.globalInstall": "全局安装",
"integrations.pi.projectInstall": "项目安装",
"integrations.pi.remove": "移除",
"integrations.pi.slashCommands": "斜杠命令",
"integrations.pi.outro": "全局安装适用于所有 Pi 工作区,项目安装则仅在当前项目中启用 OpenPets。",
"integrations.toast.pathSaved": "路径已保存。",
"integrations.busy.installing": "安装中",
"integrations.busy.replacing": "替换中",
"integrations.busy.removing": "移除中",
"integrations.busy.installingHooks": "安装钩子中",
"integrations.busy.removingHooks": "移除钩子中",
"integrations.busy.updatingInstructions": "更新说明中",
"integrations.busy.savingPath": "保存路径中",
// --- Settings: Language section (renderer) ---
"settings.language.title": "语言",
"settings.language.description": "OpenPets 菜单和窗口的显示语言。",
"settings.language.system": "跟随系统",
};

View file

@ -0,0 +1,512 @@
import type { Messages } from "../catalog.js";
// 繁體中文 (Traditional Chinese — Taiwan)
export const zhHant: Partial<Messages> = {
// --- Tray menu (main process, src/tray.ts) ---
"tray.updateAvailable": "有可用更新:{version}...",
"tray.defaultPet": "預設寵物:{name}",
"tray.showDefaultPet": "顯示預設寵物",
"tray.hideDefaultPet": "隱藏預設寵物",
"tray.pauseAllPets": "暫停所有寵物",
"tray.resumeAllPets": "恢復所有寵物",
"tray.managePets": "管理寵物...",
"tray.controlCenter": "控制中心...",
"tray.website": "網站...",
"tray.integrations": "整合...",
"tray.plugins": "外掛...",
"tray.settings": "設定...",
"tray.openLogsFolder": "開啟日誌資料夾...",
"tray.quit": "結束 OpenPets",
// --- Shared ---
"common.latest": "最新",
"common.builtInPet": "內建寵物",
"common.cancel": "取消",
// --- Pet window (main process, src/pet-window.ts) ---
"pet.paused": "已暫停",
"pet.status.thinking": "思考中",
"pet.status.working": "工作中",
"pet.status.editing": "編輯中",
"pet.status.testing": "測試中",
"pet.status.waiting": "等待中",
"pet.status.done": "完成",
"pet.status.oops": "出錯了",
"pet.status.hi": "你好",
"pet.menu.hidePet": "隱藏寵物",
"pet.menu.closePet": "關閉寵物",
"pet.menu.openControlCenter": "開啟控制中心",
// --- Common (renderer, shared across views) ---
"common.retry": "重試",
"common.close": "關閉",
"common.save": "儲存",
"common.to": "至",
// --- Navigation tabs (renderer) ---
"nav.dashboard": "儀表板",
"nav.pets": "寵物",
"nav.settings": "設定",
"nav.plugins": "外掛",
"nav.integrations": "整合",
// --- Route metadata (renderer hero header) ---
"route.dashboard.title": "儀表板",
"route.dashboard.description": "總覽你的活躍夥伴、狀態與系統指標。",
"route.pets.title": "寵物",
"route.pets.description": "安裝、匯入、預覽並選擇你的預設桌面夥伴。",
"route.settings.title": "設定",
"route.settings.description": "設定啟動行為、大小偏好與動畫設定。",
"route.plugins.title": "外掛",
"route.plugins.description": "用自訂工具與行為擴充你的桌面體驗。",
"route.integrations.title": "整合",
"route.integrations.description": "將你的夥伴連接到 Claude Code、VS Code、Cursor 等工具。",
// --- App shell (renderer) ---
"app.controlCenter": "控制中心",
"app.logo.alt": "OpenPets",
// --- Dashboard (renderer) ---
"dashboard.loading": "正在蒐集夥伴指標...",
"dashboard.hero.eyebrow": "主要夥伴",
"dashboard.hero.desc": "準備好開始你的下一段寫程式時光。",
"dashboard.hero.changePet": "更換寵物",
"dashboard.lastActive.none": "尚無活動",
"dashboard.update.available": "有可用更新",
"dashboard.update.error": "檢查失敗",
"dashboard.update.checking": "檢查中",
"dashboard.update.current": "最新版",
"dashboard.update.notChecked": "尚未檢查",
"dashboard.stat.messages": "訊息",
"dashboard.stat.messages.footer": "送出的對話泡泡總數",
"dashboard.stat.reactions": "反應",
"dashboard.stat.reactions.footer": "觸發的動畫總數",
"dashboard.stat.topCompanion": "最佳夥伴",
"dashboard.stat.topCompanion.footer": "近期最活躍的寵物",
"dashboard.activity.title": "活動總覽",
"dashboard.activity.topReactions": "熱門反應",
"dashboard.activity.noReactions": "尚無反應記錄。開始寫程式吧!",
"dashboard.reactionMix.title": "反應組合",
"dashboard.reactionMix.total": "共 {count} 個",
"dashboard.reactionMix.waiting": "等待活動中",
"dashboard.reactionMix.chartLabel": "反應組合圖表",
"dashboard.reactionMix.reactions": "個反應",
"dashboard.reactionMix.empty": "尚無反應組合。",
"dashboard.companions.title": "最佳夥伴",
"dashboard.companions.subtitle": "最活躍的寵物",
"dashboard.companions.empty": "尚無夥伴活動。",
"dashboard.lastActive.label": "上次活躍:",
"dashboard.system.title": "系統狀態",
"dashboard.system.pets": "寵物",
"dashboard.system.pets.value": "已安裝 {count} 個",
"dashboard.system.plugins": "外掛",
"dashboard.system.plugins.enabled": "已啟用 {count} 個",
"dashboard.system.catalog": "目錄",
"dashboard.system.catalog.offline": "離線",
"dashboard.system.catalog.pets": "{count} 個寵物",
"dashboard.system.catalog.ready": "就緒",
"dashboard.system.updates": "更新",
"dashboard.system.version": "版本",
// --- Placeholder view (renderer) ---
"placeholder.comingSoon": "即將推出 • 下一個移轉目標",
// --- Pets filters (renderer) ---
"pets.filter.all": "全部",
"pets.filter.installed": "已安裝",
"pets.filter.featured": "精選",
"pets.filter.originals": "原創",
"pets.filter.codex": "Codex",
"pets.search.placeholder": "搜尋寵物...",
"pets.import": "匯入寵物",
"pets.gallery": "藝廊",
// --- Pets card badges/actions (renderer) ---
"pets.badge.default": "預設",
"pets.badge.original": "原創",
"pets.badge.featured": "精選",
"pets.badge.installed": "已安裝",
"pets.badge.codex": "Codex",
"pets.badge.broken": "已損壞",
"pets.badge.ready": "就緒",
"pets.badge.originals": "原創",
"pets.action.viewPet": "檢視寵物",
"pets.action.install": "安裝",
"pets.action.import": "匯入",
"pets.action.default": "設為預設",
"pets.action.remove": "移除",
"pets.action.refresh": "重新整理",
"pets.aria.view": "檢視 {name}",
"pets.aria.install": "安裝 {name}",
"pets.aria.import": "從 Codex 匯入 {name}",
"pets.aria.setDefault": "將 {name} 設為預設",
"pets.aria.remove": "移除 {name}",
"pets.busy.installing": "安裝中",
"pets.busy.importing": "匯入中",
"pets.busy.settingDefault": "設定預設中",
"pets.busy.removing": "移除中",
"pets.busy.loadingPage": "載入頁面中",
// --- Pets pager (renderer) ---
"pets.pager.prev": "上一頁",
"pets.pager.next": "下一頁",
"pets.pager.count": "{count} 個寵物",
"pets.pager.page": " · 第 {page} / {pageCount} 頁",
// --- Pet detail dialog (renderer) ---
"pets.detail.ariaLabel": "{name} 寵物詳情",
"pets.detail.closeAria": "關閉寵物詳情",
"pets.detail.eyebrow": "寵物詳情",
"pets.detail.previewAnimations": "預覽動畫",
"pets.detail.preview.idle": "待機",
"pets.detail.preview.thinking": "思考",
"pets.detail.preview.happy": "開心",
"pets.detail.preview.wave": "揮手",
"pets.detail.installPet": "安裝寵物",
"pets.detail.importCodexPet": "匯入 Codex 寵物",
"pets.detail.setDefaultPet": "設為預設寵物",
"pets.detail.remove": "移除",
"pets.detail.refresh": "重新整理",
// --- Pet detail status text (renderer) ---
"pets.status.broken": "這個已安裝的寵物已損壞,無法設為預設。",
"pets.status.defaultProtected": "預設內建寵物,無法移除。",
"pets.status.default": "預設寵物。",
"pets.status.installedCodex": "已安裝,可設為你的預設寵物。也可在 ~/.codex/pets 中找到。",
"pets.status.installed": "已安裝,可設為你的預設寵物。",
"pets.status.availableCodex": "可從 ~/.codex/pets 匯入。",
"pets.status.availableCatalog": "可從目錄安裝。",
// --- Pet labels for SpriteFrame/PetImage (renderer) ---
"pets.spriteLabel.thumbnail": "{name} 縮圖",
"pets.spriteLabel.thumb": "{name} 縮圖",
"pets.spriteLabel.animatedPreview": "{name} 動畫預覽",
"pets.spriteLabel.statePreview": "{name} {state} 預覽",
// --- Settings: general (renderer) ---
"settings.nav.general": "一般",
"settings.nav.reactions": "反應對應",
"settings.nav.plugins": "外掛平台",
"settings.general.eyebrow": "環境",
"settings.general.title": "一般設定",
"settings.general.showOnLaunch.title": "啟動時顯示寵物",
"settings.general.showOnLaunch.description": "將 OpenPets 保留在工作列,但在需要時才顯示寵物。",
"settings.general.launchAtLogin.title": "登入時啟動",
"settings.general.launchAtLogin.supported": "在電腦開機時自動啟動 OpenPets。",
"settings.general.launchAtLogin.unsupported": "此平台不支援。",
"settings.general.petScale.title": "寵物大小",
"settings.general.petScale.description": "調整預設桌面寵物的顯示大小。",
"settings.general.resetPosition": "重設寵物位置",
"settings.general.systemStatus": "系統狀態",
"settings.general.updateAvailable": "有可用更新",
"settings.general.checking": "檢查中…",
"settings.general.checkForUpdates": "檢查更新",
"settings.toast.startupSaved": "已儲存啟動偏好。",
"settings.toast.loginStartupSaved": "已儲存登入啟動偏好。",
"settings.toast.petScaleSaved": "已儲存寵物大小。",
"settings.toast.positionReset": "已重設預設寵物位置。",
"settings.busy.saving": "儲存中",
"settings.busy.resetting": "重設中",
"settings.busy.opening": "開啟中",
"settings.busy.checking": "檢查中",
// --- Settings: update status formatting (renderer) ---
"settings.update.notLoaded": "更新狀態尚未載入。",
"settings.update.checking": "正在檢查更新…",
"settings.update.available": "有可用的版本 {version}。",
"settings.update.current": "已是最新版本。",
"settings.update.failed": "更新檢查失敗。",
"settings.update.version": "版本:{version}。",
// --- Settings: reaction mapping (renderer) ---
"settings.reactions.eyebrow": "行為",
"settings.reactions.title": "反應對應",
"settings.reactions.resetDefaults": "重設為預設值",
"settings.reactions.description": "自訂每個代理反應所播放的動畫。預覽使用預設寵物。",
"settings.reactions.previewAria": "動畫:{state}",
"settings.toast.reactionsReset": "已重設反應動畫。",
"settings.toast.reactionSaved": "已儲存反應動畫。",
"settings.animation.idle.label": "閒置",
"settings.animation.idle.description": "中性/無特殊動作。",
"settings.animation.review.label": "檢閱",
"settings.animation.review.description": "思考、閱讀、檢閱。",
"settings.animation.running.label": "執行中",
"settings.animation.running.description": "主動工作、編輯、執行。",
"settings.animation.waiting.label": "等待中",
"settings.animation.waiting.description": "等待、受阻、測試、等待權限。",
"settings.animation.waving.label": "揮手",
"settings.animation.waving.description": "提醒、問候、通知。",
"settings.animation.jumping.label": "跳躍",
"settings.animation.jumping.description": "成功、慶祝。",
"settings.animation.failed.label": "失敗",
"settings.animation.failed.description": "錯誤或失敗。",
"settings.reaction.idle.label": "閒置",
"settings.reaction.idle.description": "明確的中性反應。",
"settings.reaction.thinking.label": "思考中",
"settings.reaction.thinking.description": "代理正在推理或檢閱。",
"settings.reaction.working.label": "工作中",
"settings.reaction.working.description": "代理正在執行一般工具工作。",
"settings.reaction.editing.label": "編輯中",
"settings.reaction.editing.description": "代理正在修改檔案。",
"settings.reaction.running.label": "執行中",
"settings.reaction.running.description": "代理正在執行命令。",
"settings.reaction.testing.label": "測試中",
"settings.reaction.testing.description": "代理正在執行檢查。",
"settings.reaction.waiting.label": "等待中",
"settings.reaction.waiting.description": "代理受阻或正在等待權限。",
"settings.reaction.waving.label": "揮手",
"settings.reaction.waving.description": "寵物正在問候或吸引注意。",
"settings.reaction.success.label": "成功",
"settings.reaction.success.description": "任務已成功完成。",
"settings.reaction.error.label": "錯誤",
"settings.reaction.error.description": "發生錯誤。",
"settings.reaction.celebrating.label": "慶祝",
"settings.reaction.celebrating.description": "正向的手動反應。",
// --- Settings: plugin platform (renderer) ---
"settings.plugins.eyebrow": "外掛平台",
"settings.plugins.title": "外掛權限與 AI",
"settings.plugins.description": "控管外掛可執行行為的全域開關。敏感功能在你於此啟用前都會保持關閉。",
"settings.plugins.audio.title": "允許外掛播放聲音",
"settings.plugins.audio.description": "允許外掛的提示音、警示音與內建音效。",
"settings.plugins.voice.title": "允許外掛說話(語音)",
"settings.plugins.voice.description": "允許透過系統語音進行文字轉語音。",
"settings.plugins.dynamicSpeech.title": "允許 AI 生成的寵物對話",
"settings.plugins.dynamicSpeech.description": "敏感:讓核准的外掛顯示由模型生成的對話泡泡。",
"settings.plugins.microphone.title": "允許麥克風(按住說話)",
"settings.plugins.microphone.description": "敏感:讓核准的外掛擷取單次語音輸入。",
"settings.plugins.quietHours.title": "靜音時段",
"settings.plugins.quietHours.description": "在此時段內靜音外掛的對話、聲音與語音。",
"settings.plugins.quietWindow.title": "靜音範圍",
"settings.plugins.quietWindow.description": "靜音時段的開始與結束。",
"settings.plugins.aiProvider.title": "AI 供應商",
"settings.plugins.aiProvider.description": "由單一供應商透過主機 AI 閘道服務所有外掛。金鑰會加密,且絕不與外掛程式碼分享。",
"settings.plugins.aiProvider.disabled": "已停用",
"settings.plugins.aiProvider.anthropic": "Anthropic",
"settings.plugins.aiProvider.openai": "OpenAI",
"settings.plugins.aiProvider.ollama": "Ollama本機",
"settings.plugins.model.title": "模型",
"settings.plugins.model.description": "留空則使用供應商預設值。",
"settings.plugins.model.placeholder": "供應商預設值",
"settings.plugins.apiKey.title": "API 金鑰",
"settings.plugins.apiKey.stored": "已儲存金鑰(已加密)。",
"settings.plugins.apiKey.none": "未儲存金鑰。Ollama 不需要金鑰。",
"settings.plugins.apiKey.placeholderStored": "••••••••",
"settings.plugins.apiKey.placeholderEmpty": "貼上金鑰",
"settings.plugins.apiKey.save": "儲存",
"settings.plugins.apiKey.remove": "移除",
"settings.toast.audioSaved": "已儲存外掛聲音偏好。",
"settings.toast.voiceSaved": "已儲存外掛語音偏好。",
"settings.toast.dynamicSpeechSaved": "已儲存 AI 對話偏好。",
"settings.toast.microphoneSaved": "已儲存麥克風偏好。",
"settings.toast.quietHoursSaved": "已儲存靜音時段。",
"settings.toast.aiProviderSaved": "已儲存 AI 供應商。",
"settings.toast.aiModelSaved": "已儲存 AI 模型。",
"settings.toast.aiKeySaved": "已儲存 AI 金鑰。",
"settings.toast.aiKeyRemoved": "已移除 AI 金鑰。",
// --- Plugins view (renderer) ---
"plugins.filter.all": "全部",
"plugins.filter.installed": "已安裝",
"plugins.filter.catalog": "目錄",
"plugins.filter.local": "本機 / 開發",
"plugins.filter.broken": "已損壞",
"plugins.status.broken": "已損壞",
"plugins.status.catalogDisabled": "目錄已停用",
"plugins.status.active": "啟用中",
"plugins.status.disabled": "已停用",
"plugins.status.available": "可用",
"plugins.description.installedReady": "已安裝的外掛,可進行設定。",
"plugins.description.availableCatalog": "可從外掛目錄取得。",
"plugins.badge.bundled": "內建",
"plugins.badge.local": "本機",
"plugins.badge.js": "JS",
"plugins.badge.declarative": "宣告式",
"plugins.badge.deprecated": "已淘汰",
"plugins.card.active": "啟用中",
"plugins.card.off": "關閉",
"plugins.card.configure": "設定",
"plugins.card.installPlugin": "安裝外掛",
"plugins.empty.title": "找不到外掛",
"plugins.empty.description": "試試其他篩選條件、重新整理目錄,或載入本機外掛資料夾。",
"plugins.footer.installed": "已安裝",
"plugins.footer.catalog": "目錄",
"plugins.footer.refresh": "重新整理",
"plugins.footer.loadLocal": "載入本機外掛",
"plugins.inspector.configAria": "{name} 設定",
"plugins.inspector.closeAria": "關閉外掛設定",
"plugins.inspector.details": "外掛詳情",
"plugins.inspector.close": "關閉",
"plugins.inspector.runtime": "執行環境",
"plugins.inspector.statePermissions": "狀態與權限",
"plugins.inspector.enabled": "已啟用",
"plugins.inspector.disabled": "已停用",
"plugins.inspector.catalogDisabledNote": "此外掛已被目錄停用。",
"plugins.inspector.toggleNote": "不必離開控制中心即可切換此外掛。",
"plugins.inspector.noPermissions": "無權限",
"plugins.inspector.configuration": "設定",
"plugins.inspector.needsAttention": "需要注意",
"plugins.inspector.settings": "設定",
"plugins.inspector.saveConfiguration": "儲存設定",
"plugins.inspector.commands": "指令",
"plugins.inspector.quickActions": "快速動作",
"plugins.inspector.reload": "重新載入",
"plugins.inspector.update": "更新",
"plugins.inspector.uninstall": "解除安裝",
"plugins.inspector.uninstallConfirm": "要解除安裝 {name} 嗎?",
"plugins.inspector.catalog": "目錄",
"plugins.inspector.readyToInstall": "可供安裝",
"plugins.inspector.catalogDescription": "安裝此外掛以核准其權限,並讓它可在你的桌面夥伴中使用。",
"plugins.inspector.installPlugin": "安裝外掛",
"plugins.emptyDetail.title": "未選擇外掛",
"plugins.emptyDetail.description": "安裝目錄外掛或載入本機資料夾即可開始。",
"plugins.config.addReminder": "新增提醒",
"plugins.config.addItem": "新增項目",
"plugins.config.item": "項目 {index}",
"plugins.config.remove": "移除",
"plugins.config.removeReminder": "移除提醒",
"plugins.config.reminder": "提醒",
"plugins.config.dailyAt": "{id} · 每天 {time}",
"plugins.config.everyMin": "{id} · 每 {mins} 分鐘",
"plugins.config.group.identity": "身分與行為",
"plugins.config.group.message": "訊息",
"plugins.config.group.schedule": "排程",
"plugins.toast.pluginEnabled": "已啟用外掛。",
"plugins.toast.pluginDisabled": "已停用外掛。",
"plugins.toast.noPluginInstalled": "未安裝外掛。",
"plugins.toast.pluginInstalled": "已安裝外掛。",
"plugins.toast.pluginUpdated": "已更新外掛。",
"plugins.toast.noPluginUpdate": "未套用外掛更新。",
"plugins.toast.configSaved": "已儲存外掛設定。",
"plugins.toast.commandRan": "已執行外掛指令。",
"plugins.toast.pluginReloaded": "已重新載入外掛。",
"plugins.toast.pluginUninstalled": "已解除安裝外掛。",
"plugins.toast.catalogRefreshed": "已重新整理外掛目錄。",
"plugins.toast.localLoaded": "已載入本機外掛。",
"plugins.toast.noLocalLoaded": "未載入本機外掛。",
"plugins.busy.saving": "儲存中",
"plugins.busy.installing": "安裝中",
"plugins.busy.refreshing": "重新整理中",
"plugins.busy.loading": "載入中",
"plugins.busy.running": "執行中",
"plugins.busy.reloading": "重新載入中",
"plugins.busy.updating": "更新中",
"plugins.busy.uninstalling": "解除安裝中",
// --- Plugin permission labels (renderer) ---
"plugins.permission.pet:speak": "說話",
"plugins.permission.pet:reaction": "反應",
"plugins.permission.pet:move": "移動",
"plugins.permission.timer": "計時器",
"plugins.permission.schedule": "排程",
"plugins.permission.storage": "儲存空間",
"plugins.permission.status": "狀態",
"plugins.permission.commands": "指令",
"plugins.permission.network": "網路",
"plugins.permission.pet:interact": "泡泡按鈕",
"plugins.permission.pet:pin": "釘選泡泡",
"plugins.permission.pet:animate": "自訂動畫",
"plugins.permission.pet:speak:dynamic": "AI 對話",
"plugins.permission.pet:drop": "拖放",
"plugins.permission.pets:read": "讀取寵物",
"plugins.permission.pets:manage": "管理寵物",
"plugins.permission.audio": "聲音",
"plugins.permission.events": "事件",
"plugins.permission.ui:toast": "提示訊息",
"plugins.permission.ui:panel": "面板",
"plugins.permission.notify": "通知",
"plugins.permission.bus": "外掛匯流排",
"plugins.permission.ai": "AI 閘道",
"plugins.permission.secrets": "密鑰",
"plugins.permission.voice:speak": "語音",
"plugins.permission.voice:listen": "麥克風",
"plugins.permission.auth": "登入",
"plugins.permission.files": "檔案",
"plugins.permission.system:openExternal": "開啟連結",
"plugins.permission.system:metrics": "系統指標",
"plugins.permission.clipboard": "剪貼簿",
"plugins.permission.network:write": "網路寫入",
// --- Integrations view (renderer) ---
"integrations.commandMode.published": "已發佈套件",
"integrations.commandMode.bundled": "內建桌面 CLI",
"integrations.commandMode.local": "本機開發",
"integrations.loading": "正在載入整合…",
"integrations.claude.name": "Claude Code",
"integrations.claude.description": "將 Claude Code 連接到你的 OpenPets 夥伴。",
"integrations.opencode.name": "OpenCode",
"integrations.opencode.description": "將 OpenCode 全域連接到你的 OpenPets 夥伴。",
"integrations.cursor.name": "Cursor",
"integrations.cursor.description": "透過全域 MCP 設定將 Cursor 連接到你的 OpenPets 夥伴。",
"integrations.pi.name": "Pi",
"integrations.pi.status": "手動",
"integrations.pi.description": "透過 OpenPets Pi 擴充套件連接 Pi 程式設計代理的活動。",
"integrations.soon.vscode": "VS Code",
"integrations.soon.windsurf": "Windsurf",
"integrations.soon.zed": "Zed",
"integrations.soon.status": "即將推出",
"integrations.soon.description": "即將推出。",
"integrations.soon.button": "即將推出",
"integrations.install": "安裝",
"integrations.viewSetup": "檢視設定",
"integrations.configure": "設定",
"integrations.closeAria": "關閉整合詳情",
"integrations.detail": "整合詳情",
"integrations.close": "關閉",
"integrations.commandSource": "指令來源",
"integrations.cliMode": "CLI 模式",
"integrations.localUnavailable": " 無法使用",
"integrations.commandModeHelp": "一般設定請使用已發佈套件,桌面應用程式版本請使用內建,開發 OpenPets 時則使用本機。",
"integrations.connection": "連線",
"integrations.statusRouting": "狀態與路由",
"integrations.globalSetup": "全域設定",
"integrations.globalMcp": "全域 MCP",
"integrations.petRouting": "寵物路由",
"integrations.defaultPet": "預設寵物",
"integrations.configuration": "設定",
"integrations.commandPaths": "指令路徑",
"integrations.claudeCommand": "Claude 指令",
"integrations.nodeCommand": "Node.js 指令",
"integrations.opencodeCommand": "OpenCode 指令",
"integrations.optional": "選用",
"integrations.claudeHooks": "Claude Hooks",
"integrations.installHooks": "安裝 Hooks",
"integrations.removeHooks": "移除 Hooks",
"integrations.included": "已包含",
"integrations.instructions": "指示",
"integrations.updateInstructions": "更新指示",
"integrations.actions": "動作",
"integrations.management": "管理",
"integrations.installMcp": "安裝 MCP",
"integrations.replaceMcp": "取代 MCP",
"integrations.removeMcp": "移除 MCP",
"integrations.refreshStatus": "重新整理狀態",
"integrations.installGlobal": "全域安裝",
"integrations.removeGlobal": "移除全域",
"integrations.advanced": "進階",
"integrations.mcpJsonPreview": "MCP JSON 預覽",
"integrations.configPreview": "設定預覽",
"integrations.mcpEntryPreview": "MCP 項目預覽",
"integrations.rulesPreview": "規則預覽",
"integrations.pi.manualSetup": "手動設定",
"integrations.pi.extension": "Pi 擴充套件",
"integrations.pi.intro": "從 Pi 安裝 OpenPets Pi 擴充套件,然後在 Pi 工作階段中使用斜線指令。",
"integrations.pi.globalInstall": "全域安裝",
"integrations.pi.projectInstall": "專案安裝",
"integrations.pi.remove": "移除",
"integrations.pi.slashCommands": "斜線指令",
"integrations.pi.outro": "所有 Pi 工作區皆使用全域安裝;只想在目前專案使用 OpenPets 時則選擇專案安裝。",
"integrations.toast.pathSaved": "已儲存路徑。",
"integrations.busy.installing": "安裝中",
"integrations.busy.replacing": "取代中",
"integrations.busy.removing": "移除中",
"integrations.busy.installingHooks": "安裝 Hooks 中",
"integrations.busy.removingHooks": "移除 Hooks 中",
"integrations.busy.updatingInstructions": "更新指示中",
"integrations.busy.savingPath": "儲存路徑中",
// --- Settings: Language section (renderer) ---
"settings.language.title": "語言",
"settings.language.description": "OpenPets 選單與視窗的顯示語言。",
"settings.language.system": "跟隨系統",
};

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// Español (Latinoamérica) pet speech-bubble pools.
export const es419: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"Listo",
"En espera",
"Disponible",
"Pendiente",
"Listo cuando quieras",
"Vigilando",
"Todo tranquilo",
"Descansando",
"Tranquilo y listo",
"Aquí si me necesitas",
"Modo silencioso",
"Atento a la fila",
],
thinking: [
"Revisando",
"Viendo el contexto",
"Planeando",
"Pensando opciones",
"Mirando de cerca",
"Siguiendo el hilo",
"Ordenando ideas",
"Leyendo el contexto",
"Evaluando caminos",
"Escaneando detalles",
"Armando un plan",
"Siguiendo pistas",
],
working: [
"En progreso",
"Con la tarea",
"Avanzando",
"Procesando",
"Resolviéndolo",
"Sigo trabajando",
"Manos a la obra",
"Yendo bien",
"Ganando terreno",
"Avance constante",
"Tarea en marcha",
"Manteniendo el ritmo",
],
editing: [
"Actualizando archivos",
"Aplicando cambios",
"Ajustando el código",
"Afinando cambios",
"Limpiando",
"Cambiando archivos",
"Actualizando el diff",
"Puliendo cambios",
"Retocando detalles",
"Dando forma al parche",
"Rehaciendo el código",
"Ordenando archivos",
],
running: [
"Iniciando tarea",
"Proceso iniciado",
"Tarea en curso",
"En movimiento",
"Comando en curso",
"Terminal ocupada",
"Proceso activo",
"Esperando salida",
"Trabajo en curso",
"Herramienta activa",
"Comando lanzado",
"Mirando resultados",
],
testing: [
"Corriendo pruebas",
"Verificando",
"Revisando resultados",
"Buscando fallas",
"Confirmando comportamiento",
"Revisando regresiones",
"Validando el arreglo",
"Revisando la salida",
"Examinando la salida",
"Buscando errores",
"Confirmando chequeos",
"Sondeando comportamiento",
],
waiting: [
"Necesito tu visto bueno",
"Pausa para revisar",
"Necesito una decisión",
"Listo para aprobar",
"En pausa",
"Tú decides",
"Necesito tu input",
"Punto de decisión",
"Revisión pedida",
"Esperando aquí",
"Necesito rumbo",
"Me hago a un lado",
],
waving: [
"Hola",
"Pasando a saludar",
"Necesito tu atención",
"Aviso rápido",
"Atención",
"Por aquí",
"Ping",
"Ojo con esto",
"Un empujoncito",
"Nota de estado",
"Nueva señal",
"Solo un ping",
],
success: [
"Listo",
"Todo en orden",
"Terminado",
"Completo",
"Pruebas pasadas",
"Listo ya",
"Todo bien",
"Cerrado",
"Chequeos limpios",
"Tarea completa",
"Luz verde",
"Resultado entregado",
],
error: [
"Falló",
"Necesita atención",
"Encontré un problema",
"Chequeo fallido",
"Problema detectado",
"Sin terminar",
"Hay que revisar",
"Algo se rompió",
"Necesita una mirada",
"Bloqueado por un problema",
"Bandera roja",
"Hay que reintentar",
],
celebrating: [
"Buen trabajo",
"Triunfo confirmado",
"Gran resultado",
"Momento de éxito",
"Eso funcionó",
"Terminó bien",
"Victoria",
"Baile de la victoria",
"Gran cierre",
"Logro conseguido",
"El resultado brilla",
"Momento bien ganado",
],
};

View file

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

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// 日本語 (Japanese) pet speech-bubble pools.
export const ja: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"準備OK",
"待機中",
"いつでもどうぞ",
"スタンバイ",
"呼んでね",
"見守り中",
"静かだね",
"ひと休み",
"いつでも準備OK",
"ここにいるよ",
"静かモード",
"様子見中",
],
thinking: [
"確認中",
"状況チェック",
"計画中",
"検討中",
"よく見てる",
"追跡中",
"整理中",
"文脈を読み中",
"道を比較中",
"詳細を確認",
"計画づくり",
"手がかり追跡",
],
working: [
"進行中",
"作業中",
"順調に進行",
"処理中",
"取り組み中",
"作業を続行",
"タスク中",
"進めてるよ",
"前進中",
"着実に進行",
"対応中",
"勢いキープ",
],
editing: [
"ファイル更新中",
"変更を適用中",
"コード調整中",
"変更を仕上げ中",
"お片付け中",
"ファイル変更中",
"差分を更新",
"変更を磨き中",
"細部を微調整",
"パッチ作成中",
"コード手直し中",
"ファイル整理中",
],
running: [
"タスク開始",
"プロセス開始",
"タスク進行中",
"動き出した",
"コマンド実行中",
"シェル稼働中",
"プロセス稼働中",
"出力待ち",
"ジョブ進行中",
"ツール稼働中",
"コマンド起動",
"結果を監視中",
],
testing: [
"チェック実行中",
"検証中",
"結果を確認中",
"失敗を探索中",
"動作を確認中",
"退行チェック中",
"修正を検証中",
"出力を確認中",
"出力を点検中",
"失敗をスキャン中",
"チェックを確認中",
"動作を調査中",
],
waiting: [
"承認が必要",
"確認待ち",
"判断が必要",
"承認準備OK",
"一時停止中",
"お任せします",
"入力が必要",
"判断ポイント",
"確認をお願い",
"ここで待機",
"指示が必要",
"そっと待機",
],
waving: [
"こんにちは",
"ちょっと確認",
"注目してね",
"ちょい報告",
"お知らせ",
"こっちだよ",
"ピン",
"注意してね",
"ちょっとつんつん",
"状況メモ",
"新しい合図",
"ただのピン",
],
success: [
"完了",
"準備OK",
"終わったよ",
"コンプリート",
"チェック通過",
"用意OK",
"いいよ",
"片付いたよ",
"チェック異常なし",
"タスク完了",
"ゴーサイン",
"結果が出たよ",
],
error: [
"失敗",
"要対応",
"問題発見",
"チェック失敗",
"問題を検出",
"未完了",
"確認が必要",
"何か壊れたかも",
"見てほしい",
"問題で停止中",
"赤信号",
"再試行が必要",
],
celebrating: [
"やったね",
"勝利確定",
"いい結果",
"成功の瞬間",
"うまくいった",
"きれいに完了",
"勝利",
"ハッピーダンス",
"大団円",
"勝ち取った",
"結果が輝く",
"やり遂げた",
],
};

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// 한국어 (Korean) pet speech-bubble pools.
export const ko: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"준비 완료",
"대기 중",
"사용 가능",
"대기 모드",
"필요하면 부르세요",
"지켜보는 중",
"조용하네요",
"쉬는 중",
"느긋하게 준비",
"여기 있어요",
"조용 모드",
"대기열 주시 중",
],
thinking: [
"검토 중",
"맥락 확인",
"계획 중",
"선택지 고민",
"자세히 보는 중",
"추적 중",
"정리하는 중",
"맥락 읽는 중",
"경로 따져보기",
"세부 훑는 중",
"계획 세우기",
"단서 따라가기",
],
working: [
"진행 중",
"작업 처리 중",
"진척 중",
"처리 중",
"헤쳐 나가는 중",
"계속 작업 중",
"작업 수행 중",
"착착 진행",
"한 걸음씩",
"꾸준히 진행",
"작업 잡았어요",
"탄력 유지 중",
],
editing: [
"파일 수정 중",
"변경 적용 중",
"코드 조정 중",
"변경 다듬기",
"정리하는 중",
"파일 변경 중",
"diff 갱신 중",
"변경 손질 중",
"세부 손보기",
"패치 다듬기",
"코드 재작업",
"파일 정돈 중",
],
running: [
"작업 시작",
"프로세스 시작됨",
"작업 진행 중",
"돌아가는 중",
"명령 실행 중",
"셸 작업 중",
"프로세스 활성",
"출력 대기 중",
"작업 진행 중",
"도구 작동 중",
"명령 실행함",
"결과 주시 중",
],
testing: [
"검사 실행 중",
"검증 중",
"결과 확인 중",
"실패 찾는 중",
"동작 확인 중",
"회귀 점검 중",
"수정 검증 중",
"출력 확인 중",
"출력 검토 중",
"실패 훑는 중",
"검사 확인 중",
"동작 살피기",
],
waiting: [
"승인 필요",
"검토 위해 멈춤",
"결정이 필요해요",
"승인 준비됨",
"잠시 멈춤",
"당신 결정에 달림",
"입력 필요",
"결정 시점",
"검토 요청",
"여기서 대기",
"방향이 필요해요",
"한발 물러서서",
],
waving: [
"안녕하세요",
"확인차 인사",
"잠깐 봐주세요",
"짧은 소식",
"알림",
"여기예요",
"핑",
"주목",
"살짝 알림",
"상태 메모",
"새 신호",
"그냥 핑",
],
success: [
"완료",
"다 됐어요",
"끝났어요",
"완성",
"검사 통과",
"준비 완료",
"출발 좋아요",
"마무리됨",
"검사 깨끗해요",
"작업 완료",
"청신호",
"결과 도착",
],
error: [
"실패",
"확인 필요",
"문제 발견",
"검사 실패",
"문제 감지됨",
"미완료",
"검토 필요",
"뭔가 깨졌어요",
"살펴봐야 해요",
"문제로 막힘",
"경고 발생",
"재시도 필요",
],
celebrating: [
"잘했어요",
"성공 확정",
"멋진 결과",
"성공의 순간",
"통했어요",
"잘 끝냈어요",
"승리",
"신나는 춤",
"화려한 마무리",
"성공 안착",
"결과가 빛나요",
"값진 순간",
],
};

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// Português (Brasil) pet speech-bubble pools.
export const ptBR: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"Pronto",
"De prontidão",
"Disponível",
"Em espera",
"Pronto quando precisar",
"De olho",
"Tudo tranquilo",
"Descansando",
"Calmo e pronto",
"Aqui se precisar",
"Modo silencioso",
"Vigiando a fila",
],
thinking: [
"Revisando",
"Vendo o contexto",
"Planejando",
"Pensando nas opções",
"Olhando de perto",
"Investigando isso",
"Resolvendo",
"Lendo o contexto",
"Pesando os caminhos",
"Analisando detalhes",
"Montando um plano",
"Seguindo as pistas",
],
working: [
"Em andamento",
"Cuidando da tarefa",
"Progredindo",
"Processando",
"Trabalhando nisso",
"Continuando",
"Na tarefa",
"Avançando",
"Indo bem",
"Progresso constante",
"Tarefa em mãos",
"Mantendo o ritmo",
],
editing: [
"Atualizando arquivos",
"Aplicando mudanças",
"Ajustando o código",
"Refinando as mudanças",
"Dando uma limpada",
"Alterando arquivos",
"Atualizando o diff",
"Polindo as mudanças",
"Ajustando detalhes",
"Moldando o patch",
"Reescrevendo o código",
"Organizando arquivos",
],
running: [
"Iniciando a tarefa",
"Processo iniciado",
"Tarefa a caminho",
"Em movimento",
"Comando a caminho",
"Terminal ocupado",
"Processo ativo",
"Esperando a saída",
"Job em andamento",
"Ferramenta ativa",
"Comando disparado",
"De olho nos resultados",
],
testing: [
"Rodando as checagens",
"Verificando",
"Conferindo resultados",
"Procurando falhas",
"Confirmando o comportamento",
"Checando regressões",
"Validando a correção",
"Conferindo a saída",
"Revisando a saída",
"Vasculhando falhas",
"Confirmando as checagens",
"Sondando o comportamento",
],
waiting: [
"Precisa de aprovação",
"Pausado para revisão",
"Precisa de uma decisão",
"Pronto pra aprovar",
"Pausado",
"Você decide",
"Preciso de uma resposta",
"Hora de decidir",
"Revisão pedida",
"Esperando aqui",
"Preciso de direção",
"Ficando de lado",
],
waving: [
"Olá",
"Passando pra ver",
"Precisa de atenção",
"Atualização rápida",
"Aviso",
"Por aqui",
"Ping",
"Fica de olho",
"Uma cutucadinha",
"Recado de status",
"Sinal novo",
"Só um ping",
],
success: [
"Pronto",
"Tudo certo",
"Finalizado",
"Completo",
"Checagens passaram",
"Tá pronto",
"Pode seguir",
"Encerrado",
"Tudo limpo",
"Tarefa concluída",
"Sinal verde",
"Resultado chegou",
],
error: [
"Falhou",
"Precisa de atenção",
"Achei um problema",
"Checagem falhou",
"Problema detectado",
"Não terminou",
"Precisa de revisão",
"Algo quebrou",
"Precisa de uma olhada",
"Travado por um problema",
"Alerta vermelho",
"Precisa tentar de novo",
],
celebrating: [
"Mandou bem",
"Vitória confirmada",
"Ótimo resultado",
"Momento de sucesso",
"Deu certo",
"Terminou bonito",
"Vitória",
"Dancinha da alegria",
"Grande final",
"Vitória garantida",
"Resultado brilhando",
"Momento merecido",
],
};

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// 简体中文 (Simplified Chinese) pet speech-bubble pools.
export const zhHans: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"准备好了",
"随时待命",
"我在线",
"待命中",
"需要就喊我",
"盯着呢",
"一切安静",
"歇会儿",
"悠闲待命",
"有事找我",
"安静模式",
"看着队列",
],
thinking: [
"看一看",
"查上下文",
"做计划",
"权衡选项",
"再细看看",
"顺一遍",
"理清思路",
"读上下文",
"比较路径",
"扫细节",
"搭个方案",
"顺着线索",
],
working: [
"进行中",
"处理任务",
"有进展",
"处理中",
"正在搞",
"继续干",
"忙任务",
"往前推",
"有眉目了",
"稳步推进",
"手上有活",
"保持节奏",
],
editing: [
"更新文件",
"应用改动",
"调代码",
"完善改动",
"收尾整理",
"改文件",
"更新差异",
"打磨改动",
"微调细节",
"整补丁",
"重写代码",
"整理文件",
],
running: [
"启动任务",
"进程已起",
"任务进行中",
"动起来了",
"命令运行中",
"终端忙着",
"进程活跃",
"等输出",
"作业进行中",
"工具运行中",
"命令已发",
"看结果",
],
testing: [
"跑检查",
"验证中",
"看结果",
"找失败项",
"确认行为",
"查回归",
"校验修复",
"看输出",
"复查输出",
"扫失败",
"确认通过",
"试探行为",
],
waiting: [
"需要批准",
"暂停待审",
"需要决定",
"等你点头",
"已暂停",
"你来定",
"需要输入",
"到决策点",
"请审核",
"在此等待",
"需要方向",
"先靠边等",
],
waving: [
"你好",
"来报个到",
"请留意",
"快讯一条",
"通知",
"在这儿",
"叮",
"提个醒",
"轻推一下",
"状态更新",
"新信号",
"就叮一下",
],
success: [
"搞定",
"全齐了",
"完成",
"完工",
"检查通过",
"就绪",
"可以走",
"收尾完成",
"检查全过",
"任务完成",
"绿灯",
"结果到位",
],
error: [
"失败了",
"需要处理",
"发现问题",
"检查没过",
"检测到问题",
"没完成",
"需要复查",
"出岔子了",
"得看一眼",
"被问题卡住",
"亮红灯",
"需要重试",
],
celebrating: [
"干得漂亮",
"拿下了",
"结果很棒",
"成功时刻",
"成了",
"完美收官",
"胜利",
"开心跳一个",
"完美收尾",
"赢到手",
"结果亮眼",
"值了",
],
};

View file

@ -0,0 +1,159 @@
import type { OpenPetsReaction } from "../../local-ipc-protocol.js";
// 繁體中文 (Traditional Chinese) pet speech-bubble pools.
export const zhHant: Record<OpenPetsReaction, readonly string[]> = {
idle: [
"準備好了",
"待命中",
"隨時可用",
"待機中",
"需要就喊我",
"幫你看著",
"一切安靜",
"休息一下",
"從容待命",
"需要我就在",
"靜音模式",
"盯著佇列",
],
thinking: [
"檢視中",
"確認脈絡",
"規劃中",
"斟酌選項",
"再看仔細點",
"追查中",
"釐清頭緒",
"讀取脈絡",
"權衡路線",
"掃描細節",
"擬定計畫",
"循著線索",
],
working: [
"進行中",
"處理任務中",
"有進展囉",
"處理中",
"正在搞定",
"繼續工作",
"任務進行中",
"持續推進",
"漸入佳境",
"穩穩推進",
"任務在手",
"保持衝勁",
],
editing: [
"更新檔案中",
"套用變更中",
"調整程式碼",
"修整變更",
"整理一下",
"修改檔案中",
"更新差異",
"潤飾變更",
"微調細節",
"打磨修補",
"重整程式碼",
"整頓檔案",
],
running: [
"啟動任務",
"程序已啟動",
"任務進行中",
"動起來了",
"指令執行中",
"終端忙碌中",
"程序執行中",
"等待輸出",
"工作進行中",
"工具運作中",
"指令已發出",
"盯著結果",
],
testing: [
"執行檢查中",
"驗證中",
"查看結果",
"尋找失敗點",
"確認行為",
"檢查回歸問題",
"驗證修正",
"查看輸出",
"檢視輸出",
"掃描失敗項",
"確認檢查",
"探查行為",
],
waiting: [
"需要核准",
"暫停等審查",
"需要你決定",
"等你核准",
"已暫停",
"你來決定",
"需要你輸入",
"決策時刻",
"請你審查",
"在這先停一下",
"需要方向",
"退到一旁等",
],
waving: [
"哈囉",
"來看一下",
"需要你注意",
"簡短更新",
"通知一下",
"在這邊",
"戳一下",
"提醒你",
"輕推一下",
"狀態筆記",
"新訊號",
"戳個招呼",
],
success: [
"搞定",
"都準備好了",
"完成囉",
"已完成",
"檢查通過",
"準備就緒",
"可以出發了",
"收尾完成",
"檢查全過關",
"任務完成",
"綠燈放行",
"結果到手",
],
error: [
"失敗了",
"需要處理",
"發現問題",
"檢查沒過",
"偵測到問題",
"尚未完成",
"需要審查",
"有東西壞了",
"需要看一下",
"被問題卡住",
"亮起紅燈",
"需要重試",
],
celebrating: [
"做得好",
"確定贏了",
"結果超棒",
"成功時刻",
"成功啦",
"漂亮收尾",
"勝利",
"開心轉圈圈",
"盛大完成",
"勝利入袋",
"成果發光",
"這一刻值了",
],
};

View file

@ -2,8 +2,9 @@ import { app, powerMonitor } from "electron";
import { existsSync } from "node:fs";
import { delimiter, join, resolve } from "node:path";
import { initializeAppState, releaseStartupInstallLock } from "./app-state.js";
import { getAppStateSnapshot, initializeAppState, releaseStartupInstallLock } from "./app-state.js";
import { createAppIcon } from "./assets.js";
import { setLocaleFromPreference } from "./i18n/index.js";
import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js";
import { installAppLifecycle } from "./lifecycle.js";
import { debug, error as logError, getLogFilePath, info, initializeLogger, warn } from "./logger.js";
@ -54,6 +55,8 @@ if (!gotSingleInstanceLock) {
}
initializeAppState();
// Resolve the UI language before any window or the tray is built.
setLocaleFromPreference(getAppStateSnapshot().preferences.locale);
installInternalUiProtocol();
installInternalUiHandlers();
createAppTray();

View file

@ -7,9 +7,10 @@ import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-st
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { getInstalledPetDir } from "./pet-paths.js";
import { getActiveLocale, getActiveLocaleLang, t } from "./i18n/index.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { pickReactionMessage } from "./reaction-messages.js";
import { debug, error as logError, info } from "./logger.js";
import { debug, error as logError, info, warn } from "./logger.js";
import { executeDefaultPetPluginCommand, executeDefaultPetPluginMenuSelect, getDefaultPetPluginCommands, getDefaultPetPluginMenuItems } from "./plugin-service.js";
import type { ActiveBubble } from "./plugin-bubble-arbiter.js";
import type { PluginCommandForm } from "./plugin-sdk-bridge.js";
@ -82,7 +83,7 @@ export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismiss
info("pet.window", "default window create", { windowId: window.id, position: options.position, paused: options.paused, hasDisplay: Boolean(options.display), badge: options.badge });
installMousePassthroughAndDrag(window, options);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested, defaultPet: true });
installPetContextMenu(window, { label: t("pet.menu.hidePet"), click: options.onHideRequested, defaultPet: true });
const savePosition = debounce(() => {
if (window.isDestroyed()) {
@ -109,7 +110,7 @@ export function createAgentPetWindow(options: AgentPetWindowOptions, dismissToke
info("pet.window", "agent window create", { windowId: window.id, petId: options.petId, displayName: options.displayName, position: options.position, hasDisplay: Boolean(options.display), badge: options.badge });
installMousePassthroughAndDrag(window, options);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
installPetContextMenu(window, { label: t("pet.menu.closePet"), click: options.onCloseRequested });
void loadExplicitPetContent(window, options.petId, options.display, options.badge, dismissToken, options.scale);
return window;
}
@ -161,7 +162,7 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea
const template: Electron.MenuItemConstructorOptions[] = [];
if (topLevel.length > 0) template.push(...topLevel.slice(0, 8), { type: "separator" });
if (plugins.size > 0) template.push(...[...plugins.values()].map((plugin) => ({ label: plugin.name, submenu: plugin.commands })), { type: "separator" });
template.push({ label: "Open Control Center", click: () => { import("./windows.js").then(({ openControlCenterWindow }) => openControlCenterWindow()).catch((error) => logError("pet.window", "open control center failed", error)); } }, { label: action.label, click: action.click });
template.push({ label: t("pet.menu.openControlCenter"), click: () => { import("./windows.js").then(({ openControlCenterWindow }) => openControlCenterWindow()).catch((error) => logError("pet.window", "open control center failed", error)); } }, { label: action.label, click: action.click });
return template;
}
@ -203,7 +204,7 @@ async function openPluginCommandForm(command: { readonly pluginId: string; reado
function buildPluginCommandFormUrl(title: string, form: PluginCommandForm, channel: string): string {
const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'";
const data = JSON.stringify({ title, form, channel }).replace(/</g, "\\u003c");
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>${escapeHtml(title)}</title><style>body{margin:0;font:13px system-ui,sans-serif;background:#fff;color:#161616}.wrap{padding:18px}h1{font-size:16px;margin:0 0 14px}label{display:block;font-weight:600;margin:10px 0 5px}input,textarea{box-sizing:border-box;width:100%;border:1px solid #bbb;border-radius:8px;padding:8px;font:inherit}textarea{min-height:74px;resize:vertical}.error{color:#b00020;min-height:18px;margin-top:8px}.buttons{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}button{border:0;border-radius:8px;padding:8px 12px;font:inherit}button.primary{background:#2563eb;color:white}</style></head><body><form class="wrap"><h1></h1><div id="fields"></div><div class="error" role="alert"></div><div class="buttons"><button type="button" id="cancel">Cancel</button><button class="primary" type="submit"></button></div></form><script>const data=${data};const api=window.openPetsCommandForm;const form=document.querySelector('form'),fields=document.getElementById('fields'),err=document.querySelector('.error');document.querySelector('h1').textContent=data.title;document.querySelector('.primary').textContent=data.form.submitLabel||'Set';for(const f of data.form.fields){const box=document.createElement('div');const label=document.createElement('label');label.textContent=f.label;label.htmlFor=f.id;let input=f.type==='textarea'?document.createElement('textarea'):document.createElement('input');input.id=f.id;input.name=f.id;if(f.type==='number')input.type='number';else input.type='text';if(f.default!==undefined)input.value=f.default;if(f.min!==undefined)input.min=f.min;if(f.max!==undefined)input.max=f.max;if(f.maxLength!==undefined)input.maxLength=f.maxLength;if(f.required)input.required=true;box.append(label,input);fields.append(box);}document.getElementById('cancel').onclick=()=>api.close();window.addEventListener('keydown',e=>{if(e.key==='Escape')api.close()});form.onsubmit=async e=>{e.preventDefault();err.textContent='';const values={};for(const f of data.form.fields){const el=form.elements[f.id];values[f.id]=f.type==='number'?Number(el.value):String(el.value||'').trim();}try{await api.submit(data.channel,values)}catch(error){err.textContent=(error&&error.message)||'Command failed.'}};</script></body></html>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>${escapeHtml(title)}</title><style>body{margin:0;font:13px system-ui,"Hiragino Sans","Yu Gothic","Malgun Gothic","Apple SD Gothic Neo","PingFang SC","PingFang TC","Microsoft YaHei","Microsoft JhengHei","Noto Sans CJK JP","Noto Sans CJK KR","Noto Sans CJK SC","Noto Sans CJK TC",sans-serif;background:#fff;color:#161616}.wrap{padding:18px}h1{font-size:16px;margin:0 0 14px}label{display:block;font-weight:600;margin:10px 0 5px}input,textarea{box-sizing:border-box;width:100%;border:1px solid #bbb;border-radius:8px;padding:8px;font:inherit}textarea{min-height:74px;resize:vertical}.error{color:#b00020;min-height:18px;margin-top:8px}.buttons{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}button{border:0;border-radius:8px;padding:8px 12px;font:inherit}button.primary{background:#2563eb;color:white}</style></head><body><form class="wrap"><h1></h1><div id="fields"></div><div class="error" role="alert"></div><div class="buttons"><button type="button" id="cancel">${escapeHtml(t("common.cancel"))}</button><button class="primary" type="submit"></button></div></form><script>const data=${data};const api=window.openPetsCommandForm;const form=document.querySelector('form'),fields=document.getElementById('fields'),err=document.querySelector('.error');document.querySelector('h1').textContent=data.title;document.querySelector('.primary').textContent=data.form.submitLabel||'Set';for(const f of data.form.fields){const box=document.createElement('div');const label=document.createElement('label');label.textContent=f.label;label.htmlFor=f.id;let input=f.type==='textarea'?document.createElement('textarea'):document.createElement('input');input.id=f.id;input.name=f.id;if(f.type==='number')input.type='number';else input.type='text';if(f.default!==undefined)input.value=f.default;if(f.min!==undefined)input.min=f.min;if(f.max!==undefined)input.max=f.max;if(f.maxLength!==undefined)input.maxLength=f.maxLength;if(f.required)input.required=true;box.append(label,input);fields.append(box);}document.getElementById('cancel').onclick=()=>api.close();window.addEventListener('keydown',e=>{if(e.key==='Escape')api.close()});form.onsubmit=async e=>{e.preventDefault();err.textContent='';const values={};for(const f of data.form.fields){const el=form.elements[f.id];values[f.id]=f.type==='number'?Number(el.value):String(el.value||'').trim();}try{await api.submit(data.channel,values)}catch(error){err.textContent=(error&&error.message)||'Command failed.'}};</script></body></html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
@ -547,6 +548,12 @@ function createBasePetWindow(title: string, position: Point): BrowserWindow {
logError("pet.window", "renderer load failed", { windowId: window.id, errorCode, errorDescription });
console.error("Failed to load default pet window.", { errorCode, errorDescription });
});
window.webContents.on("console-message", (_event, level, message, line, sourceId) => {
const fields = { windowId: window.id, level, line, sourceId, message };
if (level >= 3) logError("pet.window", "renderer console", fields);
else if (level === 2) warn("pet.window", "renderer console", fields);
else debug("pet.window", "renderer console", fields);
});
window.webContents.on("render-process-gone", (_event, details) => {
logError("pet.window", "renderer process gone", { windowId: window.id, details });
console.error("Default pet renderer process gone.", details);
@ -602,7 +609,7 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin
export function preparePetTransientDisplay(display: PetTransientDisplay): PetTransientDisplay {
if (!display.reaction || display.message || display.reactionMessage) return display;
return { ...display, reactionMessage: pickReactionMessage(display.reaction) };
return { ...display, reactionMessage: pickReactionMessage(display.reaction, Math.random, getActiveLocale()) };
}
export function mergePetTransientDisplay(current: PetTransientDisplay | null, next: PetTransientDisplay): PetTransientDisplay {
@ -739,11 +746,11 @@ async function createDefaultPetRender(paused: boolean, display: PetTransientDisp
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
return {
cacheKey: `default:builtin:${paused}:${scale}`,
cacheKey: `default:builtin:${paused}:${scale}:${getActiveLocale()}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="en" data-reaction-state="${reactionState}" data-motion-state="idle">
<html lang="${getActiveLocaleLang()}" data-reaction-state="${reactionState}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
@ -815,11 +822,11 @@ async function createInstalledPetRender(petId: string, displayName: string, paus
const stateRows = defaultPetSprite.states;
return {
cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}`,
cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}:${getActiveLocale()}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="en" data-reaction-state="${reactionState}" data-motion-state="idle">
<html lang="${getActiveLocaleLang()}" data-reaction-state="${reactionState}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
@ -901,7 +908,7 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
.bubble-status-icon svg { display: block; width: 14px; height: 14px; color: currentColor; }
.bubble-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bubble-divider { height: 1px; width: 100%; margin: 8px 0; background: rgba(30, 58, 138, 0.12); }
.bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Malgun Gothic", "Apple SD Gothic Neo", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Microsoft JhengHei", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", sans-serif; }
.bubble-text { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-wrap: normal; overflow-wrap: break-word; }
.bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; }
.bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; }
@ -1021,7 +1028,7 @@ export function pluginBubblesCacheKey(pluginBubbles: PetPluginBubbles | null): s
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): string {
if (pluginBubbles?.transient) return createPluginBubbleMarkup(pluginBubbles.transient, false);
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction, Math.random, getActiveLocale()) : undefined) ?? (paused ? t("pet.paused") : "");
const status = !paused && badgeReaction ? getStatusBadge(badgeReaction) : null;
if (!text && !status) return "";
const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage);
@ -1042,14 +1049,14 @@ const statusBadgeIcons = {
} as const;
function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon?: string; readonly iconSvg?: string; readonly label: string } | null {
if (reaction === "thinking") return { className: "is-busy", icon: "", label: "Thinking" };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: "Working" };
if (reaction === "editing") return { className: "is-busy", icon: "", label: "Editing" };
if (reaction === "testing") return { className: "is-busy", icon: "", label: "Testing" };
if (reaction === "waiting") return { className: "is-waiting", icon: "", label: "Waiting" };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: "Done" };
if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: "Oops" };
if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: "Hi" };
if (reaction === "thinking") return { className: "is-busy", icon: "", label: t("pet.status.thinking") };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: t("pet.status.working") };
if (reaction === "editing") return { className: "is-busy", icon: "", label: t("pet.status.editing") };
if (reaction === "testing") return { className: "is-busy", icon: "", label: t("pet.status.testing") };
if (reaction === "waiting") return { className: "is-waiting", icon: "", label: t("pet.status.waiting") };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: t("pet.status.done") };
if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: t("pet.status.oops") };
if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: t("pet.status.hi") };
return null;
}

View file

@ -2,6 +2,10 @@ import { promises as fs } from "node:fs";
import { join } from "node:path";
import { pluginAssetMaxBytes, pluginPanelMaxBytes, type OpenPetsJavascriptPluginManifest, type PluginAssetKind } from "./plugin-manifest.js";
import { SUPPORTED_LOCALES } from "./i18n/catalog.js";
/** Per-locale size cap for bundled `locales/<locale>.json` catalogs. */
const pluginLocaleMaxBytes = 256 * 1024;
/** One file a v3 manifest declares (asset or panel page). */
export type DeclaredPluginFile = {
@ -90,6 +94,19 @@ export async function readDeclaredPluginFiles(manifest: OpenPetsJavascriptPlugin
if (realPath !== filePath) throw new Error(`Plugin declared file is invalid: ${file.relPath}`);
out.set(file.relPath, preparePluginFileBytes(file, await fs.readFile(filePath)));
}
// Plugin i18n catalogs live under `locales/<locale>.json` by convention (not
// manifest-declared); copy any that exist so install dirs ship translations.
for (const locale of SUPPORTED_LOCALES) {
const relPath = `locales/${locale}.json`;
const filePath = join(realSourceFolder, relPath);
let stat;
try { stat = await fs.lstat(filePath); } catch { continue; }
if (!stat.isFile() || stat.isSymbolicLink()) continue;
if (stat.size > pluginLocaleMaxBytes) throw new Error(`Plugin locale file is too large: ${relPath}`);
const realPath = await fs.realpath(filePath);
if (realPath !== filePath) throw new Error(`Plugin locale file is invalid: ${relPath}`);
out.set(relPath, await fs.readFile(filePath));
}
return out;
}

View file

@ -1,6 +1,7 @@
import type { OpenPetsPluginManifest, PluginConfigField } from "./plugin-manifest.js";
import { isValidUserSoundId } from "./plugin-user-sound-store.js";
export type PluginConfigValue = string | number | boolean | string[] | Array<Record<string, unknown>>;
export type PluginConfigValue = string | number | boolean | string[] | Array<Record<string, unknown>> | { kind: "user-sound"; id: string; name?: string } | null;
export type PluginConfig = Record<string, PluginConfigValue>;
export type PluginConfigValidationError = { path: string; code: string; message: string };
@ -77,6 +78,7 @@ function validateFieldValue(value: unknown, field: PluginConfigField, path: stri
return errors;
}
if (field.type === "multiSelect") return Array.isArray(value) && value.every((item) => typeof item === "string" && field.options?.some((option) => option.value === item)) ? [] : [{ path, code: "invalid_config_value", message: "Multi-select config value must be an array of option values." }];
if (field.type === "sound") return isValidSoundConfigValue(value) ? [] : [{ path, code: "invalid_config_value", message: "Sound config value must be a host sound name or user sound reference." }];
if (value === null || Array.isArray(value) || typeof value === "object") return [{ path, code: "invalid_config_value", message: "Config value must match the field type." }];
if (field.type === "text" || field.type === "textarea") {
if (typeof value !== "string") errors.push({ path, code: "invalid_config_value", message: "Config value must be a string." });
@ -97,6 +99,17 @@ function validateFieldValue(value: unknown, field: PluginConfigField, path: stri
function isValidTime(value: string): boolean { const m = /^(\d{2}):(\d{2})$/.exec(value); return !!m && Number(m[1]) <= 23 && Number(m[2]) <= 59; }
function isValidSoundConfigValue(value: unknown): boolean {
if (value === null || value === "") return true;
if (typeof value === "string") return value.length <= 80 && /^[A-Za-z0-9._-]+$/.test(value) && !looksLikeFilesystemPath(value);
if (!isPlainRecord(value)) return false;
return value.kind === "user-sound" && isValidUserSoundId(value.id) && (value.name === undefined || (typeof value.name === "string" && value.name.length <= 120));
}
function looksLikeFilesystemPath(value: string): boolean {
return value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\") || value.includes("/") || value.startsWith("file:");
}
function configSchemaEntries(manifest: OpenPetsPluginManifest): Array<[string, PluginConfigField]> {
return Object.entries(manifest.configSchema ?? {}).sort(([a], [b]) => a.localeCompare(b));
}

View file

@ -0,0 +1,63 @@
import { basename, extname } from "node:path";
import type { PluginLogLevel, PluginRuntimeLogger } from "./plugin-sdk-bridge.js";
export type PluginDiagnosticsFields = Record<string, unknown>;
const allowedKeys = new Set(["pluginId", "runtime", "route", "operation", "phase", "reason", "errorCode", "durationMs", "sizeBytes", "status", "subscriberCount", "scheduleId", "eventName", "topic", "method", "host", "panelId", "commandId", "menuItemId", "bubbleId", "kind", "level", "line", "source", "basename", "ext", "ok", "canceled", "count", "skipped", "sourceBasename", "provider", "model", "keyHash", "keyLength"]);
export function sanitizePluginDiagnosticsFields(fields: PluginDiagnosticsFields = {}): PluginDiagnosticsFields {
const safe: PluginDiagnosticsFields = {};
for (const [key, value] of Object.entries(fields)) {
if (!allowedKeys.has(key)) continue;
if (value === undefined || value === null) continue;
if (typeof value === "number") { if (Number.isFinite(value)) safe[key] = Math.round(value); continue; }
if (typeof value === "boolean") { safe[key] = value; continue; }
const text = String(value);
if (key === "host" || key === "method" || key === "runtime" || key === "phase" || key === "route" || key === "operation" || key === "kind" || key === "level") safe[key] = text.slice(0, 80);
else if (key === "source" || key === "basename" || key === "sourceBasename") safe[key] = basename(text).slice(0, 120);
else if (key === "ext") safe[key] = extname(text) || text.slice(0, 16);
else safe[key] = redactPluginDiagnosticText(text).slice(0, 180);
}
return safe;
}
export function redactPluginDiagnosticText(value: string): string {
return value
.replace(/https?:\/\/[^\s)]+/gi, (raw) => { try { const url = new URL(raw); return `${url.protocol}//${url.hostname}${url.pathname ? "/…" : ""}`; } catch { return "[url]"; } })
.replace(/(?:[A-Za-z]:\\|\/)[^\s)'"]+/g, (raw) => basename(raw) || "[path]")
.replace(/\b(?:token|secret|password|api[_-]?key)=([^\s&]+)/gi, "[redacted]")
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{12,}\b/g, "[redacted]")
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]");
}
export function truncatePluginConsoleMessage(value: unknown): string {
return redactPluginDiagnosticText(String(value ?? "").replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, " ")).slice(0, 500);
}
export function classifyPluginError(error: unknown): "permission" | "quota" | "validation" | "network" | "host" | "callback" | "unknown" {
const message = error instanceof Error ? error.message : String(error ?? "");
if (/permission|not approved|denied/i.test(message)) return "permission";
if (/quota|too many|rate/i.test(message)) return "quota";
if (/invalid|validation|must|unsupported/i.test(message)) return "validation";
if (/http|network|fetch|host|url|redirect|timed out|dns|status/i.test(message)) return "network";
if (/callback|handler/i.test(message)) return "callback";
if (/unavailable|renderer|window|panel|host/i.test(message)) return "host";
return "unknown";
}
export function logPluginDiagnostic(logger: PluginRuntimeLogger | undefined, level: PluginLogLevel, message: string, fields?: PluginDiagnosticsFields): void {
logger?.(level, message, sanitizePluginDiagnosticsFields(fields));
}
export async function withPluginDiagnostic<T>(logger: PluginRuntimeLogger | undefined, level: PluginLogLevel, message: string, fields: PluginDiagnosticsFields, fn: () => Promise<T>): Promise<T> {
const started = Date.now();
try {
const result = await fn();
logPluginDiagnostic(logger, level, message, { ...fields, phase: "success", durationMs: Date.now() - started, ok: true });
return result;
} catch (error) {
logPluginDiagnostic(logger, "warn", message, { ...fields, phase: "fail", ok: false, durationMs: Date.now() - started, reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error) });
throw error;
}
}

View file

@ -1,10 +1,11 @@
import { promises as fs } from "node:fs";
import * as os from "node:os";
import { basename, extname } from "node:path";
import { basename, extname, join } from "node:path";
import { app, clipboard, dialog, nativeTheme, net, Notification, shell } from "electron";
import { getDefaultPetWindowForPlugins } from "./default-pet-controller.js";
import { getActiveLocaleLang } from "./i18n/index.js";
import { debug, warn } from "./logger.js";
import { playPetWindowAudio, stopPetWindowAudio } from "./pet-window.js";
import { PluginAiGateway } from "./plugin-ai-gateway.js";
@ -13,7 +14,7 @@ import { PluginOauthBroker } from "./plugin-oauth.js";
import { openPluginPanel } from "./plugin-panels.js";
import { getPluginPlatformSettings, isInQuietHours } from "./plugin-platform-settings.js";
import {
badgePluginPet, clearPluginPetsForPlugin, closeAllPluginPets, closePluginPet, getPluginPetArbiter, getPluginPetState, hidePluginPet, listPluginPets,
setPluginPetStatusReaction, clearPluginPetsForPlugin, closeAllPluginPets, closePluginPet, getPluginPetArbiter, getPluginPetState, hidePluginPet, listPluginPets,
movePluginPetBy, movePluginPetTo, movePluginPetToHome, onPluginPetTick, onPluginPetsChange, reactPluginPet,
setPluginPetAnimation, setPluginPetFollowCursor, setPluginPetPhysics, setPluginPetScale, showPluginPet, spawnPluginPet, wanderPluginPet,
} from "./plugin-pet-registry.js";
@ -22,6 +23,8 @@ import { PluginSecretsStore } from "./plugin-secrets.js";
import { showPluginToast } from "./plugin-toast.js";
import { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";
import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-bridge.js";
import { maxUserSoundBytes, UserSoundStore, userSoundMimeByExtension } from "./plugin-user-sound-store.js";
import { classifyPluginError } from "./plugin-diagnostics.js";
/**
* The Electron implementation of every SDK v3 host capability. Built once at
@ -30,9 +33,6 @@ import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-
*/
const maxPickedFileBytes = 16 * 1024 * 1024;
const maxAudioFileBytes = 1024 * 1024;
const audioMimeByExtension: Record<string, string> = { ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav" };
type PickedFileEntry = { path: string; name: string; sizeBytes: number };
@ -79,6 +79,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
const aiGateway = new PluginAiGateway(secretsStore);
const oauthBroker = new PluginOauthBroker(secretsStore);
const pickedFiles = new Map<string, PickedFileEntry>();
const userSounds = new UserSoundStore(join(userDataPath, "plugin-user-sounds"));
let nextPickedFileId = 0;
const capabilities: ElectronPluginHostCapabilities = {
@ -91,18 +92,45 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
},
audio: {
async play(spec, volume) {
const baseFields = { kind: spec.kind, pluginId: "pluginId" in spec ? spec.pluginId : undefined, soundId: "id" in spec ? spec.id : undefined, name: "name" in spec ? spec.name : undefined };
debug("plugin", "audio play requested", baseFields);
const window = getDefaultPetWindowForPlugins();
if (!window) { debug("plugin", "audio skipped", { reason: "no-pet-window" }); return; }
if (!window) { debug("plugin", "audio play skipped", { ...baseFields, reason: "no-pet-window" }); return; }
if (spec.kind === "named") {
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
debug("plugin", "audio play started", baseFields);
return;
}
const stat = await fs.stat(spec.path);
if (!stat.isFile() || stat.size > maxAudioFileBytes) throw new Error("Plugin sound file is missing or too large.");
const mime = audioMimeByExtension[extname(spec.path).toLowerCase()];
if (!mime) throw new Error("Plugin sound format is not supported.");
const bytes = await fs.readFile(spec.path);
playPetWindowAudio(window, { kind: "data", dataUrl: `data:${mime};base64,${bytes.toString("base64")}`, volume });
try {
const sourcePath = spec.kind === "user-sound" ? await userSounds.resolvePath(spec.pluginId, spec.id) : spec.path;
const stat = await fs.stat(sourcePath);
const ext = extname(sourcePath).toLowerCase();
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
const mime = userSoundMimeByExtension[ext];
if (!mime) throw new Error("Plugin sound format is not supported.");
debug("plugin", "audio play file ready", { ...baseFields, ext, sizeBytes: stat.size });
const bytes = await fs.readFile(sourcePath);
playPetWindowAudio(window, { kind: "data", dataUrl: `data:${mime};base64,${bytes.toString("base64")}`, volume });
debug("plugin", "audio play started", { ...baseFields, ext, sizeBytes: stat.size });
} catch (error) { warn("plugin", "audio play failed", { ...baseFields, reason: error instanceof Error ? error.message : "unknown" }); throw error; }
},
async importUserSound(pluginId, fileId, opts) {
const entry = pickedFiles.get(fileId);
if (!entry) throw new Error("Plugin file handle is invalid.");
return userSounds.importFromPath(pluginId, entry.path, { name: opts?.name ?? entry.name });
},
async importUserSoundFromPath(pluginId, path, opts) {
// Trusted Control Center plumbing only: plugins receive opaque refs and
// cannot pass arbitrary paths through the SDK.
const fields: Record<string, unknown> = { pluginId, basename: basename(path), ext: extname(path).toLowerCase() };
try { fields.sizeBytes = (await fs.stat(path)).size; } catch { fields.reason = "stat-unavailable"; }
debug("plugin", "user sound import requested", fields);
const sound = await userSounds.importFromPath(pluginId, path, { name: opts?.name ?? basename(path) });
debug("plugin", "user sound import succeeded", { pluginId, soundId: sound.id, name: sound.name });
return sound;
},
async forgetUserSound(pluginId, ref) {
await userSounds.forget(pluginId, ref);
},
async stop() {
const window = getDefaultPetWindowForPlugins();
@ -123,7 +151,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
react: async (petHandleId, reaction) => reactPluginPet(petHandleId, reaction),
setAnimation: async (petHandleId, spec) => setPluginPetAnimation(petHandleId, spec),
setScale: async (petHandleId, scale) => setPluginPetScale(petHandleId, scale),
badge: async (petHandleId, reaction) => badgePluginPet(petHandleId, reaction),
setStatusReaction: async (petHandleId, reaction) => setPluginPetStatusReaction(petHandleId, reaction),
moveBy: (petHandleId, opts) => movePluginPetBy(petHandleId, opts),
wander: (petHandleId, opts) => wanderPluginPet(petHandleId, opts),
moveToHome: (petHandleId) => movePluginPetToHome(petHandleId),
@ -136,8 +164,9 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
},
toast: (spec) => showPluginToast(spec),
async notify(spec) {
if (!Notification.isSupported()) throw new Error("OS notifications are not supported on this system.");
new Notification({ title: spec.title, body: spec.body, silent: spec.sound !== true }).show();
if (!Notification.isSupported()) { warn("plugin", "notify failed", { reason: "unsupported" }); throw new Error("OS notifications are not supported on this system."); }
try { new Notification({ title: spec.title, body: spec.body, silent: spec.sound !== true }).show(); }
catch (error) { warn("plugin", "notify failed", { reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; }
},
panels: {
open: (opts) => openPluginPanel(opts),
@ -150,17 +179,17 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
},
ai: {
available: () => aiGateway.available(),
complete: (req) => aiGateway.complete(req),
stream: (req, onToken) => aiGateway.stream(req, onToken),
complete: async (req) => { const started = Date.now(); try { return await aiGateway.complete(req); } catch (error) { warn("plugin", "ai complete failed", { durationMs: Date.now() - started, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
stream: async (req, onToken) => { const started = Date.now(); try { return await aiGateway.stream(req, onToken); } catch (error) { warn("plugin", "ai stream failed", { durationMs: Date.now() - started, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
},
voice: {
speak: (text, opts) => pluginVoiceSpeak(text, opts),
listen: (opts) => pluginVoiceListen(aiGateway, { timeoutMs: opts.timeoutMs ?? 10_000 }),
speak: async (text, opts) => { try { await pluginVoiceSpeak(text, opts); } catch (error) { warn("plugin", "voice speak failed", { reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
listen: async (opts) => { try { return await pluginVoiceListen(aiGateway, { timeoutMs: opts.timeoutMs ?? 10_000 }); } catch (error) { warn("plugin", "voice listen failed", { reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
},
auth: {
oauth: (pluginId, config) => oauthBroker.oauth(pluginId, config),
refresh: (pluginId, provider) => oauthBroker.refresh(pluginId, provider),
signOut: (pluginId, provider) => oauthBroker.signOut(pluginId, provider),
oauth: async (pluginId, config) => { try { return await oauthBroker.oauth(pluginId, config); } catch (error) { warn("plugin", "oauth failed", { pluginId, provider: config.provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
refresh: async (pluginId, provider) => { try { return await oauthBroker.refresh(pluginId, provider); } catch (error) { warn("plugin", "oauth refresh failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
signOut: async (pluginId, provider) => { try { await oauthBroker.signOut(pluginId, provider); } catch (error) { warn("plugin", "oauth signout failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
},
files: {
async pick(opts) {
@ -170,15 +199,17 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
const result = await dialog.showOpenDialog({ properties: opts.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters });
if (result.canceled) return [];
const out: PluginPickedFileHost[] = [];
let skipped = 0;
for (const path of result.filePaths.slice(0, 16)) {
try {
const stat = await fs.stat(path);
if (!stat.isFile()) continue;
if (!stat.isFile()) { skipped++; continue; }
const fileId = `pick-${++nextPickedFileId}`;
pickedFiles.set(fileId, { path, name: basename(path), sizeBytes: stat.size });
out.push({ fileId, name: basename(path), sizeBytes: stat.size });
} catch { /* unreadable selections are skipped */ }
} catch { skipped++; }
}
debug("plugin", "files picked", { count: out.length, skipped });
return out;
},
async read(fileId, encoding) {
@ -188,12 +219,14 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
if (!entry) throw new Error("Plugin file handle is invalid.");
const stat = await fs.stat(entry.path);
if (stat.size > maxPickedFileBytes) throw new Error("Picked file is too large to read.");
debug("plugin", "file read", { basename: entry.name, ext: extname(entry.name).toLowerCase(), sizeBytes: stat.size });
const bytes = await fs.readFile(entry.path);
return encoding === "text" ? bytes.toString("utf8") : new Uint8Array(bytes);
},
async save(opts) {
const result = await dialog.showSaveDialog({ defaultPath: opts.suggestedName });
if (result.canceled || !result.filePath) return;
debug("plugin", "file saved", { basename: basename(result.filePath), ext: extname(result.filePath).toLowerCase(), sizeBytes: typeof opts.data === "string" ? Buffer.byteLength(opts.data) : opts.data.byteLength });
await fs.writeFile(result.filePath, typeof opts.data === "string" ? opts.data : Buffer.from(opts.data));
},
},
@ -201,7 +234,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
async info() {
return {
platform: process.platform === "darwin" ? "mac" as const : process.platform === "win32" ? "win" as const : "linux" as const,
locale: app.getLocale() || "en-US",
locale: getActiveLocaleLang(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
theme: nativeTheme.shouldUseDarkColors ? "dark" as const : "light" as const,
appVersion: app.getVersion(),
@ -214,6 +247,9 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
return { cpuPercent: cpuPercent(), memUsedPercent };
},
async openExternal(url) {
let host: string | undefined;
try { host = new URL(url).hostname; } catch { host = undefined; }
debug("plugin", "system openExternal", { host });
await shell.openExternal(url);
},
async readClipboardText() {
@ -236,6 +272,8 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
} catch (error) {
warn("plugin", "plugin pet teardown failed", { pluginId, error: error instanceof Error ? error.message : String(error) });
}
// Runtime teardown must not delete persistent plugin data. User-imported
// sounds are cleared only by PluginService uninstall/prune paths.
motionStop("default");
},
shutdown() {

View file

@ -0,0 +1,104 @@
// Plugin-level i18n: loads each plugin's `locales/<locale>.json` catalogs and
// resolves both host-rendered `$t:` manifest references (at display time) and
// the runtime `ctx.t(key, vars?)` helper plugins compose strings with. Reuses
// the host i18n primitives so plugin translations track the active host locale.
import { promises as fs } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { getActiveLocale } from "./i18n/index.js";
import { interpolate, SUPPORTED_LOCALES, type Locale } from "./i18n/catalog.js";
/** Literal prefix marking a host-resolved translation reference: `$t:`. */
const PLUGIN_TEXT_PREFIX = "$t:";
/** Per-locale size cap mirroring the bounded manifest reader convention. */
const maxPluginLocaleBytes = 256 * 1024;
export type PluginLocaleCatalogs = Partial<Record<Locale, Record<string, string>>>;
const registry = new Map<string, PluginLocaleCatalogs>();
async function readBoundedUtf8(path: string, maxBytes: number): Promise<string> {
let handle: FileHandle | undefined;
try {
handle = await fs.open(path, "r");
const buffer = Buffer.alloc(maxBytes + 1);
const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, 0);
if (bytesRead > maxBytes) throw new Error("Plugin locale file is too large.");
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle?.close().catch(() => undefined);
}
}
function asFlatStringRecord(value: unknown): Record<string, string> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (typeof val !== "string") return undefined;
out[key] = val;
}
return out;
}
/**
* Read `locales/<locale>.json` for each supported locale from the plugin's
* install directory. Each file must be a flat `string -> string` object; missing
* or invalid files are skipped (only well-formed catalogs are returned).
*/
export async function loadPluginLocales(installPath: string): Promise<PluginLocaleCatalogs> {
const catalogs: PluginLocaleCatalogs = {};
await Promise.all(
SUPPORTED_LOCALES.map(async (locale) => {
const path = join(installPath, "locales", `${locale}.json`);
try {
const text = await readBoundedUtf8(path, maxPluginLocaleBytes);
const parsed = asFlatStringRecord(JSON.parse(text) as unknown);
if (parsed) catalogs[locale] = parsed;
} catch {
// Missing or malformed locale files are fine; fall back to other catalogs.
}
}),
);
return catalogs;
}
export function registerPluginLocales(pluginId: string, catalogs: PluginLocaleCatalogs): void {
registry.set(pluginId, catalogs);
}
export function unregisterPluginLocales(pluginId: string): void {
registry.delete(pluginId);
}
/** Lazily load and register a plugin's catalogs if not already present. */
export async function ensureLoaded(pluginId: string, installPath: string): Promise<void> {
if (registry.has(pluginId)) return;
registry.set(pluginId, await loadPluginLocales(installPath));
}
function lookup(pluginId: string, key: string): string | undefined {
const catalogs = registry.get(pluginId);
if (!catalogs) return undefined;
return catalogs[getActiveLocale()]?.[key] ?? catalogs.en?.[key];
}
/**
* Resolve a host-rendered static string. Returns the value unchanged unless it
* starts with `$t:`; then strips the prefix and resolves the key against the
* active-locale catalog -> plugin `en` catalog -> the raw key.
*/
export function resolvePluginText(pluginId: string, value: string | undefined): string | undefined {
if (value === undefined || !value.startsWith(PLUGIN_TEXT_PREFIX)) return value;
const key = value.slice(PLUGIN_TEXT_PREFIX.length);
return lookup(pluginId, key) ?? key;
}
/**
* Build the `ctx.t(key, vars?)` helper for a plugin: active-locale catalog ->
* `en` catalog -> the raw key, then `{var}` interpolation.
*/
export function makePluginT(pluginId: string): (key: string, vars?: Record<string, string | number>) => string {
return (key, vars) => interpolate(lookup(pluginId, key) ?? key, vars);
}

View file

@ -1,10 +1,12 @@
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, ipcMain, session, type Event, type IpcMainInvokeEvent, type RenderProcessGoneDetails, type Session, type WebContents } from "electron";
import { app, BrowserWindow, ipcMain, session, type Event, type IpcMainEvent, type IpcMainInvokeEvent, type RenderProcessGoneDetails, type Session, type WebContents } from "electron";
import type { OpenPetsJavascriptPluginManifest } from "./plugin-manifest.js";
import type { PluginSdkApi } from "./plugin-sdk-bridge.js";
import { classifyPluginError, logPluginDiagnostic, truncatePluginConsoleMessage } from "./plugin-diagnostics.js";
import type { PluginRuntimeLogger, PluginSdkApi } from "./plugin-sdk-bridge.js";
import { isPluginSdkRoute, type PluginSdkRoute } from "./plugin-sdk-routes.js";
import type { PluginStateRecord } from "./plugin-state.js";
export type PluginJsHostStartOptions = {
@ -20,6 +22,7 @@ export interface PluginJsHostInstance { stop(): void }
export interface PluginJsHost { startPlugin(options: PluginJsHostStartOptions): Promise<PluginJsHostInstance> }
const configDisposers = new WeakMap<WebContents, Map<string, () => void>>();
type SdkWithLogger = PluginSdkApi & { __logger?: PluginRuntimeLogger };
export class ElectronPluginJsHost implements PluginJsHost {
readonly #startupTimeoutMs: number;
@ -30,8 +33,12 @@ export class ElectronPluginJsHost implements PluginJsHost {
async startPlugin(options: PluginJsHostStartOptions): Promise<PluginJsHostInstance> {
const partition = `openpets-plugin:${encodeURIComponent(options.record.id)}:${Date.now()}`;
const logger = (options.sdk as SdkWithLogger | undefined)?.__logger;
logPluginDiagnostic(logger, "debug", "plugin js host start", { pluginId: options.record.id, runtime: "javascript", phase: "begin" });
const pluginSession = session.fromPartition(partition, { cache: false });
logPluginDiagnostic(logger, "debug", "plugin js host session created", { pluginId: options.record.id, runtime: "javascript" });
const entryUrl = pathToFileURL(options.entryPath).toString();
logPluginDiagnostic(logger, "debug", "plugin js host entry read", { pluginId: options.record.id, runtime: "javascript", phase: "begin", source: options.entryPath });
const moduleUrl = buildPluginModuleUrl(await readFile(options.entryPath, "utf8"), entryUrl);
const htmlUrl = buildPluginHtmlUrl(moduleUrl);
const token = `${options.record.id}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@ -55,10 +62,13 @@ export class ElectronPluginJsHost implements PluginJsHost {
});
hardenWebContents(window.webContents, options.onBroken);
installSdkHandler(sdkChannel, window.webContents, options.sdk);
window.webContents.on("console-message", (_event, level, message, line, sourceId) => logPluginDiagnostic(logger, level >= 2 ? "warn" : "debug", "plugin renderer console", { pluginId: options.record.id, runtime: "javascript", level, reason: truncatePluginConsoleMessage(message), line, source: sourceId }));
window.webContents.on("render-process-gone", (_event, details) => logPluginDiagnostic(logger, "warn", "plugin renderer gone", { pluginId: options.record.id, runtime: "javascript", reason: details.reason }));
window.webContents.on("unresponsive", () => logPluginDiagnostic(logger, "warn", "plugin renderer unresponsive", { pluginId: options.record.id, runtime: "javascript" }));
const removeSdkHandler = installSdkHandler(sdkChannel, window.webContents, options.sdk, options.record.id);
const stopped = { value: false };
const instance: PluginJsHostInstance = { stop: () => { stopped.value = true; ipcMain.removeHandler(sdkChannel); void stopRegisteredPlugin(window.webContents).catch(() => undefined); cleanupSession(pluginSession); if (!window.isDestroyed()) window.destroy(); } };
const instance: PluginJsHostInstance = { stop: () => { logPluginDiagnostic(logger, "debug", "plugin js host stop", { pluginId: options.record.id, runtime: "javascript", phase: "begin" }); stopped.value = true; removeSdkHandler(); void stopRegisteredPlugin(window.webContents).catch(() => undefined); cleanupSession(pluginSession); if (!window.isDestroyed()) window.destroy(); logPluginDiagnostic(logger, "debug", "plugin js host stop", { pluginId: options.record.id, runtime: "javascript", phase: "end" }); } };
await new Promise<void>((resolve, reject) => {
let settled = false;
@ -66,14 +76,16 @@ export class ElectronPluginJsHost implements PluginJsHost {
const cleanup = () => { clearTimeout(timeout); window.webContents.removeListener("did-finish-load", loaded); window.webContents.removeListener("render-process-gone", gone); window.webContents.removeListener("unresponsive", unresponsive); };
const fail = (error: Error) => { if (settled) return; settled = true; cleanup(); instance.stop(); reject(error); };
const loaded = () => {
void runRegistrationHandshake(window.webContents, moduleUrl, options.sdk).then(() => { if (settled) return; settled = true; cleanup(); resolve(); }, (error: unknown) => fail(error instanceof Error ? error : new Error("JavaScript plugin registration failed.")));
logPluginDiagnostic(logger, "debug", "plugin js host registration", { pluginId: options.record.id, runtime: "javascript", phase: "begin" });
void runRegistrationHandshake(window.webContents, moduleUrl, options.sdk).then(() => { logPluginDiagnostic(logger, "info", "plugin js host registration", { pluginId: options.record.id, runtime: "javascript", phase: "success" }); if (settled) return; settled = true; cleanup(); resolve(); }, (error: unknown) => { logPluginDiagnostic(logger, "warn", "plugin js host registration", { pluginId: options.record.id, runtime: "javascript", phase: "fail", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error) }); fail(error instanceof Error ? error : new Error("JavaScript plugin registration failed.")); });
};
const gone = (_event: Event, details: RenderProcessGoneDetails) => fail(new Error(`JavaScript plugin renderer exited: ${details.reason}`));
const unresponsive = () => fail(new Error("JavaScript plugin renderer became unresponsive."));
window.webContents.once("did-finish-load", loaded);
window.webContents.once("render-process-gone", gone);
window.webContents.once("unresponsive", unresponsive);
window.loadURL(htmlUrl).catch((error: unknown) => fail(error instanceof Error ? error : new Error("JavaScript plugin failed to load.")));
logPluginDiagnostic(logger, "debug", "plugin js host load", { pluginId: options.record.id, runtime: "javascript", phase: "begin" });
window.loadURL(htmlUrl).then(() => logPluginDiagnostic(logger, "debug", "plugin js host load", { pluginId: options.record.id, runtime: "javascript", phase: "success" })).catch((error: unknown) => { logPluginDiagnostic(logger, "warn", "plugin js host load", { pluginId: options.record.id, runtime: "javascript", phase: "fail", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error) }); fail(error instanceof Error ? error : new Error("JavaScript plugin failed to load.")); });
});
if (stopped.value) throw new Error("JavaScript plugin stopped during startup.");
@ -85,12 +97,32 @@ function getPluginSdkPreloadPath(): string {
return `${app.getAppPath()}/plugin-sdk-preload.cjs`;
}
function installSdkHandler(channel: string, contents: WebContents, sdk: PluginSdkApi | undefined): void {
function installSdkHandler(channel: string, contents: WebContents, sdk: PluginSdkApi | undefined, pluginId: string): () => void {
const logger = (sdk as SdkWithLogger | undefined)?.__logger;
const syncListener = (event: IpcMainEvent, path: unknown, args: unknown[]) => {
const started = Date.now();
try {
if (event.sender !== contents) throw new Error("Invalid plugin SDK sender.");
if (!sdk || typeof path !== "string" || !isPluginSdkRoute(path) || !Array.isArray(args)) throw new Error("Invalid plugin SDK call.");
event.returnValue = dispatchSyncSdkCall(sdk, path, args);
} catch (error) {
logPluginDiagnostic(logger, "warn", "plugin sdk dispatch failed", { pluginId, route: typeof path === "string" ? path : "invalid", ok: false, reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started });
event.returnValue = { __openPetsError: error instanceof Error ? error.message : String(error) };
}
};
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, path: unknown, args: unknown[]) => {
if (event.sender !== contents) throw new Error("Invalid plugin SDK sender.");
if (!sdk || typeof path !== "string" || !Array.isArray(args)) throw new Error("Invalid plugin SDK call.");
return dispatchSdkCall(contents, sdk, path, args);
const started = Date.now();
try {
if (event.sender !== contents) throw new Error("Invalid plugin SDK sender.");
if (!sdk || typeof path !== "string" || !isPluginSdkRoute(path) || !Array.isArray(args)) throw new Error("Invalid plugin SDK call.");
return await dispatchSdkCall(contents, sdk, path, args);
} catch (error) {
logPluginDiagnostic(logger, "warn", "plugin sdk dispatch failed", { pluginId, route: typeof path === "string" ? path : "invalid", ok: false, reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started });
throw error;
}
});
ipcMain.on(channel, syncListener);
return () => { ipcMain.removeHandler(channel); ipcMain.off(channel, syncListener); };
}
type RunCallback = (id: unknown) => ((...callbackArgs: unknown[]) => Promise<unknown>) | undefined;
@ -99,13 +131,13 @@ type SdkCallHandler = (sdk: PluginSdkApi, args: unknown[], runCallback: RunCallb
const noop = (): void => undefined;
const callbackOf = (runCallback: RunCallback, id: unknown): ((...callbackArgs: unknown[]) => unknown) => runCallback(id) ?? noop;
const sdkCallHandlers: Record<string, SdkCallHandler> = {
export const sdkCallHandlers: Record<PluginSdkRoute, SdkCallHandler> = {
// Pet handles (first arg is the pet handle id; "default" targets the default pet).
"pet.speak": (sdk, args) => sdk.pets.forPet(args[0]).speak(args[1]),
"pet.react": (sdk, args) => sdk.pets.forPet(args[0]).react(args[1] as never),
"pet.setAnimation": (sdk, args) => sdk.pets.forPet(args[0]).setAnimation(args[1]),
"pet.setScale": (sdk, args) => sdk.pets.forPet(args[0]).setScale(args[1]),
"pet.badge": (sdk, args) => sdk.pets.forPet(args[0]).badge(args[1]),
"pet.setStatusReaction": (sdk, args) => sdk.pets.forPet(args[0]).setStatusReaction(args[1]),
"pet.moveBy": (sdk, args) => sdk.pets.forPet(args[0]).moveBy(args[1]),
"pet.wander": (sdk, args) => sdk.pets.forPet(args[0]).wander(args[1]),
"pet.moveToHome": (sdk, args) => sdk.pets.forPet(args[0]).moveToHome(),
@ -124,6 +156,7 @@ const sdkCallHandlers: Record<string, SdkCallHandler> = {
"pets.offChange": (sdk, args) => sdk.pets.offChange(args[0]),
// UI: bubbles, toasts, panels, menus.
"ui.bubble": (sdk, args) => sdk.ui.bubble(args[0]),
"ui.alert": (sdk, args) => sdk.ui.alert(args[0]),
"ui.bubbleUpdate": (sdk, args) => sdk.ui.bubbleUpdate(args[0], args[1]),
"ui.bubbleDismiss": (sdk, args) => sdk.ui.bubbleDismiss(args[0]),
"ui.bubblePin": (sdk, args) => sdk.ui.bubblePin(args[0]),
@ -141,11 +174,15 @@ const sdkCallHandlers: Record<string, SdkCallHandler> = {
"ui.menuOffSelect": (sdk, args) => sdk.ui.menuOffSelect(args[0]),
// Audio.
"audio.play": (sdk, args) => sdk.audio.play(args[0], args[1]),
"audio.importUserSound": (sdk, args) => sdk.audio.importUserSound(args[0], args[1]),
"audio.forgetUserSound": (sdk, args) => sdk.audio.forgetUserSound(args[0]),
"audio.stop": (sdk) => sdk.audio.stop(),
// Senses bus.
"events.on": (sdk, args, runCallback) => sdk.events.on(args[0], callbackOf(runCallback, args[1])),
"events.off": (sdk, args) => sdk.events.off(args[0]),
// Assets.
// Preload currently constructs asset refs locally, but keep this route in the
// canonical table for host-side parity and future explicit asset resolution.
"assets.resolve": (sdk, args) => sdk.assets.resolve(args[0], args[1]),
// Inter-plugin bus.
"bus.publish": (sdk, args) => sdk.bus.publish(args[0], args[1]),
@ -212,9 +249,18 @@ const sdkCallHandlers: Record<string, SdkCallHandler> = {
"log.info": (sdk, args) => sdk.log.info(...args),
"log.warn": (sdk, args) => sdk.log.warn(...args),
"log.error": (sdk, args) => sdk.log.error(...args),
"i18n.t": (sdk, args) => sdk.t(String(args[0] ?? ""), args[1] as never),
"i18n.locale": (sdk) => sdk.locale,
};
async function dispatchSdkCall(contents: WebContents, sdk: PluginSdkApi, path: string, args: unknown[]): Promise<unknown> {
function dispatchSyncSdkCall(sdk: PluginSdkApi, path: PluginSdkRoute, args: unknown[]): unknown {
if (path !== "i18n.t" && path !== "i18n.locale") throw new Error("Plugin SDK call is not synchronous.");
const handler = sdkCallHandlers[path];
if (!handler) throw new Error("Unknown plugin SDK call.");
return handler(sdk, args, () => undefined, undefined as never);
}
async function dispatchSdkCall(contents: WebContents, sdk: PluginSdkApi, path: PluginSdkRoute, args: unknown[]): Promise<unknown> {
const runCallback: RunCallback = (id) => typeof id === "string" ? (...callbackArgs: unknown[]) => contents.executeJavaScript(`globalThis.__openPetsRunCallback(${JSON.stringify(id)}, ${JSON.stringify(callbackArgs)})`, true) : undefined;
const handler = sdkCallHandlers[path];
if (!handler) throw new Error("Unknown plugin SDK call.");

View file

@ -43,7 +43,7 @@ export type PluginJavascriptPermission = Exclude<PluginPermission, "timer">;
/** Permissions flagged sensitive in the UI (louder consent, global toggles). */
export const sensitivePluginPermissions: ReadonlySet<PluginPermission> = new Set(["voice:listen", "clipboard", "pet:speak:dynamic"]);
export type PluginIcon = "plugin" | "bell" | "timer" | "github" | "heart" | "sparkles" | "coffee" | "focus";
export type PluginConfigFieldType = "text" | "textarea" | "number" | "boolean" | "select" | "time" | "date" | "multiSelect" | "list" | "secret";
export type PluginConfigFieldType = "text" | "textarea" | "number" | "boolean" | "select" | "time" | "date" | "multiSelect" | "list" | "secret" | "sound";
/** Asset kinds a v3 plugin can declare and bundle. */
export type PluginAssetKind = "icons" | "images" | "svgs" | "sprites" | "sounds";
@ -122,7 +122,7 @@ const configOptionFields = new Set(["label", "value"]);
const triggerFields = new Set(["on", "everyMinutes", "actions"]);
const speakActionFields = new Set(["type", "message"]);
const reactActionFields = new Set(["type", "reaction"]);
const supportedConfigTypes = new Set(["text", "textarea", "number", "boolean", "select", "time", "date", "multiSelect", "list", "secret"]);
const supportedConfigTypes = new Set(["text", "textarea", "number", "boolean", "select", "time", "date", "multiSelect", "list", "secret", "sound"]);
const deferredConfigTypes = new Set(["multi-select", "schedule", "connection"]);
const deferredConfigFeatures = new Set(["dynamicOptions"]);
const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github", "heart", "sparkles", "coffee", "focus"]);
@ -340,7 +340,7 @@ function validateConfigSchema(value: unknown, errors: PluginManifestValidationEr
addError(errors, "$.configSchema", "invalid_config_schema", "configSchema must be an object.");
return fields;
}
const v3OnlyTypes = new Set(["date", "secret"]);
const v3OnlyTypes = new Set(["date", "secret", "sound"]);
for (const [key, field] of Object.entries(value)) {
const path = `$.configSchema.${key}`;
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(key)) addError(errors, path, "invalid_config_key", "Config field keys must be simple identifiers.");

View file

@ -1,11 +1,12 @@
import { randomBytes } from "node:crypto";
import { promises as fs } from "node:fs";
import { dirname } from "node:path";
import { basename } from "node:path";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, ipcMain, session, type IpcMainEvent } from "electron";
import { debug, info } from "./logger.js";
import { debug, info, warn } from "./logger.js";
import { logPluginDiagnostic, truncatePluginConsoleMessage } from "./plugin-diagnostics.js";
import { isUnderPath } from "./plugin-manifest-reader.js";
import type { PluginPanelHostHandle } from "./plugin-sdk-bridge.js";
@ -34,6 +35,7 @@ const maxPanelMessageBytes = 64 * 1024;
export async function openPluginPanel(options: OpenPluginPanelOptions): Promise<PluginPanelHostHandle> {
const realInstall = await fs.realpath(options.installPath);
const realPanel = await fs.realpath(options.panelPath);
const panelLabel = basename(realPanel);
if (!isUnderPath(realPanel, realInstall)) throw new Error("Plugin panel page is outside the plugin install directory.");
const panelStat = await fs.lstat(realPanel);
if (!panelStat.isFile()) throw new Error("Plugin panel page is not a file.");
@ -47,6 +49,7 @@ export async function openPluginPanel(options: OpenPluginPanelOptions): Promise<
const allowedRoot = pathToFileURL(realInstall).toString();
panelSession.webRequest.onBeforeRequest((details, callback) => {
const allowed = details.url === "about:blank" || (details.url.startsWith("file://") && (details.url === allowedRoot || details.url.startsWith(`${allowedRoot.endsWith("/") ? allowedRoot : `${allowedRoot}/`}`)));
if (!allowed) logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel request blocked", { pluginId: options.pluginId, panelId: token, reason: "outside-install", host: safeHost(details.url) });
callback({ cancel: !allowed });
});
@ -69,16 +72,21 @@ export async function openPluginPanel(options: OpenPluginPanelOptions): Promise<
});
window.setMenu(null);
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event) => event.preventDefault());
window.webContents.on("will-redirect", (event) => event.preventDefault());
panelSession.on("will-download", (event) => event.preventDefault());
window.webContents.on("will-navigate", (event, url) => { logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel navigation blocked", { pluginId: options.pluginId, panelId: token, host: safeHost(url) }); event.preventDefault(); });
window.webContents.on("will-redirect", (event, url) => { logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel redirect blocked", { pluginId: options.pluginId, panelId: token, host: safeHost(url) }); event.preventDefault(); });
window.webContents.on("did-fail-load", (_event, _code, description) => logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel load failed", { pluginId: options.pluginId, panelId: token, source: panelLabel, reason: description }));
window.webContents.on("console-message", (_event, level, message, line, sourceId) => logPluginDiagnostic(panelDiagnosticLogger, level >= 2 ? "warn" : "debug", "plugin panel console", { pluginId: options.pluginId, panelId: token, level, line, source: sourceId, reason: truncatePluginConsoleMessage(message) }));
window.webContents.on("render-process-gone", (_event, details) => logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel renderer gone", { pluginId: options.pluginId, panelId: token, reason: details.reason }));
panelSession.on("will-download", (event) => { logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel download blocked", { pluginId: options.pluginId, panelId: token }); event.preventDefault(); });
const handleToPlugin = (event: IpcMainEvent, msg: unknown): void => {
if (event.sender !== window.webContents) return;
try {
const text = JSON.stringify(msg ?? null);
if (text !== undefined && Buffer.byteLength(text) <= maxPanelMessageBytes) options.onMessage(JSON.parse(text));
} catch { /* non-clone-safe panel messages are dropped */ }
const sizeBytes = text === undefined ? 0 : Buffer.byteLength(text);
if (text !== undefined && sizeBytes <= maxPanelMessageBytes) options.onMessage(JSON.parse(text));
else logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel message dropped", { pluginId: options.pluginId, panelId: token, reason: "too-large", sizeBytes });
} catch { logPluginDiagnostic(panelDiagnosticLogger, "warn", "plugin panel message dropped", { pluginId: options.pluginId, panelId: token, reason: "not-clone-safe" }); }
};
const handleCloseRequest = (event: IpcMainEvent): void => {
if (event.sender !== window.webContents) return;
@ -93,7 +101,7 @@ export async function openPluginPanel(options: OpenPluginPanelOptions): Promise<
options.onClosed();
});
debug("plugin", "panel loading", { pluginId: options.pluginId, panel: dirname(realPanel) });
debug("plugin", "panel loading", { pluginId: options.pluginId, panel: panelLabel });
await window.loadFile(realPanel);
window.show();
info("plugin", "panel opened", { pluginId: options.pluginId });
@ -106,3 +114,10 @@ export async function openPluginPanel(options: OpenPluginPanelOptions): Promise<
close: async () => { if (!window.isDestroyed()) window.close(); },
};
}
function safeHost(urlText: string): string | undefined { try { return new URL(urlText).hostname || undefined; } catch { return undefined; } }
function panelDiagnosticLogger(level: "debug" | "info" | "warn" | "error", message: string, fields?: Record<string, unknown>): void {
if (level === "warn") warn("plugin", message, fields);
else if (level === "info") info("plugin", message, fields);
else debug("plugin", message, fields);
}

View file

@ -1,7 +1,7 @@
import { BrowserWindow } from "electron";
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
import { applyExternalPetReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-pet-controller.js";
import { applyExternalPetReaction, applyExternalPetStatusReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-pet-controller.js";
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { debug, info } from "./logger.js";
@ -27,7 +27,7 @@ type SpawnedPet = {
window: BrowserWindow | null;
readonly arbiter: PetBubbleArbiter;
bubbles: PetPluginBubbles;
badge: PetStatusBadgeReaction | null;
statusReaction: PetStatusBadgeReaction | null;
currentAnimation: string;
spriteOverride: { filePath: string; fps: number; loop: boolean } | null;
scale: PetScaleValue;
@ -78,7 +78,7 @@ export function onPluginPetsChange(listener: (pets: PluginPetInfo[]) => void): (
function refreshSpawnedPet(pet: SpawnedPet): void {
if (!pet.window || pet.window.isDestroyed()) return;
void loadExplicitPetContent(pet.window, pet.petId, null, pet.badge, undefined, pet.scale, pet.bubbles.transient || pet.bubbles.pinned ? pet.bubbles : null).then(() => {
void loadExplicitPetContent(pet.window, pet.petId, null, pet.statusReaction, undefined, pet.scale, pet.bubbles.transient || pet.bubbles.pinned ? pet.bubbles : null).then(() => {
if (pet.window && !pet.window.isDestroyed() && pet.spriteOverride) setPetSpriteOverride(pet.window, pet.spriteOverride);
});
}
@ -100,7 +100,7 @@ export async function spawnPluginPet(opts: { pluginId: string; petId: string; na
window: null,
arbiter: null as unknown as PetBubbleArbiter,
bubbles: { transient: null, pinned: null },
badge: null,
statusReaction: null,
currentAnimation: "idle",
spriteOverride: null,
scale: state.preferences.petScale as PetScaleValue,
@ -223,15 +223,14 @@ export function setPluginPetScale(petHandleId: string, scale: number): void {
if (pet) pet.scale = scale as PetScaleValue;
}
export function badgePluginPet(petHandleId: string, reaction: OpenPetsReaction | null): void {
export function setPluginPetStatusReaction(petHandleId: string, reaction: OpenPetsReaction | null): void {
if (petHandleId === "default") {
// The default pet's badge flows through its reaction pipeline.
if (reaction) applyExternalPetReaction(reaction);
applyExternalPetStatusReaction(reaction);
return;
}
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
pet.badge = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
pet.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
refreshSpawnedPet(pet);
}

View file

@ -4,11 +4,14 @@ import { relative, resolve, sep } from "node:path";
import { validateReaction, validateSayMessage, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { resolvePluginNumericConfig, resolvePluginStringConfig } from "./plugin-config.js";
import type { PluginJsHost, PluginJsHostInstance } from "./plugin-js-host.js";
import { loadPluginLocales, registerPluginLocales, unregisterPluginLocales } from "./plugin-i18n.js";
import { defaultMaxPluginManifestBytes, readSafePluginManifest } from "./plugin-manifest-reader.js";
import { type OpenPetsJavascriptPluginManifest, type OpenPetsPluginManifest, type PluginAction } from "./plugin-manifest.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
import { PluginSdkBridge, type PluginHostCapabilities, type PluginInspectorState, type PluginLogLevel, type PluginRuntimePublicState, type PluginStorageStore } from "./plugin-sdk-bridge.js";
import { PluginSdkBridge, type PluginHostCapabilities, type PluginLogLevel, type PluginRuntimePublicState, type PluginStorageStore } from "./plugin-sdk-bridge.js";
import type { PluginInspectorState } from "./plugin-sdk-state.js";
import type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
export interface PluginTimerHandle { cancel(): void }
export interface PluginRuntimeScheduler { setTimeout(callback: () => void, delayMs: number): PluginTimerHandle }
@ -63,13 +66,17 @@ export class PluginRuntime {
}
async start(): Promise<void> {
logPluginDiagnostic(this.#logger, "debug", "plugin runtime start", { phase: "begin" });
this.#active = true;
await this.reloadAll();
logPluginDiagnostic(this.#logger, "debug", "plugin runtime start", { phase: "success" });
}
stop(): void {
logPluginDiagnostic(this.#logger, "debug", "plugin runtime stop", { phase: "begin" });
this.#active = false;
for (const id of this.#slots.keys()) this.#cancelPlugin(id);
logPluginDiagnostic(this.#logger, "debug", "plugin runtime stop", { phase: "end" });
}
getPluginState(id: string): PluginRuntimePublicState { return this.#sdkBridge.getPublicState(id); }
@ -91,10 +98,12 @@ export class PluginRuntime {
}
async reloadPlugin(id: string): Promise<void> {
const started = Date.now();
logPluginDiagnostic(this.#logger, "debug", "plugin reload", { pluginId: id, phase: "begin" });
this.#cancelPlugin(id);
if (!this.#active) return;
if (!this.#active) { logPluginDiagnostic(this.#logger, "debug", "plugin reload", { pluginId: id, phase: "skip", reason: "runtime-inactive" }); return; }
const record = this.#stateStore.getRecord(id);
if (!record || !record.enabled || record.catalogDisabled) return;
if (!record || !record.enabled || record.catalogDisabled) { logPluginDiagnostic(this.#logger, "debug", "plugin reload", { pluginId: id, phase: "skip", reason: !record ? "not-installed" : !record.enabled ? "disabled" : "catalog-disabled" }); return; }
const slot = this.#slotFor(id);
const generation = slot.generation;
@ -103,14 +112,18 @@ export class PluginRuntime {
if (!this.#canCommitReload(record, generation)) return;
this.#stateStore.clearBrokenReason(id);
if (manifest.runtime === "javascript") {
logPluginDiagnostic(this.#logger, "debug", "plugin start", { pluginId: id, runtime: "javascript", phase: "begin" });
await this.#startJavascriptPlugin(record, manifest, slot, generation);
} else {
logPluginDiagnostic(this.#logger, "debug", "plugin start", { pluginId: id, runtime: "declarative", phase: "begin" });
const timers = this.#compileDeclarativePlugin(record, manifest);
if (!this.#canCommitReload(record, generation)) return;
slot.active = true;
for (const timer of timers) this.#scheduleTimer(id, slot, generation, timer);
logPluginDiagnostic(this.#logger, "info", "plugin reload", { pluginId: id, runtime: "declarative", phase: "success", durationMs: Date.now() - started, count: timers.length });
}
} catch (error) {
logPluginDiagnostic(this.#logger, "warn", "plugin reload", { pluginId: id, phase: "fail", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started });
if (this.#canCommitReload(record, generation)) this.#markBroken(id, error instanceof Error ? error.message : "Plugin runtime validation failed.");
}
}
@ -140,6 +153,8 @@ export class PluginRuntime {
const approved = new Set(record.approvedPermissions);
for (const permission of manifest.permissions) if (!approved.has(permission)) throw new Error(`Plugin permission is not approved: ${permission}`);
const entryPath = await resolveJavascriptEntry(record.installPath, manifest.entry);
const catalogs = await loadPluginLocales(record.installPath);
registerPluginLocales(record.id, catalogs);
const sdk = this.#sdkBridge.createApi(record, manifest);
const host = await this.#jsHost.startPlugin({ record, manifest, entryPath, sdk, onBroken: (reason) => {
if (this.#canCommitReload(record, generation)) this.#markBroken(record.id, reason);
@ -150,7 +165,7 @@ export class PluginRuntime {
}
slot.jsHost = host;
slot.active = true;
this.#logger("info", "plugin started", { id: record.id, version: record.version, source: record.source, runtime: manifest.runtime });
logPluginDiagnostic(this.#logger, "info", "plugin started", { pluginId: record.id, runtime: manifest.runtime, source: record.source, phase: "success" });
}
#scheduleTimer(id: string, slot: PluginRuntimeSlot, generation: number, timer: CompiledTimer): void {
@ -161,10 +176,12 @@ export class PluginRuntime {
void this.#runTimer(id, slot, generation, timer);
}, timer.intervalMs);
slot.timers.push(handle);
logPluginDiagnostic(this.#logger, "debug", "plugin declarative schedule created", { pluginId: id, runtime: "declarative", scheduleId: String(generation), durationMs: timer.intervalMs });
}
async #runTimer(id: string, slot: PluginRuntimeSlot, generation: number, timer: CompiledTimer): Promise<void> {
try {
logPluginDiagnostic(this.#logger, "debug", "plugin declarative schedule fired", { pluginId: id, runtime: "declarative", scheduleId: String(generation) });
for (const action of timer.actions) {
if (!this.#active || !slot.active || slot.generation !== generation) return;
if (action.type === "pet.speak") await this.#petApi.speak(action.message);
@ -172,27 +189,31 @@ export class PluginRuntime {
}
if (this.#active && slot.active && slot.generation === generation) this.#scheduleTimer(id, slot, generation, timer);
} catch (error) {
logPluginDiagnostic(this.#logger, "warn", "plugin declarative callback failed", { pluginId: id, runtime: "declarative", scheduleId: String(generation), reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error) });
if (this.#active && slot.active && slot.generation === generation) this.#markBroken(id, error instanceof Error ? error.message : "Plugin action failed.");
}
}
#markBroken(id: string, reason: string): void {
this.#logger("error", "plugin marked broken", { id, reason });
logPluginDiagnostic(this.#logger, "error", "plugin marked broken", { pluginId: id, reason });
this.#cancelPlugin(id);
this.#stateStore.setBrokenReason(id, reason);
}
#cancelPlugin(id: string): void {
const slot = this.#slotFor(id);
if (slot.active || slot.timers.length > 0 || slot.jsHost) logPluginDiagnostic(this.#logger, "debug", "plugin cancel", { pluginId: id, phase: "begin", count: slot.timers.length });
slot.active = false;
slot.generation += 1;
for (const timer of slot.timers) timer.cancel();
slot.timers = [];
slot.jsHost?.stop();
slot.jsHost = undefined;
unregisterPluginLocales(id);
this.#sdkBridge.clearPlugin(id);
const teardown = (this.#capabilities as { clearPlugin?: (pluginId: string) => void } | undefined)?.clearPlugin;
if (teardown) { try { teardown(id); } catch { /* host teardown is best effort */ } }
logPluginDiagnostic(this.#logger, "debug", "plugin cancel", { pluginId: id, phase: "end" });
}
#slotFor(id: string): PluginRuntimeSlot {

View file

@ -0,0 +1,55 @@
import type { PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
import type { PluginRuntimeState } from "./plugin-sdk-state.js";
import { pluginNamedHostSounds } from "./plugin-sdk-types.js";
export type PluginAudioApi = ReturnType<typeof createPluginAudioApi>;
export function createPluginAudioApi(options: {
readonly pluginId: string;
readonly state: PluginRuntimeState;
readonly capabilities: PluginHostCapabilities;
readonly requirePermission: (permission: PluginPermission) => void;
readonly audioPerMinute: number;
readonly resolveAssetRef: (ref: unknown, kinds: readonly PluginAssetKind[]) => { path: string };
}) {
const { pluginId, state, capabilities, requirePermission, audioPerMinute, resolveAssetRef } = options;
const play = async (sound: unknown, playOptions?: unknown) => {
requirePermission("audio");
state.audioWindow.tick(audioPerMinute, "audio");
check(capabilities.settings.audioAllowed(), "Plugin sound is disabled in settings.");
check(!capabilities.settings.inQuietHours(), "Quiet hours are active.");
const volume = clampNumber(Number(isRecord(playOptions) && playOptions.volume !== undefined ? playOptions.volume : 0.6), 0, 1);
if (typeof sound === "string") {
check(pluginNamedHostSounds.has(sound), `Unknown host sound: ${sound}`);
await capabilities.audio.play({ kind: "named", name: sound }, volume);
} else if (isRecord(sound) && sound.kind === "user-sound" && typeof sound.id === "string") {
await capabilities.audio.play({ kind: "user-sound", pluginId, id: sound.id }, volume);
} else {
await capabilities.audio.play({ kind: "file", path: resolveAssetRef(sound, ["sounds"]).path }, volume);
}
};
return {
play,
importUserSound: async (file: unknown, opts?: unknown) => {
requirePermission("audio");
requirePermission("files");
const fileId = isRecord(file) && typeof file.fileId === "string" ? file.fileId : String(file);
check(state.pickedFiles.has(fileId), "Plugin file handle is invalid.");
const importOptions = isRecord(opts) ? opts : {};
return capabilities.audio.importUserSound(pluginId, fileId, { name: importOptions.name === undefined ? undefined : String(importOptions.name).slice(0, 80) });
},
forgetUserSound: async (ref: unknown) => {
requirePermission("audio");
if (!isRecord(ref) || ref.kind !== "user-sound" || typeof ref.id !== "string") throw new Error("Invalid user sound reference.");
await capabilities.audio.forgetUserSound(pluginId, { kind: "user-sound", id: ref.id });
},
stop: async () => { requirePermission("audio"); await capabilities.audio.stop(); },
};
}
function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); }
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

View file

@ -3,14 +3,26 @@ import { lookup } from "node:dns/promises";
import * as net from "node:net";
import { join } from "node:path";
import { getActiveLocaleLang } from "./i18n/index.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { validateReaction, validateSayMessage } from "./local-ipc-protocol.js";
import { makePluginT } from "./plugin-i18n.js";
import { resolveDeclaredAssetPath, resolveDeclaredPanelPath } from "./plugin-assets.js";
import type { PluginConfig } from "./plugin-config.js";
import type { OpenPetsJavascriptPluginManifest, PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
import type { PluginRuntimeScheduler, PluginTimerHandle } from "./plugin-runtime.js";
import type { PluginRuntimeScheduler } from "./plugin-runtime.js";
import { createPluginAudioApi } from "./plugin-sdk-audio.js";
import { createPluginBusApi, type PluginBusTopicEntry } from "./plugin-sdk-bus.js";
import { createPluginConfigApi } from "./plugin-sdk-config.js";
import { createPluginEventsApi } from "./plugin-sdk-events.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
import type { ScheduleSpec, PluginInspectorState } from "./plugin-sdk-state.js";
import { WindowCounter, type PluginRuntimeState } from "./plugin-sdk-state.js";
import { createPluginStorageApi } from "./plugin-sdk-storage.js";
import { createPluginUiApi } from "./plugin-sdk-ui.js";
import type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
// ---------------------------------------------------------------------------
// Public bridge types
@ -34,7 +46,7 @@ export type PluginStatus = { text: string; tone?: "info" | "success" | "warning"
export type PluginRuntimePublicState = { commands: readonly PluginCommand[]; status?: PluginStatus; menuItems?: readonly PluginMenuItem[] };
export type PluginLogLevel = "debug" | "info" | "warn" | "error";
export type PluginRuntimeLogger = (level: PluginLogLevel, message: string, fields?: Record<string, unknown>) => void;
export type PluginSdkApi = ReturnType<PluginSdkBridge["createApi"]>;
export type PluginSdkApi = Omit<ReturnType<PluginSdkBridge["createApi"]>, "__logger">;
export interface PluginStorageStore { get(pluginId: string, key: string): unknown; set(pluginId: string, key: string, value: unknown): void; delete(pluginId: string, key: string): void; keys?(pluginId: string): string[] }
// ---------------------------------------------------------------------------
@ -95,7 +107,10 @@ export interface PluginHostCapabilities {
show(opts: { petId: string; pluginId: string; bubble: PluginBubbleDescriptor; callbacks: PluginBubbleCallbacks }): Promise<PluginBubbleHostHandle>;
};
audio: {
play(spec: { kind: "named"; name: string } | { kind: "file"; path: string }, volume: number): Promise<void>;
play(spec: { kind: "named"; name: string } | { kind: "file"; path: string } | { kind: "user-sound"; pluginId: string; id: string }, volume: number): Promise<void>;
importUserSound(pluginId: string, fileId: string, opts?: { name?: string }): Promise<{ kind: "user-sound"; id: string; name?: string }>;
importUserSoundFromPath?(pluginId: string, path: string, opts?: { name?: string }): Promise<{ kind: "user-sound"; id: string; name?: string }>;
forgetUserSound(pluginId: string, ref: { kind: "user-sound"; id: string }): Promise<void>;
stop(): Promise<void>;
};
events: {
@ -110,7 +125,7 @@ export interface PluginHostCapabilities {
react(petHandleId: string, reaction: OpenPetsReaction): Promise<void>;
setAnimation(petHandleId: string, spec: PluginAnimationSpec): Promise<void>;
setScale(petHandleId: string, scale: number): Promise<void>;
badge(petHandleId: string, reaction: OpenPetsReaction | null): Promise<void>;
setStatusReaction(petHandleId: string, reaction: OpenPetsReaction | null): Promise<void>;
moveBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise<void>;
wander(petHandleId: string, opts: { distance?: number; durationMs?: number }): Promise<void>;
moveToHome(petHandleId: string): Promise<void>;
@ -165,11 +180,10 @@ export interface PluginHostCapabilities {
listenAllowed(): boolean;
inQuietHours(): boolean;
};
/** Trusted host lifecycle hook; not exposed through the plugin SDK. */
clearPlugin?(pluginId: string): void;
}
const namedHostSounds = new Set(["chime", "pop", "nom", "alert", "level-up", "tick", "success", "error"]);
export const pluginNamedHostSounds = namedHostSounds;
/**
* Capability defaults used when the Electron layer is not wired (contract
* tests, headless runs). Pet speech/reactions fall back to the v2 pet API;
@ -191,7 +205,7 @@ export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): Plugi
};
},
},
audio: { play: async () => undefined, stop: async () => undefined },
audio: { play: async () => undefined, importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), importUserSoundFromPath: async (_pluginId, _path, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), forgetUserSound: async () => undefined, stop: async () => undefined },
events: { subscribe: () => () => undefined },
pets: {
list: () => [{ id: "default", name: "Default pet", kind: "default", visible: true }],
@ -202,7 +216,7 @@ export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): Plugi
react: async (_petId, reaction) => { await petApi.react(reaction); },
setAnimation: async (_petId, spec) => { if (spec.kind === "reaction") await petApi.react(spec.reaction); },
setScale: async () => undefined,
badge: async () => undefined,
setStatusReaction: async () => undefined,
moveBy: async (_petId, opts) => { await petApi.moveBy(opts); },
wander: async (_petId, opts) => { await petApi.wander(opts); },
moveToHome: async () => { await petApi.moveToHome(); },
@ -244,39 +258,10 @@ export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): Plugi
// Quotas
// ---------------------------------------------------------------------------
const quotas = {
petActionsPerMinute: 60,
schedules: 64,
commands: 32,
menuItems: 16,
storageBytes: 5 * 1024 * 1024,
storageSubscriptions: 64,
logsPerMinute: 200,
httpPerMinute: 30,
httpResponseBytes: 4 * 1024 * 1024,
httpRequestBodyBytes: 256 * 1024,
streamResponseBytes: 10 * 1024 * 1024,
busPerMinute: 120,
busPayloadBytes: 32 * 1024,
busSubscriptions: 64,
eventSubscriptions: 64,
audioPerMinute: 20,
notifyPerMinute: 10,
toastPerMinute: 20,
aiPerMinute: 20,
voicePerMinute: 10,
activeBubbles: 8,
activePanels: 3,
spawnedPets: 4,
secretBytes: 8 * 1024,
dynamicTextChars: 2000,
markdownChars: 1000,
};
export const pluginSdkQuotas: Readonly<typeof quotas> = quotas;
const quotas = pluginSdkQuotas;
const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
const busTopicPattern = /^[A-Za-z0-9._:/-]{1,128}$/;
const allowedEventNames = new Set([
"pet:clicked", "pet:doubleClicked", "pet:dragStart", "pet:dragEnd", "pet:hover", "pet:drop",
"idle:enter", "idle:exit", "agent:activity", "config:changed",
@ -315,56 +300,6 @@ export class MemoryPluginStorageStore implements PluginStorageStore {
// Per-plugin runtime state
// ---------------------------------------------------------------------------
type ScheduleSpec =
| { type: "once"; delayMs: number }
| { type: "every"; intervalMs: number }
| { type: "daily"; daily: { time: string; days?: number[] } }
| { type: "cron"; expr: string }
| { type: "at"; timestamp: number };
type ScheduleSlot = { spec: ScheduleSpec; callback: () => unknown; handle: PluginTimerHandle; nextRunMs: number };
type BubbleSlot = { host: PluginBubbleHostHandle; onAction?: (actionId: string) => void; onSubmit?: (values: Record<string, string | number>) => void; onDismiss?: (reason: PluginBubbleDismissReason) => void; dismissed: boolean };
type PluginRuntimeState = {
commands: Map<string, { meta: PluginCommand; handler: (values?: Record<string, unknown>) => unknown | Promise<unknown> }>;
menuItems: PluginMenuItem[];
menuHandlers: Set<(id: string) => void>;
status?: PluginStatus;
schedules: Map<string, ScheduleSlot>;
configListeners: Set<(config: PluginConfig) => void>;
storageSubscriptions: Map<string, { key: string; handler: (value: unknown) => void }>;
busSubscriptions: Map<string, { topic: string; handler: (payload: unknown) => void }>;
eventSubscriptions: Map<string, () => void>;
tickSubscriptions: Map<string, () => void>;
bubbles: Map<string, BubbleSlot>;
panels: Map<string, PluginPanelHostHandle & { onMessage?: (msg: unknown) => void }>;
spawnedPets: Set<string>;
pickedFiles: Set<string>;
userCommandDepth: number;
lastError?: string;
petWindow: WindowCounter;
logWindow: WindowCounter;
httpWindow: WindowCounter;
busWindow: WindowCounter;
audioWindow: WindowCounter;
notifyWindow: WindowCounter;
toastWindow: WindowCounter;
aiWindow: WindowCounter;
voiceWindow: WindowCounter;
};
export type PluginInspectorState = {
schedules: Array<{ id: string; type: ScheduleSpec["type"]; nextRunMs: number }>;
commands: readonly PluginCommand[];
menuItems: readonly PluginMenuItem[];
status?: PluginStatus;
activeBubbles: number;
activePanels: number;
eventSubscriptions: number;
lastError?: string;
quotaCounters: Record<string, number>;
};
// ---------------------------------------------------------------------------
// The bridge
// ---------------------------------------------------------------------------
@ -381,7 +316,7 @@ export class PluginSdkBridge {
readonly #logger: PluginRuntimeLogger;
readonly #capabilities: PluginHostCapabilities;
readonly #states = new Map<string, PluginRuntimeState>();
readonly #busTopics = new Map<string, Set<{ pluginId: string; handler: (payload: unknown) => void }>>();
readonly #busTopics = new Map<string, Set<PluginBusTopicEntry>>();
constructor(options: { stateStore: PluginStateStore; petApi: PluginPetApi; scheduler: PluginRuntimeScheduler; storage?: PluginStorageStore; onError?: (id: string, reason: string) => void; logger?: PluginRuntimeLogger; capabilities?: PluginHostCapabilities }) {
this.#stateStore = options.stateStore;
@ -476,27 +411,15 @@ export class PluginSdkBridge {
return out;
};
const showBubble = async (petHandleId: string, spec: unknown): Promise<{ bubbleId: string }> => {
requirePermission("pet:speak");
state.petWindow.tick(quotas.petActionsPerMinute, "pet action");
const bubble = validateBubbleSpec(spec);
check(countActiveBubbles(state) < quotas.activeBubbles, "Plugin active bubble quota exceeded.");
const bubbleId = opaqueId("bubble");
const slot: BubbleSlot = { host: undefined as unknown as PluginBubbleHostHandle, dismissed: false };
const callbacks: PluginBubbleCallbacks = {
onAction: (actionId) => { try { slot.onAction?.(actionId); } catch (error) { this.#onError(pluginId, safeError(error)); } },
onSubmit: (values) => { try { slot.onSubmit?.(values); } catch (error) { this.#onError(pluginId, safeError(error)); } },
onDismiss: (reason) => { slot.dismissed = true; state.bubbles.delete(bubbleId); try { slot.onDismiss?.(reason); } catch (error) { this.#onError(pluginId, safeError(error)); } },
};
slot.host = await caps.bubbles.show({ petId: validatePetHandleId(petHandleId), pluginId, bubble, callbacks });
if (!slot.dismissed) state.bubbles.set(bubbleId, slot);
return { bubbleId };
};
const requireBubble = (bubbleId: unknown): BubbleSlot => { const slot = state.bubbles.get(String(bubbleId)); if (!slot) throw new Error("Plugin bubble is no longer live."); return slot; };
const audio = createPluginAudioApi({ pluginId, state, capabilities: caps, requirePermission, audioPerMinute: quotas.audioPerMinute, resolveAssetRef });
const ui = createPluginUiApi({ pluginId, manifest, installPath: record.installPath, state, capabilities: caps, audio, requirePermission, guardCallback, validateBubbleSpec, validatePetHandleId, resolvePanelPath: (name) => resolveDeclaredPanelPath(manifest, record.installPath, name), normalizeJson, validateMenuItems, validateSayMessage, safeError, logger: this.#logger, onError: (reason) => this.#onError(pluginId, reason), quotas });
const storage = createPluginStorageApi({ pluginId, state, storage: this.#storage, requirePermission, guardCallback, validateStorageKey, onError: (reason) => this.#onError(pluginId, reason), safeError, storageSubscriptionsQuota: quotas.storageSubscriptions });
const config = createPluginConfigApi({ state, getConfig });
const events = createPluginEventsApi({ state, capabilities: caps, requirePermission, guardCallback, allowedEventNames, eventSubscriptionsQuota: quotas.eventSubscriptions });
const bus = createPluginBusApi({ pluginId, state, topics: this.#busTopics, requirePermission, guardCallback, normalizeJson, busPerMinute: quotas.busPerMinute, busPayloadBytes: quotas.busPayloadBytes, busSubscriptionsQuota: quotas.busSubscriptions });
const petNamespace = (petHandleId: string) => ({
speak: (spec: unknown) => showBubble(petHandleId, spec),
speak: (spec: unknown) => ui.showBubble(petHandleId, spec),
react: async (reaction: OpenPetsReaction) => {
requirePermission("pet:reaction");
state.petWindow.tick(quotas.petActionsPerMinute, "pet action");
@ -514,7 +437,7 @@ export class PluginSdkBridge {
await caps.pets.setAnimation(validatePetHandleId(petHandleId), { kind: "sprite", spritePath: sprite.path, loop: animation.loop !== false, fps });
},
setScale: async (scale: unknown) => { requirePermission("pet:animate"); const value = Number(scale); check(Number.isFinite(value) && value >= 0.5 && value <= 2, "Pet scale must be between 0.5 and 2."); await caps.pets.setScale(validatePetHandleId(petHandleId), value); },
badge: async (badge: unknown) => { requirePermission("pet:reaction"); state.petWindow.tick(quotas.petActionsPerMinute, "pet action"); await caps.pets.badge(validatePetHandleId(petHandleId), badge === null ? null : validateReaction(badge)); },
setStatusReaction: async (reaction: unknown) => { requirePermission("pet:reaction"); state.petWindow.tick(quotas.petActionsPerMinute, "pet action"); await caps.pets.setStatusReaction(validatePetHandleId(petHandleId), reaction === null || reaction === "idle" ? null : validateReaction(reaction)); },
moveBy: async (options: unknown) => { requirePermission("pet:move"); state.petWindow.tick(quotas.petActionsPerMinute, "pet action"); const opts = validateMoveBy(options); if (petHandleId === "default") await this.#petApi.moveBy(opts); else await caps.pets.moveBy(validatePetHandleId(petHandleId), opts); },
wander: async (options: unknown) => { requirePermission("pet:move"); state.petWindow.tick(quotas.petActionsPerMinute, "pet action"); const opts = validateWander(options); if (petHandleId === "default") await this.#petApi.wander(opts); else await caps.pets.wander(validatePetHandleId(petHandleId), opts); },
moveToHome: async () => { requirePermission("pet:move"); state.petWindow.tick(quotas.petActionsPerMinute, "pet action"); if (petHandleId === "default") await this.#petApi.moveToHome(); else await caps.pets.moveToHome(validatePetHandleId(petHandleId)); },
@ -542,6 +465,7 @@ export class PluginSdkBridge {
});
return {
__logger: this.#logger,
pet: petNamespace("default"),
pets: {
list: async () => { requirePermission("pets:read"); return caps.pets.list(); },
@ -565,93 +489,9 @@ export class PluginSdkBridge {
},
offChange: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
},
ui: {
bubble: (spec: unknown) => showBubble("default", spec),
bubbleUpdate: async (bubbleId: unknown, patch: unknown) => { const slot = requireBubble(bubbleId); await slot.host.update(validateBubbleSpec(patch, true)); },
bubbleDismiss: async (bubbleId: unknown) => { const slot = state.bubbles.get(String(bubbleId)); if (slot) await slot.host.dismiss(); },
bubblePin: async (bubbleId: unknown) => { requirePermission("pet:pin"); await requireBubble(bubbleId).host.pin(); },
bubbleUnpin: async (bubbleId: unknown) => { const slot = state.bubbles.get(String(bubbleId)); if (slot) await slot.host.unpin(); },
bubbleSubscribe: (bubbleId: unknown, kind: unknown, handler: (...args: never[]) => void) => {
const slot = requireBubble(bubbleId);
if (kind === "action") slot.onAction = handler as (actionId: string) => void;
else if (kind === "submit") slot.onSubmit = handler as (values: Record<string, string | number>) => void;
else if (kind === "dismiss") slot.onDismiss = handler as (reason: PluginBubbleDismissReason) => void;
else throw new Error("Invalid bubble subscription kind.");
},
toast: async (spec: unknown) => {
requirePermission("ui:toast");
state.toastWindow.tick(quotas.toastPerMinute, "toast");
if (!isRecord(spec)) throw new Error("Invalid toast spec.");
const text = validateSayMessage(String(spec.text ?? ""));
const tone = spec.tone === undefined ? undefined : (check(["info", "success", "warning", "error"].includes(String(spec.tone)), "Invalid toast tone."), spec.tone as PluginStatus["tone"]);
const durationMs = spec.durationMs === undefined ? undefined : clampNumber(Number(spec.durationMs), 1_000, 15_000);
await caps.toast({ text, tone, durationMs });
},
panel: async (spec: unknown) => {
requirePermission("ui:panel");
check(state.panels.size < quotas.activePanels, "Plugin panel quota exceeded.");
if (!isRecord(spec) || typeof spec.panel !== "string") throw new Error("Invalid panel spec.");
const panelPath = resolveDeclaredPanelPath(manifest, record.installPath, spec.panel);
const width = spec.width === undefined ? 420 : clampNumber(Number(spec.width), 200, 1200);
const height = spec.height === undefined ? 480 : clampNumber(Number(spec.height), 160, 900);
const title = spec.title === undefined ? manifest.name : String(spec.title).slice(0, 80);
const panelId = opaqueId("panel");
const holder: { onMessage?: (msg: unknown) => void } = {};
const host = await caps.panels.open({
pluginId, installPath: record.installPath, panelPath, title, width, height,
onMessage: (msg) => { try { holder.onMessage?.(msg); } catch (error) { this.#onError(pluginId, safeError(error)); } },
onClosed: () => { state.panels.delete(panelId); },
});
state.panels.set(panelId, Object.assign(host, holder));
return { panelId };
},
panelShow: async (panelId: unknown) => { await requirePanel(state, panelId).show(); },
panelHide: async (panelId: unknown) => { await requirePanel(state, panelId).hide(); },
panelPost: async (panelId: unknown, msg: unknown) => { await requirePanel(state, panelId).postMessage(normalizeJson(msg, quotas.busPayloadBytes, "panel message")); },
panelClose: async (panelId: unknown) => { const panel = state.panels.get(String(panelId)); if (panel) { await panel.close(); state.panels.delete(String(panelId)); } },
panelOnMessage: (panelId: unknown, handler: (msg: unknown) => void) => { requirePanel(state, panelId).onMessage = guardCallback(handler); },
menuSetItems: async (items: unknown) => { requirePermission("commands"); state.menuItems = validateMenuItems(items); },
menuOnSelect: (handler: (id: string) => void) => { requirePermission("commands"); const wrapped = guardCallback(handler); state.menuHandlers.add(wrapped); return { subscriptionId: registerDisposer(state, () => state.menuHandlers.delete(wrapped)) }; },
menuOffSelect: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
},
audio: {
play: async (sound: unknown, options?: unknown) => {
requirePermission("audio");
state.audioWindow.tick(quotas.audioPerMinute, "audio");
check(caps.settings.audioAllowed(), "Plugin sound is disabled in settings.");
check(!caps.settings.inQuietHours(), "Quiet hours are active.");
const volume = clampNumber(Number(isRecord(options) && options.volume !== undefined ? options.volume : 0.6), 0, 1);
if (typeof sound === "string") { check(namedHostSounds.has(sound), `Unknown host sound: ${sound}`); await caps.audio.play({ kind: "named", name: sound }, volume); }
else await caps.audio.play({ kind: "file", path: resolveAssetRef(sound, ["sounds"]).path }, volume);
},
stop: async () => { requirePermission("audio"); await caps.audio.stop(); },
},
events: {
on: (event: unknown, handler: (payload: Record<string, unknown>) => void) => {
requirePermission("events");
const name = String(event);
check(allowedEventNames.has(name), `Unknown plugin event: ${name}`);
if (name === "pet:drop") requirePermission("pet:drop");
check(state.eventSubscriptions.size < quotas.eventSubscriptions, "Plugin event subscription quota exceeded.");
const subId = opaqueId("event");
if (name === "config:changed") {
const listener = (config: PluginConfig) => guardCallback(handler)(config as Record<string, unknown>);
state.configListeners.add(listener);
state.eventSubscriptions.set(subId, () => state.configListeners.delete(listener));
} else if (name === "pet:drop") {
// Register dropped-file handles so files.read accepts them.
const wrapped = guardCallback((payload: Record<string, unknown>) => {
if (Array.isArray(payload.files)) for (const file of payload.files) { if (isRecord(file) && typeof file.fileId === "string") state.pickedFiles.add(file.fileId); }
handler(payload);
});
state.eventSubscriptions.set(subId, caps.events.subscribe(name, wrapped));
} else {
state.eventSubscriptions.set(subId, caps.events.subscribe(name, guardCallback(handler)));
}
return { subscriptionId: subId };
},
off: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
},
ui: ui.api,
audio,
events,
assets: {
resolve: (kind: unknown, name: unknown) => {
const kindMap: Record<string, PluginAssetKind> = { icon: "icons", image: "images", svg: "svgs", sprite: "sprites", sound: "sounds" };
@ -661,34 +501,7 @@ export class PluginSdkBridge {
return { kind: String(kind), name: String(name) };
},
},
bus: {
publish: async (topic: unknown, payload: unknown) => {
requirePermission("bus");
state.busWindow.tick(quotas.busPerMinute, "bus");
const topicName = String(topic);
check(busTopicPattern.test(topicName), "Invalid bus topic.");
const normalized = normalizeJson(payload, quotas.busPayloadBytes, "bus payload");
for (const subscriber of this.#busTopics.get(topicName) ?? []) {
if (subscriber.pluginId === pluginId) continue;
try { subscriber.handler(normalized); } catch { /* subscriber errors are isolated */ }
}
},
subscribe: (topic: unknown, handler: (payload: unknown) => void) => {
requirePermission("bus");
const topicName = String(topic);
check(busTopicPattern.test(topicName), "Invalid bus topic.");
check(state.busSubscriptions.size < quotas.busSubscriptions, "Plugin bus subscription quota exceeded.");
const entry = { pluginId, handler: guardCallback(handler) };
let subscribers = this.#busTopics.get(topicName);
if (!subscribers) { subscribers = new Set(); this.#busTopics.set(topicName, subscribers); }
subscribers.add(entry);
const subId = opaqueId("bus");
state.busSubscriptions.set(subId, { topic: topicName, handler: entry.handler });
state.eventSubscriptions.set(subId, () => { subscribers.delete(entry); state.busSubscriptions.delete(subId); });
return { subscriptionId: subId };
},
unsubscribe: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
},
bus,
schedule: {
once: (id: string, delayMs: number, callback: () => unknown) => { const delay = Number(delayMs); check(Number.isFinite(delay) && delay >= 1, "Invalid plugin schedule delay."); setSchedule(id, { type: "once", delayMs: delay }, callback); },
every: (id: string, intervalMs: number, callback: () => unknown) => { const interval = Number(intervalMs); check(Number.isFinite(interval) && interval >= 10 * 60_000, "Invalid plugin schedule delay."); setSchedule(id, { type: "every", intervalMs: interval }, callback); },
@ -699,43 +512,20 @@ export class PluginSdkBridge {
cancel: (id: string) => { state.schedules.get(String(id))?.handle.cancel(); state.schedules.delete(String(id)); },
cancelAll: () => { for (const slot of state.schedules.values()) slot.handle.cancel(); state.schedules.clear(); },
},
storage: {
get: (key: string) => { requirePermission("storage"); return this.#storage.get(pluginId, validateStorageKey(key)); },
set: (key: string, value: unknown) => {
requirePermission("storage");
const storageKey = validateStorageKey(key);
this.#storage.set(pluginId, storageKey, value);
for (const sub of state.storageSubscriptions.values()) if (sub.key === storageKey) { try { sub.handler(value); } catch (error) { this.#onError(pluginId, safeError(error)); } }
},
delete: (key: string) => {
requirePermission("storage");
const storageKey = validateStorageKey(key);
this.#storage.delete(pluginId, storageKey);
for (const sub of state.storageSubscriptions.values()) if (sub.key === storageKey) { try { sub.handler(undefined); } catch (error) { this.#onError(pluginId, safeError(error)); } }
},
keys: () => { requirePermission("storage"); return this.#storage.keys?.(pluginId) ?? []; },
subscribe: (key: string, handler: (value: unknown) => void) => {
requirePermission("storage");
check(state.storageSubscriptions.size < quotas.storageSubscriptions, "Plugin storage subscription quota exceeded.");
const subId = opaqueId("storage");
state.storageSubscriptions.set(subId, { key: validateStorageKey(key), handler: guardCallback(handler) });
return { subscriptionId: subId };
},
unsubscribe: (subscriptionId: unknown) => { state.storageSubscriptions.delete(String(subscriptionId)); },
},
config: { get: getConfig, onChange: (listener: (config: PluginConfig) => void) => { state.configListeners.add(listener); return () => state.configListeners.delete(listener); } },
storage,
config,
net: {
fetch: async (url: string, options?: unknown) => {
requirePermission("network");
state.httpWindow.tick(quotas.httpPerMinute, "HTTP");
const opts = validateNetOptions(options, approved);
return safeHttpFetch(String(url), opts, allowedNetworkHosts(record, manifest));
return safeHttpFetch(String(url), opts, allowedNetworkHosts(record, manifest), { logger: this.#logger, pluginId, route: "net.fetch" });
},
stream: async (url: string, options: unknown, onChunk: (chunk: string) => void) => {
requirePermission("network");
state.httpWindow.tick(quotas.httpPerMinute, "HTTP");
const opts = validateNetOptions(options, approved);
return safeHttpStream(String(url), opts, allowedNetworkHosts(record, manifest), guardCallback(onChunk));
return safeHttpStream(String(url), opts, allowedNetworkHosts(record, manifest), guardCallback(onChunk), { logger: this.#logger, pluginId, route: "net.stream" });
},
},
notify: {
@ -840,8 +630,10 @@ export class PluginSdkBridge {
unregister: (id: string) => { state.commands.delete(String(id)); },
},
status: { set: (status: PluginStatus | string) => { requirePermission("status"); state.status = validateStatus(status); }, clear: () => { state.status = undefined; } },
http: { fetch: async (url: string, options?: unknown) => { requirePermission("network"); state.httpWindow.tick(quotas.httpPerMinute, "HTTP"); const opts = isRecord(options) ? options : {}; check(opts.method === undefined || String(opts.method).toUpperCase() === "GET", "Plugin HTTP fetch only supports GET."); return safeHttpFetch(String(url), { method: "GET", headers: isRecord(opts.headers) ? opts.headers as Record<string, string> : undefined, timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs) }, allowedNetworkHosts(record, manifest)); } },
http: { fetch: async (url: string, options?: unknown) => { requirePermission("network"); state.httpWindow.tick(quotas.httpPerMinute, "HTTP"); const opts = isRecord(options) ? options : {}; check(opts.method === undefined || String(opts.method).toUpperCase() === "GET", "Plugin HTTP fetch only supports GET."); return safeHttpFetch(String(url), { method: "GET", headers: isRecord(opts.headers) ? opts.headers as Record<string, string> : undefined, timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs) }, allowedNetworkHosts(record, manifest), { logger: this.#logger, pluginId, route: "http.fetch" }); } },
log: Object.fromEntries((["debug", "info", "warn", "error"] as PluginLogLevel[]).map((level) => [level, (...args: unknown[]) => { state.logWindow.tick(quotas.logsPerMinute, "log"); this.#logger(level, "plugin log", { id: manifest.id, args }); }])) as Record<PluginLogLevel, (...args: unknown[]) => void>,
t: makePluginT(manifest.id),
get locale(): string { return getActiveLocaleLang(); },
};
}
@ -872,14 +664,14 @@ export class PluginSdkBridge {
const values = command.meta.form ? validateCommandFormValues(command.meta.form, args) : undefined;
state.userCommandDepth += 1;
const release = () => { setTimeout(() => { state.userCommandDepth = Math.max(0, state.userCommandDepth - 1); }, 2_000).unref?.(); };
try { await withTimeout(Promise.resolve().then(() => command.handler(values)), timeoutMs); } finally { release(); }
try { await withTimeout(Promise.resolve().then(() => command.handler(values)), timeoutMs); } catch (error) { this.#logger("warn", "plugin callback failed", { pluginId: id, commandId, reason: safeError(error), errorCode: classifyPluginError(error) }); throw error; } finally { release(); }
}
async executeMenuSelect(id: string, itemId: string): Promise<void> {
const state = this.#pluginState(id);
if (!state.menuItems.some((item) => item.id === itemId)) throw new Error("Plugin menu item is not registered.");
state.userCommandDepth += 1;
try { for (const handler of state.menuHandlers) await Promise.resolve(handler(itemId)); } finally { setTimeout(() => { state.userCommandDepth = Math.max(0, state.userCommandDepth - 1); }, 2_000).unref?.(); }
try { for (const handler of state.menuHandlers) await Promise.resolve(handler(itemId)); } catch (error) { this.#logger("warn", "plugin callback failed", { pluginId: id, menuItemId: itemId, reason: safeError(error), errorCode: classifyPluginError(error) }); throw error; } finally { setTimeout(() => { state.userCommandDepth = Math.max(0, state.userCommandDepth - 1); }, 2_000).unref?.(); }
}
notifyConfigChanged(id: string): void {
@ -959,18 +751,6 @@ export class PluginSdkBridge {
}
}
function requirePanel(state: PluginRuntimeState, panelId: unknown): PluginPanelHostHandle & { onMessage?: (msg: unknown) => void } {
const panel = state.panels.get(String(panelId));
if (!panel) throw new Error("Plugin panel is no longer open.");
return panel;
}
function registerDisposer(state: PluginRuntimeState, dispose: () => void): string {
const subId = opaqueId("sub");
state.eventSubscriptions.set(subId, dispose);
return subId;
}
function countActiveBubbles(state: PluginRuntimeState): number { return state.bubbles.size; }
// ---------------------------------------------------------------------------
@ -1024,9 +804,18 @@ async function prepareSafeRequest(urlText: string, opts: ValidatedNetOptions, al
return { url, init, controller, timeout };
}
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>): Promise<SimpleHttpResponse> {
type NetworkDiagnostics = { logger?: PluginRuntimeLogger; pluginId?: string; route?: string };
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>, diagnostics?: NetworkDiagnostics): Promise<SimpleHttpResponse> {
const opts: ValidatedNetOptions = isValidatedNetOptions(options) ? options : { method: "GET", headers: undefined, timeoutMs: undefined };
const { url, init, timeout } = await prepareSafeRequest(urlText, opts, allowedHosts);
const started = Date.now();
let host = "";
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "begin" });
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try { prepared = await prepareSafeRequest(urlText, opts, allowedHosts); }
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
@ -1035,15 +824,24 @@ export async function safeHttpFetch(urlText: string, options: ValidatedNetOption
for (const key of ["content-type", "etag", "last-modified", "retry-after", "x-ratelimit-remaining"]) { const value = response.headers.get(key); if (value) headers[key] = value; }
let json: unknown;
if ((headers["content-type"] ?? "").includes("application/json")) { try { json = JSON.parse(text); } catch { json = undefined; } }
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "success", status: response.status, sizeBytes: Buffer.byteLength(text), durationMs: Date.now() - started });
return { status: response.status, ok: response.ok, headers, text, ...(json === undefined ? {} : { json }) };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("Plugin HTTP fetch timed out.");
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP fetch timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
if (mapped instanceof Error) throw mapped;
throw error;
} finally { clearTimeout(timeout); }
}
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void): Promise<{ status: number; ok: boolean }> {
const { url, init, timeout } = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts);
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void, diagnostics?: NetworkDiagnostics): Promise<{ status: number; ok: boolean }> {
const started = Date.now();
let host = "";
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try { prepared = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts); }
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
@ -1062,9 +860,12 @@ export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions,
const tail = decoder.decode();
if (tail.length > 0) onChunk(tail);
}
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "success", status: response.status, durationMs: Date.now() - started });
return { status: response.status, ok: response.ok };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("Plugin HTTP stream timed out.");
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP stream timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
if (mapped instanceof Error) throw mapped;
throw error;
} finally { clearTimeout(timeout); }
}
@ -1079,7 +880,6 @@ export function isPrivateIp(address: string): boolean { if (net.isIPv4(address))
// Validators
// ---------------------------------------------------------------------------
class WindowCounter { count = 0; started = Date.now(); tick(max: number, label: string): void { const now = Date.now(); if (now - this.started >= 60_000) this.reset(); this.count += 1; check(this.count <= max, `Plugin ${label} quota exceeded.`); } reset(): void { this.count = 0; this.started = Date.now(); } }
function check(ok: boolean, message: string): void { if (!ok) throw new Error(message); }
function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); }
function validateStorageKey(key: string): string { if (!/^[A-Za-z0-9._:-]{1,128}$/.test(String(key))) throw new Error("Invalid plugin storage key."); return String(key); }

View file

@ -0,0 +1,50 @@
import type { PluginPermission } from "./plugin-manifest.js";
import type { PluginRuntimeState } from "./plugin-sdk-state.js";
export type PluginBusTopicEntry = { pluginId: string; handler: (payload: unknown) => void };
export function createPluginBusApi(options: {
readonly pluginId: string;
readonly state: PluginRuntimeState;
readonly topics: Map<string, Set<PluginBusTopicEntry>>;
readonly requirePermission: (permission: PluginPermission) => void;
readonly guardCallback: <A extends unknown[]>(fn: (...args: A) => unknown) => ((...args: A) => void);
readonly normalizeJson: (value: unknown, maxBytes: number, label: string) => unknown;
readonly busPerMinute: number;
readonly busPayloadBytes: number;
readonly busSubscriptionsQuota: number;
}) {
const { pluginId, state, topics, requirePermission, guardCallback, normalizeJson, busPerMinute, busPayloadBytes, busSubscriptionsQuota } = options;
return {
publish: async (topic: unknown, payload: unknown) => {
requirePermission("bus");
state.busWindow.tick(busPerMinute, "bus");
const topicName = String(topic);
check(busTopicPattern.test(topicName), "Invalid bus topic.");
const normalized = normalizeJson(payload, busPayloadBytes, "bus payload");
for (const subscriber of topics.get(topicName) ?? []) {
if (subscriber.pluginId === pluginId) continue;
try { subscriber.handler(normalized); } catch { /* subscriber errors are isolated */ }
}
},
subscribe: (topic: unknown, handler: (payload: unknown) => void) => {
requirePermission("bus");
const topicName = String(topic);
check(busTopicPattern.test(topicName), "Invalid bus topic.");
check(state.busSubscriptions.size < busSubscriptionsQuota, "Plugin bus subscription quota exceeded.");
const entry = { pluginId, handler: guardCallback(handler) };
let subscribers = topics.get(topicName);
if (!subscribers) { subscribers = new Set(); topics.set(topicName, subscribers); }
subscribers.add(entry);
const subId = opaqueId("bus");
state.busSubscriptions.set(subId, { topic: topicName, handler: entry.handler });
state.eventSubscriptions.set(subId, () => { subscribers.delete(entry); state.busSubscriptions.delete(subId); });
return { subscriptionId: subId };
},
unsubscribe: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
};
}
const busTopicPattern = /^[A-Za-z0-9._:/-]{1,128}$/;
function opaqueId(prefix: string): string { return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; }
function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }

View file

@ -0,0 +1,27 @@
import type { PluginConfig } from "./plugin-config.js";
import type { PluginRuntimeState } from "./plugin-sdk-state.js";
export function createPluginConfigApi(options: {
readonly state: PluginRuntimeState;
readonly getConfig: () => PluginConfig;
}) {
const { state, getConfig } = options;
return {
get: getConfig,
onChange: (listener: (config: PluginConfig) => void) => {
state.configListeners.add(listener);
return () => state.configListeners.delete(listener);
},
};
}
export function registerConfigChangedEvent(options: {
readonly state: PluginRuntimeState;
readonly subscriptionId: string;
readonly handler: (payload: Record<string, unknown>) => void;
readonly guardCallback: <A extends unknown[]>(fn: (...args: A) => unknown) => ((...args: A) => void);
}): void {
const listener = (config: PluginConfig) => options.guardCallback(options.handler)(config as Record<string, unknown>);
options.state.configListeners.add(listener);
options.state.eventSubscriptions.set(options.subscriptionId, () => options.state.configListeners.delete(listener));
}

View file

@ -0,0 +1,41 @@
import type { PluginPermission } from "./plugin-manifest.js";
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
import type { PluginRuntimeState } from "./plugin-sdk-state.js";
import { registerConfigChangedEvent } from "./plugin-sdk-config.js";
export function createPluginEventsApi(options: {
readonly state: PluginRuntimeState;
readonly capabilities: PluginHostCapabilities;
readonly requirePermission: (permission: PluginPermission) => void;
readonly guardCallback: <A extends unknown[]>(fn: (...args: A) => unknown) => ((...args: A) => void);
readonly allowedEventNames: ReadonlySet<string>;
readonly eventSubscriptionsQuota: number;
}) {
const { state, capabilities, requirePermission, guardCallback, allowedEventNames, eventSubscriptionsQuota } = options;
return {
on: (event: unknown, handler: (payload: Record<string, unknown>) => void) => {
requirePermission("events");
const name = String(event);
if (!allowedEventNames.has(name)) throw new Error(`Unknown plugin event: ${name}`);
if (name === "pet:drop") requirePermission("pet:drop");
if (state.eventSubscriptions.size >= eventSubscriptionsQuota) throw new Error("Plugin event subscription quota exceeded.");
const subId = opaqueId("event");
if (name === "config:changed") {
registerConfigChangedEvent({ state, subscriptionId: subId, handler, guardCallback });
} else if (name === "pet:drop") {
const wrapped = guardCallback((payload: Record<string, unknown>) => {
if (Array.isArray(payload.files)) for (const file of payload.files) { if (isRecord(file) && typeof file.fileId === "string") state.pickedFiles.add(file.fileId); }
handler(payload);
});
state.eventSubscriptions.set(subId, capabilities.events.subscribe(name, wrapped));
} else {
state.eventSubscriptions.set(subId, capabilities.events.subscribe(name, guardCallback(handler)));
}
return { subscriptionId: subId };
},
off: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
};
}
function opaqueId(prefix: string): string { return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; }
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

View file

@ -0,0 +1,30 @@
export const pluginSdkQuotas = {
petActionsPerMinute: 60,
schedules: 64,
commands: 32,
menuItems: 16,
storageBytes: 5 * 1024 * 1024,
storageSubscriptions: 64,
logsPerMinute: 200,
httpPerMinute: 30,
httpResponseBytes: 4 * 1024 * 1024,
httpRequestBodyBytes: 256 * 1024,
streamResponseBytes: 10 * 1024 * 1024,
busPerMinute: 120,
busPayloadBytes: 32 * 1024,
busSubscriptions: 64,
eventSubscriptions: 64,
audioPerMinute: 20,
notifyPerMinute: 10,
toastPerMinute: 20,
aiPerMinute: 20,
voicePerMinute: 10,
activeBubbles: 8,
activePanels: 3,
spawnedPets: 4,
secretBytes: 8 * 1024,
dynamicTextChars: 2000,
markdownChars: 1000,
} as const;
export type PluginSdkQuotas = typeof pluginSdkQuotas;

View file

@ -0,0 +1,30 @@
export const pluginSdkAsyncRoutes = [
"pet.speak", "pet.react", "pet.setAnimation", "pet.setScale", "pet.setStatusReaction", "pet.moveBy", "pet.wander", "pet.moveToHome", "pet.moveTo", "pet.followCursor", "pet.physics", "pet.onTick", "pet.offTick", "pet.getState", "pet.show", "pet.hide", "pet.close",
"pets.list", "pets.spawn", "pets.onChange", "pets.offChange",
"ui.bubble", "ui.alert", "ui.bubbleUpdate", "ui.bubbleDismiss", "ui.bubblePin", "ui.bubbleUnpin", "ui.bubbleSubscribe", "ui.toast", "ui.panel", "ui.panelShow", "ui.panelHide", "ui.panelPost", "ui.panelClose", "ui.panelOnMessage", "ui.menuSetItems", "ui.menuOnSelect", "ui.menuOffSelect",
"audio.play", "audio.importUserSound", "audio.forgetUserSound", "audio.stop",
"events.on", "events.off",
"assets.resolve",
"bus.publish", "bus.subscribe", "bus.unsubscribe",
"schedule.once", "schedule.every", "schedule.daily", "schedule.cron", "schedule.at", "schedule.list", "schedule.cancel", "schedule.cancelAll",
"storage.get", "storage.set", "storage.delete", "storage.keys", "storage.subscribe", "storage.unsubscribe",
"config.get", "config.onChange", "config.offChange",
"net.fetch", "net.stream",
"notify.notify",
"ai.available", "ai.complete", "ai.stream",
"secrets.get", "secrets.set", "secrets.delete", "secrets.has",
"voice.speak", "voice.listen",
"auth.oauth", "auth.refresh", "auth.signOut",
"files.pick", "files.read", "files.save",
"system.info", "system.metrics", "system.openExternal", "system.readClipboardText", "system.writeClipboardText",
"commands.register", "commands.unregister", "status.set", "status.clear", "http.fetch",
"log.debug", "log.info", "log.warn", "log.error",
] as const;
export const pluginSdkSyncRoutes = ["i18n.t", "i18n.locale"] as const;
export const pluginSdkRoutes = [...pluginSdkAsyncRoutes, ...pluginSdkSyncRoutes] as const;
export type PluginSdkRoute = typeof pluginSdkRoutes[number];
export function isPluginSdkRoute(path: string): path is PluginSdkRoute {
return (pluginSdkRoutes as readonly string[]).includes(path);
}

View file

@ -0,0 +1,60 @@
import type { PluginConfig } from "./plugin-config.js";
import type { PluginCommand, PluginBubbleDismissReason, PluginBubbleHostHandle, PluginMenuItem, PluginPanelHostHandle, PluginStatus } from "./plugin-sdk-bridge.js";
import type { PluginTimerHandle } from "./plugin-runtime.js";
export type ScheduleSpec =
| { type: "once"; delayMs: number }
| { type: "every"; intervalMs: number }
| { type: "daily"; daily: { time: string; days?: number[] } }
| { type: "cron"; expr: string }
| { type: "at"; timestamp: number };
export type ScheduleSlot = { spec: ScheduleSpec; callback: () => unknown; handle: PluginTimerHandle; nextRunMs: number };
export type BubbleSlot = { host: PluginBubbleHostHandle; onAction?: (actionId: string) => void; onSubmit?: (values: Record<string, string | number>) => void; onDismiss?: (reason: PluginBubbleDismissReason) => void; dismissed: boolean };
export class WindowCounter {
count = 0;
started = Date.now();
tick(max: number, label: string): void { const now = Date.now(); if (now - this.started >= 60_000) this.reset(); this.count += 1; if (this.count > max) throw new Error(`Plugin ${label} quota exceeded.`); }
reset(): void { this.count = 0; this.started = Date.now(); }
}
export type PluginRuntimeState = {
commands: Map<string, { meta: PluginCommand; handler: (values?: Record<string, unknown>) => unknown | Promise<unknown> }>;
menuItems: PluginMenuItem[];
menuHandlers: Set<(id: string) => void>;
status?: PluginStatus;
schedules: Map<string, ScheduleSlot>;
configListeners: Set<(config: PluginConfig) => void>;
storageSubscriptions: Map<string, { key: string; handler: (value: unknown) => void }>;
busSubscriptions: Map<string, { topic: string; handler: (payload: unknown) => void }>;
eventSubscriptions: Map<string, () => void>;
tickSubscriptions: Map<string, () => void>;
bubbles: Map<string, BubbleSlot>;
panels: Map<string, PluginPanelHostHandle & { onMessage?: (msg: unknown) => void }>;
spawnedPets: Set<string>;
pickedFiles: Set<string>;
userCommandDepth: number;
lastError?: string;
petWindow: WindowCounter;
logWindow: WindowCounter;
httpWindow: WindowCounter;
busWindow: WindowCounter;
audioWindow: WindowCounter;
notifyWindow: WindowCounter;
toastWindow: WindowCounter;
aiWindow: WindowCounter;
voiceWindow: WindowCounter;
};
export type PluginInspectorState = {
schedules: Array<{ id: string; type: ScheduleSpec["type"]; nextRunMs: number }>;
commands: readonly PluginCommand[];
menuItems: readonly PluginMenuItem[];
status?: PluginStatus;
activeBubbles: number;
activePanels: number;
eventSubscriptions: number;
lastError?: string;
quotaCounters: Record<string, number>;
};

View file

@ -0,0 +1,46 @@
import type { PluginPermission } from "./plugin-manifest.js";
import type { PluginStorageStore } from "./plugin-sdk-bridge.js";
import type { PluginRuntimeState } from "./plugin-sdk-state.js";
export function createPluginStorageApi(options: {
readonly pluginId: string;
readonly state: PluginRuntimeState;
readonly storage: PluginStorageStore;
readonly requirePermission: (permission: PluginPermission) => void;
readonly guardCallback: <A extends unknown[]>(fn: (...args: A) => unknown) => ((...args: A) => void);
readonly validateStorageKey: (key: string) => string;
readonly onError: (reason: string) => void;
readonly safeError: (error: unknown) => string;
readonly storageSubscriptionsQuota: number;
}) {
const { pluginId, state, storage, requirePermission, guardCallback, validateStorageKey, onError, safeError, storageSubscriptionsQuota } = options;
const notify = (storageKey: string, value: unknown) => {
for (const sub of state.storageSubscriptions.values()) if (sub.key === storageKey) { try { sub.handler(value); } catch (error) { onError(safeError(error)); } }
};
return {
get: (key: string) => { requirePermission("storage"); return storage.get(pluginId, validateStorageKey(key)); },
set: (key: string, value: unknown) => {
requirePermission("storage");
const storageKey = validateStorageKey(key);
storage.set(pluginId, storageKey, value);
notify(storageKey, value);
},
delete: (key: string) => {
requirePermission("storage");
const storageKey = validateStorageKey(key);
storage.delete(pluginId, storageKey);
notify(storageKey, undefined);
},
keys: () => { requirePermission("storage"); return storage.keys?.(pluginId) ?? []; },
subscribe: (key: string, handler: (value: unknown) => void) => {
requirePermission("storage");
if (state.storageSubscriptions.size >= storageSubscriptionsQuota) throw new Error("Plugin storage subscription quota exceeded.");
const subId = opaqueId("storage");
state.storageSubscriptions.set(subId, { key: validateStorageKey(key), handler: guardCallback(handler) });
return { subscriptionId: subId };
},
unsubscribe: (subscriptionId: unknown) => { state.storageSubscriptions.delete(String(subscriptionId)); },
};
}
function opaqueId(prefix: string): string { return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; }

View file

@ -0,0 +1 @@
export const pluginNamedHostSounds = new Set(["chime", "pop", "nom", "alert", "level-up", "tick", "success", "error"]);

View file

@ -0,0 +1,143 @@
import type { OpenPetsJavascriptPluginManifest, PluginPermission } from "./plugin-manifest.js";
import type { PluginAudioApi } from "./plugin-sdk-audio.js";
import type { BubbleSlot, PluginRuntimeState } from "./plugin-sdk-state.js";
import type { PluginBubbleDescriptor, PluginBubbleDismissReason, PluginBubbleHostHandle, PluginHostCapabilities, PluginLogLevel, PluginMenuItem, PluginStatus } from "./plugin-sdk-bridge.js";
export function createPluginUiApi(options: {
readonly pluginId: string;
readonly manifest: OpenPetsJavascriptPluginManifest;
readonly installPath: string;
readonly state: PluginRuntimeState;
readonly capabilities: PluginHostCapabilities;
readonly audio: PluginAudioApi;
readonly requirePermission: (permission: PluginPermission) => void;
readonly guardCallback: <A extends unknown[]>(fn: (...args: A) => unknown) => ((...args: A) => void);
readonly validateBubbleSpec: (spec: unknown, forUpdate?: boolean) => PluginBubbleDescriptor;
readonly validatePetHandleId: (value: unknown) => string;
readonly resolvePanelPath: (name: string) => string;
readonly normalizeJson: (value: unknown, maxBytes: number, label: string) => unknown;
readonly validateMenuItems: (value: unknown) => PluginMenuItem[];
readonly validateSayMessage: (message: string) => string;
readonly safeError: (error: unknown) => string;
readonly logger: (level: PluginLogLevel, message: string, fields?: Record<string, unknown>) => void;
readonly onError: (reason: string) => void;
readonly quotas: { petActionsPerMinute: number; activeBubbles: number; notifyPerMinute: number; toastPerMinute: number; activePanels: number; busPayloadBytes: number };
}) {
const { pluginId, manifest, state, capabilities, audio, requirePermission, guardCallback, validateBubbleSpec, validatePetHandleId, resolvePanelPath, normalizeJson, validateMenuItems, validateSayMessage, safeError, logger, onError, quotas } = options;
const showBubble = async (petHandleId: string, spec: unknown): Promise<{ bubbleId: string }> => {
requirePermission("pet:speak");
state.petWindow.tick(quotas.petActionsPerMinute, "pet action");
const bubble = validateBubbleSpec(spec);
check(state.bubbles.size < quotas.activeBubbles, "Plugin active bubble quota exceeded.");
const bubbleId = opaqueId("bubble");
const slot: BubbleSlot = { host: undefined as unknown as PluginBubbleHostHandle, dismissed: false };
const callbacks = {
onAction: (actionId: string) => { try { slot.onAction?.(actionId); } catch (error) { onError(safeError(error)); } },
onSubmit: (values: Record<string, string | number>) => { try { slot.onSubmit?.(values); } catch (error) { onError(safeError(error)); } },
onDismiss: (reason: PluginBubbleDismissReason) => { slot.dismissed = true; state.bubbles.delete(bubbleId); try { slot.onDismiss?.(reason); } catch (error) { onError(safeError(error)); } },
};
slot.host = await capabilities.bubbles.show({ petId: validatePetHandleId(petHandleId), pluginId, bubble, callbacks });
if (!slot.dismissed) state.bubbles.set(bubbleId, slot);
return { bubbleId };
};
const requireBubble = (bubbleId: unknown): BubbleSlot => {
const slot = state.bubbles.get(String(bubbleId));
if (!slot) throw new Error("Plugin bubble is no longer live.");
return slot;
};
const sendNotify = async (spec: unknown) => {
requirePermission("notify");
state.notifyWindow.tick(quotas.notifyPerMinute, "notification");
if (!isRecord(spec)) throw new Error("Invalid notification spec.");
const title = validateSayMessage(String(spec.title ?? ""));
const body = spec.body === undefined ? undefined : validateSayMessage(String(spec.body));
await capabilities.notify({ title, body, sound: spec.sound === true && capabilities.settings.audioAllowed() && !capabilities.settings.inQuietHours() });
};
return {
api: {
bubble: (spec: unknown) => showBubble("default", spec),
alert: async (spec: unknown) => {
const alertOptions = isRecord(spec) ? spec : { text: spec };
const handle = await showBubble("default", { ...alertOptions, sticky: true, priority: "high" });
if (alertOptions.sound !== undefined) {
void Promise.resolve().then(async () => {
try { await audio.play(alertOptions.sound, { volume: alertOptions.volume }); }
catch (error) { logger("warn", "plugin alert sound skipped", { id: manifest.id, reason: safeError(error) }); }
});
}
if (alertOptions.notify !== undefined) {
void Promise.resolve().then(async () => {
try { await sendNotify(alertOptions.notify === true ? { title: alertOptions.title ?? alertOptions.text ?? manifest.name, body: alertOptions.body } : alertOptions.notify); }
catch (error) { logger("warn", "plugin alert notify skipped", { id: manifest.id, reason: safeError(error) }); }
});
}
return handle;
},
bubbleUpdate: async (bubbleId: unknown, patch: unknown) => { const slot = requireBubble(bubbleId); await slot.host.update(validateBubbleSpec(patch, true)); },
bubbleDismiss: async (bubbleId: unknown) => { const slot = state.bubbles.get(String(bubbleId)); if (slot) await slot.host.dismiss().catch(() => undefined); },
bubblePin: async (bubbleId: unknown) => { requirePermission("pet:pin"); await requireBubble(bubbleId).host.pin(); },
bubbleUnpin: async (bubbleId: unknown) => { const slot = state.bubbles.get(String(bubbleId)); if (slot) await slot.host.unpin().catch(() => undefined); },
bubbleSubscribe: (bubbleId: unknown, kind: unknown, handler: (...args: never[]) => void) => {
const slot = state.bubbles.get(String(bubbleId));
if (!slot) { logger("debug", "plugin bubble subscribe skipped", { id: manifest.id, bubbleId: String(bubbleId), reason: "not-live" }); return { ok: false }; }
if (kind === "action") slot.onAction = handler as (actionId: string) => void;
else if (kind === "submit") slot.onSubmit = handler as (values: Record<string, string | number>) => void;
else if (kind === "dismiss") slot.onDismiss = handler as (reason: PluginBubbleDismissReason) => void;
else throw new Error("Invalid bubble subscription kind.");
return { ok: true };
},
toast: async (spec: unknown) => {
requirePermission("ui:toast");
state.toastWindow.tick(quotas.toastPerMinute, "toast");
if (!isRecord(spec)) throw new Error("Invalid toast spec.");
const text = validateSayMessage(String(spec.text ?? ""));
const tone = spec.tone === undefined ? undefined : (check(["info", "success", "warning", "error"].includes(String(spec.tone)), "Invalid toast tone."), spec.tone as PluginStatus["tone"]);
const durationMs = spec.durationMs === undefined ? undefined : clampNumber(Number(spec.durationMs), 1_000, 15_000);
await capabilities.toast({ text, tone, durationMs });
},
panel: async (spec: unknown) => {
requirePermission("ui:panel");
check(state.panels.size < quotas.activePanels, "Plugin panel quota exceeded.");
if (!isRecord(spec) || typeof spec.panel !== "string") throw new Error("Invalid panel spec.");
const width = spec.width === undefined ? 420 : clampNumber(Number(spec.width), 200, 1200);
const height = spec.height === undefined ? 480 : clampNumber(Number(spec.height), 160, 900);
const title = spec.title === undefined ? manifest.name : String(spec.title).slice(0, 80);
const panelId = opaqueId("panel");
const holder: { onMessage?: (msg: unknown) => void } = {};
const host = await capabilities.panels.open({ pluginId, installPath: options.installPath, panelPath: resolvePanelPath(String(spec.panel)), title, width, height, onMessage: (msg) => { try { holder.onMessage?.(msg); } catch (error) { onError(safeError(error)); } }, onClosed: () => { state.panels.delete(panelId); } });
state.panels.set(panelId, Object.assign(host, holder));
return { panelId };
},
panelShow: async (panelId: unknown) => { await requirePanel(state, panelId).show(); },
panelHide: async (panelId: unknown) => { await requirePanel(state, panelId).hide(); },
panelPost: async (panelId: unknown, msg: unknown) => { await requirePanel(state, panelId).postMessage(normalizeJson(msg, quotas.busPayloadBytes, "panel message")); },
panelClose: async (panelId: unknown) => { const panel = state.panels.get(String(panelId)); if (panel) { await panel.close(); state.panels.delete(String(panelId)); } },
panelOnMessage: (panelId: unknown, handler: (msg: unknown) => void) => { requirePanel(state, panelId).onMessage = guardCallback(handler); },
menuSetItems: async (items: unknown) => { requirePermission("commands"); state.menuItems = validateMenuItems(items); },
menuOnSelect: (handler: (id: string) => void) => { requirePermission("commands"); const wrapped = guardCallback(handler); state.menuHandlers.add(wrapped); return { subscriptionId: registerDisposer(state, () => state.menuHandlers.delete(wrapped)) }; },
menuOffSelect: (subscriptionId: unknown) => { state.eventSubscriptions.get(String(subscriptionId))?.(); state.eventSubscriptions.delete(String(subscriptionId)); },
},
showBubble,
};
}
function requirePanel(state: PluginRuntimeState, panelId: unknown) {
const panel = state.panels.get(String(panelId));
if (!panel) throw new Error("Plugin panel is no longer open.");
return panel;
}
function registerDisposer(state: PluginRuntimeState, dispose: () => void): string {
const subId = opaqueId("sub");
state.eventSubscriptions.set(subId, dispose);
return subId;
}
function opaqueId(prefix: string): string { return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; }
function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); }
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

View file

@ -1,13 +1,15 @@
import { existsSync, mkdirSync, promises as fs } from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, extname, join } from "node:path";
import { getCatalogPlugin, getPluginCatalog, type PluginCatalogOptions } from "./plugin-catalog.js";
import type { PluginCatalogEntryV2 } from "./plugin-catalog-validation.js";
import { getEffectivePluginConfig, validatePluginConfigReplacement, type PluginConfigValidationError, type PluginConfig } from "./plugin-config.js";
import { publishLocalPluginSnapshot, readLocalPluginSourceManifest } from "./plugin-local-loader.js";
import { readSafePluginManifest } from "./plugin-manifest-reader.js";
import type { OpenDialogOptions } from "electron";
import type { PluginJsHost } from "./plugin-js-host.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginIcon, type PluginPermission } from "./plugin-manifest.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginConfigField, type PluginIcon, type PluginPermission } from "./plugin-manifest.js";
import { ensureLoaded as ensurePluginLocales, resolvePluginText } from "./plugin-i18n.js";
import { downloadCatalogPluginZip, installCatalogPluginPackage, readCatalogPluginManifestFromZip, resolveSafePluginInstallDir } from "./plugin-package.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
import { JsonPluginStorageStore, type PluginCommand, type PluginHostCapabilities, type PluginLogLevel, type PluginStatus } from "./plugin-sdk-bridge.js";
@ -41,8 +43,9 @@ export type PluginServiceSnapshot = { readonly plugins: readonly SafePluginRecor
export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative" | "javascript"; readonly icon?: PluginIcon; readonly sdkVersion?: string; readonly permissions: readonly PluginPermission[]; readonly installed: boolean; readonly bundled?: boolean; readonly deprecated?: boolean; readonly statusReason?: string };
export type PluginCatalogSnapshot = { readonly plugins: readonly SafeCatalogPluginRecord[] };
export type PluginServiceResult = { readonly ok: true; readonly snapshot: PluginServiceSnapshot } | { readonly ok: false; readonly error: string; readonly snapshot: PluginServiceSnapshot };
export type PluginConfigSoundPickResult = { readonly ok: true; readonly sound: { readonly kind: "user-sound"; readonly id: string; readonly name?: string }; readonly snapshot: PluginServiceSnapshot } | { readonly ok: false; readonly error: string; readonly snapshot: PluginServiceSnapshot };
export type DevPluginLoadResult = { readonly path: string; readonly id?: string; readonly ok: true } | { readonly path: string; readonly ok: false; readonly error: string };
export type PluginFolderDialog = () => Promise<{ readonly canceled: boolean; readonly filePaths: readonly string[] }>;
export type PluginFolderDialog = (options?: unknown) => Promise<{ readonly canceled: boolean; readonly filePaths: readonly string[] }>;
export type PluginPermissionDialog = (manifest: OpenPetsPluginManifest) => Promise<boolean>;
export type PluginServiceOptions = {
@ -55,6 +58,7 @@ export type PluginServiceOptions = {
readonly allowedPluginRoots?: readonly string[];
readonly maxManifestBytes?: number;
readonly showOpenDialog?: PluginFolderDialog;
readonly showSoundOpenDialog?: PluginFolderDialog;
readonly confirmPermissions?: PluginPermissionDialog;
readonly catalogOptions?: PluginCatalogOptions;
readonly fetchImpl?: typeof fetch;
@ -66,9 +70,9 @@ export type PluginServiceOptions = {
readonly capabilities?: PluginHostCapabilities;
};
export const bundledOfficialPluginIds = ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders", "openpets.github-notifications"] as const;
const bundledEnabledByDefault = new Set<string>(["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders"]);
const staleBundledPluginIds = ["openpets.daily-reminders", "openpets.pomodoro"] as const;
export const bundledOfficialPluginIds = ["openpets.reminders"] as const;
const bundledEnabledByDefault = new Set<string>(["openpets.reminders"]);
const staleBundledPluginIds = ["openpets.daily-reminders", "openpets.pomodoro", "openpets.ambient-companion", "openpets.break-buddy", "openpets.focus-buddy", "openpets.github-notifications", "openpets.pet-pal", "openpets.quick-reminders", "openpets.wander-buddy"] as const;
export class PluginService {
readonly stateStore: PluginStateStore;
@ -77,6 +81,7 @@ export class PluginService {
readonly #maxManifestBytes?: number;
readonly #userDataPath?: string;
readonly #showOpenDialog?: PluginFolderDialog;
readonly #showSoundOpenDialog?: PluginFolderDialog;
readonly #confirmPermissions?: PluginPermissionDialog;
readonly #catalogOptions?: PluginCatalogOptions;
readonly #fetchImpl?: typeof fetch;
@ -84,6 +89,7 @@ export class PluginService {
readonly #disableCatalog: boolean;
readonly #seedBundledPlugins: boolean;
readonly #bundledPluginSourceDirs: readonly string[];
readonly #capabilities?: PluginHostCapabilities;
constructor(options: PluginServiceOptions) {
if (!options.stateStore && !options.userDataPath) throw new Error("Plugin service requires userDataPath or stateStore.");
@ -91,6 +97,7 @@ export class PluginService {
this.allowedPluginRoots = options.allowedPluginRoots ?? [join(options.userDataPath ?? "", "plugins"), join(options.userDataPath ?? "", "plugins-dev")];
this.#userDataPath = options.userDataPath;
this.#showOpenDialog = options.showOpenDialog;
this.#showSoundOpenDialog = options.showSoundOpenDialog;
this.#confirmPermissions = options.confirmPermissions;
this.#catalogOptions = options.catalogOptions;
this.#fetchImpl = options.fetchImpl;
@ -98,6 +105,7 @@ export class PluginService {
this.#disableCatalog = options.disableCatalog === true;
this.#seedBundledPlugins = options.seedBundledPlugins !== false;
this.#bundledPluginSourceDirs = options.bundledPluginSourceDirs ?? [];
this.#capabilities = options.capabilities;
this.stateStore = options.stateStore ?? new PluginStateStore({ userDataPath: options.userDataPath ?? "" });
if (options.runtime) {
this.runtime = options.runtime;
@ -111,11 +119,14 @@ export class PluginService {
async start(): Promise<void> {
this.#ensureRoots();
this.stateStore.initialize();
// Always purge retired ids — even in dev mode (where seeding is off) — so
// a previously-seeded plugin that was removed from the lineup disappears.
if (this.#seedBundledPlugins) await this.seedBundledPlugins();
else await this.#pruneStaleBundledPlugins();
await this.runtime.start();
}
async seedBundledPlugins(): Promise<void> {
async #pruneStaleBundledPlugins(): Promise<void> {
if (!this.#userDataPath) return;
for (const id of staleBundledPluginIds) {
const stale = this.stateStore.getRecord(id);
@ -123,12 +134,19 @@ export class PluginService {
try {
const safeInstall = await resolveSafePluginInstallDir(this.#userDataPath, id, stale.installPath, stale.source);
this.stateStore.removeRecord(id);
this.#capabilities?.clearPlugin?.(id);
await fs.rm(join(this.#userDataPath, "plugin-user-sounds", id), { recursive: true, force: true }).catch(() => undefined);
await fs.rm(safeInstall, { recursive: true, force: true });
} catch (error) {
this.#log("warn", "Refused to prune stale bundled plugin.", { pluginId: id, reason: safeError(error) });
}
}
}
}
async seedBundledPlugins(): Promise<void> {
if (!this.#userDataPath) return;
await this.#pruneStaleBundledPlugins();
for (const id of bundledOfficialPluginIds) {
const sourceFolder = this.#findBundledSourceFolder(id);
if (!sourceFolder) continue;
@ -181,11 +199,37 @@ export class PluginService {
return { ok: true, snapshot: await this.getSnapshot() };
}
async pickConfigSound(id: string): Promise<PluginConfigSoundPickResult> {
this.#log("debug", "Plugin config sound pick requested.", { pluginId: id });
const record = this.stateStore.getRecord(id);
if (!record) { this.#log("warn", "Plugin config sound pick unavailable.", { pluginId: id, reason: "not-installed" }); return this.#soundError("Plugin is not installed."); }
let manifest: OpenPetsPluginManifest;
try { manifest = await this.#readManifest(record); }
catch { this.#log("warn", "Plugin config sound pick unavailable.", { pluginId: id, reason: "manifest-unavailable" }); return this.#soundError("Plugin manifest is unavailable."); }
if (!Object.values(manifest.configSchema ?? {}).some((field) => field.type === "sound")) { this.#log("warn", "Plugin config sound pick unavailable.", { pluginId: id, reason: "no-sound-field" }); return this.#soundError("Plugin does not declare a sound config field."); }
const importer = this.#capabilities?.audio.importUserSoundFromPath;
if (!importer) { this.#log("warn", "Plugin config sound pick unavailable.", { pluginId: id, reason: "importer-unavailable" }); return this.#soundError("Plugin sound import is unavailable."); }
const picker = this.#showSoundOpenDialog ?? this.#showOpenDialog ?? defaultSoundOpenDialog;
this.#log("debug", "Plugin config sound picker opened.", { pluginId: id });
const selection = await picker({ properties: ["openFile"], filters: [{ name: "Audio", extensions: ["ogg", "mp3", "wav"] }] });
if (selection.canceled || selection.filePaths.length === 0) { this.#log("debug", "Plugin config sound pick canceled.", { pluginId: id, canceled: true }); return { ok: true, sound: { kind: "user-sound", id: "" }, snapshot: await this.getSnapshot() }; }
const selectedPath = selection.filePaths[0] ?? "";
const selectedFields: Record<string, unknown> = { pluginId: id, basename: basename(selectedPath), ext: extname(selectedPath).toLowerCase() };
try { selectedFields.sizeBytes = (await fs.stat(selectedPath)).size; } catch { selectedFields.reason = "stat-unavailable"; }
this.#log("debug", "Plugin config sound file selected.", selectedFields);
try {
const sound = await importer(record.id, selectedPath);
this.#log("info", "Plugin config sound import succeeded.", { pluginId: id, soundId: sound.id, name: sound.name });
return { ok: true, sound, snapshot: await this.getSnapshot() };
} catch (error) { const reason = safeSoundError(error); this.#log("warn", "Plugin config sound import failed.", { pluginId: id, reason }); return this.#soundError(reason); }
}
async executeCommand(id: string, commandId: string, args?: Record<string, unknown>): Promise<PluginServiceResult> {
const record = this.stateStore.getRecord(id);
if (!record) return this.#error("Plugin is not installed.");
await ensurePluginLocales(id, record.installPath).catch(() => undefined);
try { await this.runtime.executeCommand(id, commandId, args); }
catch (error) { return this.#error(safeError(error)); }
catch (error) { return this.#error(safeCommandError(error, id)); }
return { ok: true, snapshot: await this.getSnapshot() };
}
@ -220,7 +264,7 @@ export class PluginService {
catch (error) { return this.#error(safeError(error)); }
this.stateStore.removeRecord(id);
await this.runtime.reloadPlugin(id);
try { await fs.rm(realInstall, { recursive: true, force: true }); await fs.rm(join(this.#userDataPath, "plugin-storage", `${id}.json`), { force: true }); }
try { this.#capabilities?.clearPlugin?.(id); await fs.rm(join(this.#userDataPath, "plugin-user-sounds", id), { recursive: true, force: true }); await fs.rm(realInstall, { recursive: true, force: true }); await fs.rm(join(this.#userDataPath, "plugin-storage", `${id}.json`), { force: true }); }
catch (error) { return this.#error(safeError(error)); }
return { ok: true, snapshot: await this.getSnapshot() };
}
@ -252,7 +296,7 @@ export class PluginService {
}
let loaded: Awaited<ReturnType<typeof publishLocalPluginSnapshot>>;
try {
loaded = await publishLocalPluginSnapshot({ manifest: source.manifest, manifestText: source.manifestText, entryText: source.entryText, userDataPath: this.#userDataPath, maxManifestBytes: this.#maxManifestBytes });
loaded = await publishLocalPluginSnapshot({ manifest: source.manifest, manifestText: source.manifestText, entryText: source.entryText, declaredFiles: source.declaredFiles, userDataPath: this.#userDataPath, maxManifestBytes: this.#maxManifestBytes });
} catch (error) {
return this.#error(safeError(error));
}
@ -320,6 +364,8 @@ export class PluginService {
if (!isDevRecord) continue;
this.stateStore.removeRecord(record.id);
await this.runtime.reloadPlugin(record.id);
this.#capabilities?.clearPlugin?.(record.id);
await fs.rm(join(this.#userDataPath, "plugin-user-sounds", record.id), { recursive: true, force: true }).catch(() => undefined);
await fs.rm(record.installPath, { recursive: true, force: true }).catch(() => undefined);
await fs.rm(join(this.#userDataPath, "plugin-storage", `${record.id}.json`), { force: true }).catch(() => undefined);
}
@ -367,7 +413,8 @@ export class PluginService {
const manifest = await this.#readManifest(record);
const config = getEffectivePluginConfig(manifest, record.config);
const runtimeState = typeof (this.runtime as unknown as { getPluginState?: unknown }).getPluginState === "function" ? this.runtime.getPluginState(record.id) : { commands: [] };
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, description: manifest.description, icon: manifest.icon, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands, status: runtimeState.status };
await ensurePluginLocales(record.id, record.installPath).catch(() => undefined);
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: resolvePluginText(record.id, manifest.name) ?? manifest.name, description: resolvePluginText(record.id, manifest.description), icon: manifest.icon, configSchema: resolveConfigSchemaText(record.id, manifest.configSchema), effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands.map((command) => resolveCommandText(record.id, command)), status: runtimeState.status };
} catch (error) {
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason) ?? safeError(error) };
}
@ -389,6 +436,10 @@ export class PluginService {
return { ok: false, error, snapshot: await this.getSnapshot() };
}
async #soundError(error: string): Promise<PluginConfigSoundPickResult> {
return { ok: false, error, snapshot: await this.getSnapshot() };
}
#ensureRoots(): void {
for (const root of this.allowedPluginRoots) mkdirSync(root, { recursive: true });
}
@ -450,12 +501,36 @@ export function initializePluginService(userDataPath: string, petApi: PluginPetA
export type PluginCommandMenuItem = { readonly pluginId: string; readonly pluginName: string; readonly commandId: string; readonly commandTitle: string; readonly form?: PluginCommand["form"]; readonly placement?: "top" | "submenu"; readonly priority?: number; readonly featured?: boolean };
export type PluginDynamicMenuItem = { readonly pluginId: string; readonly pluginName: string; readonly itemId: string; readonly title: string; readonly enabled?: boolean; readonly checked?: boolean };
/** Resolve `$t:` references in a command form's field labels and submit label against the owning plugin's catalogs. */
function resolveCommandFormText(pluginId: string, form: PluginCommand["form"]): PluginCommand["form"] {
if (!form) return form;
return {
...form,
submitLabel: resolvePluginText(pluginId, form.submitLabel),
fields: form.fields.map((field) => ({
...field,
label: resolvePluginText(pluginId, field.label) ?? field.label,
options: field.options?.map((option) => ({ ...option, label: resolvePluginText(pluginId, option.label) ?? option.label })),
})),
};
}
/** Resolve `$t:` references in command titles/descriptions/forms for Control Center snapshots and pet menus. */
function resolveCommandText(pluginId: string, command: PluginCommand): PluginCommand {
return {
...command,
title: resolvePluginText(pluginId, command.title) ?? command.title,
description: resolvePluginText(pluginId, command.description),
form: resolveCommandFormText(pluginId, command.form),
};
}
export async function getDefaultPetPluginCommands(maxPlugins = 8, maxCommandsPerPlugin = 8): Promise<PluginCommandMenuItem[]> {
if (!appPluginService) return [];
const snapshot = await appPluginService.getSnapshot();
return snapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason && plugin.commands && plugin.commands.length > 0)
.sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id)).slice(0, maxPlugins)
.flatMap((plugin) => [...(plugin.commands ?? [])].slice(0, maxCommandsPerPlugin).map((command) => ({ pluginId: plugin.id, pluginName: plugin.name ?? plugin.id, commandId: command.id, commandTitle: command.title, form: command.form, placement: command.placement, priority: command.priority, featured: command.featured })));
.flatMap((plugin) => [...(plugin.commands ?? [])].slice(0, maxCommandsPerPlugin).map((command) => ({ pluginId: plugin.id, pluginName: resolvePluginText(plugin.id, plugin.name) ?? plugin.id, commandId: command.id, commandTitle: command.title, form: command.form, placement: command.placement, priority: command.priority, featured: command.featured })));
}
export async function getDefaultPetPluginMenuItems(maxPlugins = 8, maxItemsPerPlugin = 8): Promise<PluginDynamicMenuItem[]> {
@ -465,7 +540,7 @@ export async function getDefaultPetPluginMenuItems(maxPlugins = 8, maxItemsPerPl
.sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id)).slice(0, maxPlugins)
.flatMap((plugin) => {
const items = appPluginService!.runtime.getPluginState(plugin.id).menuItems ?? [];
return [...items].slice(0, maxItemsPerPlugin).map((item) => ({ pluginId: plugin.id, pluginName: plugin.name ?? plugin.id, itemId: item.id, title: item.title, enabled: item.enabled, checked: item.checked }));
return [...items].slice(0, maxItemsPerPlugin).map((item) => ({ pluginId: plugin.id, pluginName: resolvePluginText(plugin.id, plugin.name) ?? plugin.id, itemId: item.id, title: resolvePluginText(plugin.id, item.title) ?? item.title, enabled: item.enabled, checked: item.checked }));
});
}
@ -504,6 +579,22 @@ function safeError(error: unknown): string {
return "Plugin manifest is unavailable.";
}
function safeSoundError(error: unknown): string {
const message = error instanceof Error ? error.message : "Plugin sound import failed.";
if (/format is not supported|unsupported format|unsupported audio/i.test(message)) return "Plugin sound format is not supported.";
if (/too large|size/i.test(message)) return "Plugin sound file is too large.";
if (/missing|not found|ENOENT/i.test(message)) return "Plugin sound file is missing.";
if (/permission|EACCES|EPERM/i.test(message)) return "Plugin sound file cannot be read.";
if (looksPathLike(message)) return "Plugin sound import failed.";
return message.slice(0, 160) || "Plugin sound import failed.";
}
function safeCommandError(error: unknown, pluginId: string): string {
const message = error instanceof Error ? error.message : "Plugin command failed.";
if (looksPathLike(message)) return "Plugin command failed. Check logs for details.";
return message.replace(/\$t:([A-Za-z0-9._:-]+)/g, (_match, key: string) => resolvePluginText(pluginId, `$t:${key}`) ?? key).slice(0, 180) || "Plugin command failed.";
}
function sanitizePluginUiMessage(value: string | undefined): string | undefined {
if (!value) return undefined;
if (looksPathLike(value)) return "Plugin needs attention. Check logs for details.";
@ -514,6 +605,23 @@ function looksPathLike(value: string): boolean {
return /(?:[A-Za-z]:\\|\/[^\s]+|\\[^\s]+|file:\/\/|ENOENT|EACCES|EPERM)/.test(value);
}
/** Resolve `$t:` references in a config field's display strings (label/description/options/nested itemSchema). */
function resolveConfigFieldText(pluginId: string, field: PluginConfigField): PluginConfigField {
return {
...field,
label: resolvePluginText(pluginId, field.label),
description: resolvePluginText(pluginId, field.description),
options: field.options?.map((option) => ({ ...option, label: resolvePluginText(pluginId, option.label) ?? option.label })),
itemSchema: field.itemSchema ? Object.fromEntries(Object.entries(field.itemSchema).map(([key, sub]) => [key, resolveConfigFieldText(pluginId, sub)])) : field.itemSchema,
};
}
/** Walk a manifest's `configSchema`, resolving every `$t:` display string against the plugin's catalogs. */
function resolveConfigSchemaText(pluginId: string, schema: OpenPetsPluginManifest["configSchema"]): OpenPetsPluginManifest["configSchema"] {
if (!schema) return schema;
return Object.fromEntries(Object.entries(schema).map(([key, field]) => [key, resolveConfigFieldText(pluginId, field)]));
}
function isPermissionSubset(next: readonly PluginPermission[], approved: readonly PluginPermission[]): boolean {
const approvedSet = new Set(approved);
return next.every((permission) => approvedSet.has(permission));
@ -584,6 +692,15 @@ async function defaultOpenDialog(): Promise<{ canceled: boolean; filePaths: stri
return dialog.showOpenDialog({ properties: ["openDirectory"] });
}
async function defaultSoundOpenDialog(options?: unknown): Promise<{ canceled: boolean; filePaths: string[] }> {
const { dialog } = await import("electron");
return dialog.showOpenDialog(isDialogOptions(options) ? options : { properties: ["openFile"], filters: [{ name: "Audio", extensions: ["ogg", "mp3", "wav"] }] });
}
function isDialogOptions(value: unknown): value is OpenDialogOptions {
return typeof value === "object" && value !== null;
}
async function defaultConfirmPermissions(manifest: OpenPetsPluginManifest): Promise<boolean> {
const { dialog } = await import("electron");
const permissions = manifest.permissions.length === 0 ? "No permissions" : manifest.permissions.join(", ");

View file

@ -0,0 +1,82 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import { basename, extname, join } from "node:path";
export type UserSoundRef = { kind: "user-sound"; id: string; name?: string };
export type UserSoundEntry = { path: string; name?: string };
export const userSoundMimeByExtension: Readonly<Record<string, string>> = { ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav" };
export const maxUserSoundBytes = 1024 * 1024;
export const userSoundIdPattern = /^[a-f0-9]{32}$/;
export class UserSoundStore {
readonly root: string;
readonly #cache = new Map<string, UserSoundEntry>();
constructor(root: string) {
this.root = root;
}
async importFromPath(pluginId: string, sourcePath: string, opts: { name?: string } = {}): Promise<UserSoundRef> {
const stat = await fs.stat(sourcePath);
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
const ext = extname(sourcePath).toLowerCase();
if (!userSoundMimeByExtension[ext]) throw new Error("Plugin sound format is not supported.");
const bytes = await fs.readFile(sourcePath);
const id = createHash("sha256").update(pluginId).update("\0").update(bytes).digest("hex").slice(0, 32);
const dir = join(this.root, safeSegment(pluginId));
await fs.mkdir(dir, { recursive: true });
const dest = join(dir, `${id}${ext}`);
await fs.writeFile(dest, bytes, { flag: "w" });
const entry = { path: dest, name: opts.name ?? basename(sourcePath) };
this.#cache.set(cacheKey(pluginId, id), entry);
return { kind: "user-sound", id, name: entry.name };
}
async resolvePath(pluginId: string, id: string): Promise<string> {
assertUserSoundId(id);
const entry = await this.load(pluginId, id);
if (entry) return entry.path;
throw new Error("User sound reference is invalid.");
}
async load(pluginId: string, id: string): Promise<UserSoundEntry | undefined> {
assertUserSoundId(id);
const key = cacheKey(pluginId, id);
const cached = this.#cache.get(key);
if (cached) return cached;
const dir = join(this.root, safeSegment(pluginId));
for (const ext of Object.keys(userSoundMimeByExtension)) {
const path = join(dir, `${safeSegment(id)}${ext}`);
try {
const stat = await fs.stat(path);
if (stat.isFile()) {
const entry = { path, name: undefined };
this.#cache.set(key, entry);
return entry;
}
} catch { /* try next extension */ }
}
return undefined;
}
async forget(pluginId: string, ref: { id: string }): Promise<void> {
assertUserSoundId(ref.id);
const key = cacheKey(pluginId, ref.id);
const entry = this.#cache.get(key) ?? await this.load(pluginId, ref.id);
this.#cache.delete(key);
if (entry) await fs.unlink(entry.path).catch(() => undefined);
}
async clearPlugin(pluginId: string): Promise<void> {
const prefix = `${pluginId}\0`;
for (const key of [...this.#cache.keys()]) if (key.startsWith(prefix)) this.#cache.delete(key);
await fs.rm(join(this.root, safeSegment(pluginId)), { recursive: true, force: true });
}
}
export function safeSegment(value: string): string { return value.replace(/[^a-z0-9._-]/gi, "_").slice(0, 80); }
export function isValidUserSoundId(value: unknown): value is string { return typeof value === "string" && userSoundIdPattern.test(value); }
export function assertUserSoundId(value: string): void { if (!isValidUserSoundId(value)) throw new Error("User sound reference is invalid."); }
function cacheKey(pluginId: string, id: string): string { return `${pluginId}\0${id}`; }

View file

@ -1,3 +1,5 @@
import type { Locale } from "./i18n/catalog.js";
import { localizedReactionMessagePools } from "./i18n/reactions/index.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
export const reactionMessagePools = {
@ -157,7 +159,7 @@ export const reactionMessagePools = {
],
} as const satisfies Record<OpenPetsReaction, readonly string[]>;
export function pickReactionMessage(reaction: OpenPetsReaction, random: () => number = Math.random): string {
const pool = reactionMessagePools[reaction];
export function pickReactionMessage(reaction: OpenPetsReaction, random: () => number = Math.random, locale: Locale = "en"): string {
const pool = localizedReactionMessagePools[locale]?.[reaction] ?? reactionMessagePools[reaction];
return pool[Math.floor(random() * pool.length) % pool.length] ?? reaction;
}

View file

@ -0,0 +1,54 @@
import React, { createContext, useContext } from "react";
// Renderer-side i18n. The renderer never imports the catalog source (it lives
// under the main-process rootDir); instead it receives a fully-resolved message
// map over IPC (`openpets:get-i18n`) and looks strings up here. Missing keys
// fall back to the key itself so a partial wiring is visible, not blank.
export type I18nSnapshot = {
locale: string;
localePreference: string;
availableLocales: { value: string; label: string }[];
messages: Record<string, string>;
};
export type I18nContextValue = {
locale: string;
localePreference: string;
availableLocales: { value: string; label: string }[];
t: (key: string, vars?: Record<string, string | number>) => string;
reload: () => void;
};
function interpolate(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match,
);
}
const fallback: I18nContextValue = {
locale: "en",
localePreference: "system",
availableLocales: [],
t: (key) => key,
reload: () => {},
};
const I18nContext = createContext<I18nContextValue>(fallback);
export function I18nProvider({ snapshot, onReload, children }: { snapshot: I18nSnapshot | null; onReload: () => void; children: React.ReactNode }) {
const value: I18nContextValue = snapshot
? {
locale: snapshot.locale,
localePreference: snapshot.localePreference,
availableLocales: snapshot.availableLocales,
t: (key, vars) => interpolate(snapshot.messages[key] ?? key, vars),
reload: onReload,
}
: { ...fallback, reload: onReload };
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
return useContext(I18nContext);
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
@tailwind utilities;
@layer base {
body { margin:0; min-height:100vh; overflow:hidden; color:#102149; background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%), linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
body { margin:0; min-height:100vh; overflow:hidden; color:#102149; background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%), linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Malgun Gothic", "Apple SD Gothic Neo", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Microsoft JhengHei", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", sans-serif; -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(96, 165, 250, 0.25); border-radius: 100px; }
@ -142,21 +142,21 @@
.plugins-layout { @apply relative flex min-h-0 flex-1; }
.plugins-hub { @apply flex min-h-0 flex-col overflow-hidden; }
.plugins-layout .plugins-hub { @apply w-full; }
.plugin-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-4 overflow-y-auto p-1.5 pr-2.5 pb-3 max-[850px]:grid-cols-1; overscroll-behavior: contain; }
.plugin-card { @apply flex w-full flex-col rounded-[24px] border border-slate-200 bg-white/80 p-0 text-left shadow-sm transition-[border-color,background-color,box-shadow]; }
.plugin-grid { @apply grid min-h-0 flex-1 grid-cols-2 items-start content-start gap-3.5 overflow-y-auto p-1.5 pr-2.5 pb-3 max-[850px]:grid-cols-1; grid-auto-rows: max-content; overscroll-behavior: contain; }
.plugin-card { @apply flex w-full flex-col rounded-2xl border border-slate-200 bg-white/80 p-0 text-left shadow-sm transition-[border-color,background-color,box-shadow]; }
.plugin-card:hover { @apply border-brand/40 bg-white; }
.plugin-card.broken { @apply border-red-200 bg-red-50/60; }
.plugin-card-body { @apply flex items-start gap-4 p-4; }
.plugin-card-icon { @apply grid h-14 w-14 shrink-0 place-items-center rounded-2xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-card-icon .plugin-glyph { @apply h-7 w-7; }
.plugin-card-body { @apply flex items-start gap-3 p-3; }
.plugin-card-icon { @apply grid h-12 w-12 shrink-0 place-items-center rounded-xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-card-icon .plugin-glyph { @apply h-6 w-6; }
.plugin-glyph { @apply h-7 w-7 stroke-[2.5]; }
.plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-1; }
.plugin-card-content strong { @apply truncate text-base font-black text-navy tracking-tight; }
.plugin-card-content small { @apply line-clamp-2 text-xs leading-relaxed text-slatecopy; }
.plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-0.5; }
.plugin-card-content strong { @apply truncate text-[15px] font-bold text-navy tracking-tight; }
.plugin-card-content small { @apply line-clamp-2 text-xs leading-snug text-slatecopy; }
.plugin-card-footer { @apply flex items-center justify-between border-t border-blue-50/50 bg-blue-50/10 px-4 py-3 rounded-b-[24px]; }
.plugin-card-footer { @apply flex items-center justify-between border-t border-blue-50/50 bg-blue-50/10 px-3 py-2 rounded-b-2xl; }
.plugin-card-meta { @apply flex items-center gap-2; }
.plugin-card-actions { @apply flex items-center gap-3; }
@ -214,18 +214,20 @@
.plugin-list-item { @apply flex flex-col gap-3 rounded-2xl border border-blue-100/70 bg-blue-50/25 p-3; }
.plugin-list-item-header { @apply flex items-center justify-between text-xs font-bold uppercase tracking-wider text-slatecopy; }
.plugin-command-list { @apply flex flex-wrap gap-2; }
.plugin-command-form-panel { @apply flex flex-col gap-3 rounded-2xl border border-blue-100/50 bg-blue-50/15 p-3; }
.plugin-command-form { @apply grid gap-2; }
.plugin-actions-section { @apply grid grid-cols-3 gap-2; }
.plugin-actions-section .btn-secondary { @apply bg-blue-50/70; }
.plugin-actions-section .btn-primary { @apply min-w-[96px]; }
.plugin-actions-section .btn-danger { @apply min-w-[112px]; }
@media (max-width: 900px) { .plugins-layout, .plugins-hub, .plugin-grid { @apply overflow-visible; } .plugin-actions-section { @apply grid-cols-1; } }
.integration-grid { @apply grid grid-cols-2 gap-4 max-[850px]:grid-cols-1; }
.integration-card { @apply flex min-h-[190px] flex-col justify-between rounded-3xl border border-blue-100/70 bg-white/70 p-4 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] active:scale-[0.96]; }
.integration-grid { @apply grid grid-cols-2 gap-3.5 max-[850px]:grid-cols-1; }
.integration-card { @apply flex min-h-[138px] flex-col justify-between rounded-2xl border border-blue-100/70 bg-white/70 p-0 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] active:scale-[0.96]; }
.integration-card:hover { @apply border-brand/45 bg-white/90 shadow-md; }
.integration-icon { @apply grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-2xl border border-blue-100/70 bg-blue-50/70 text-brand shadow-sm; }
.integration-logo { @apply h-9 w-9 object-contain; }
.integration-icon svg { @apply w-8 h-8; }
.integration-icon { @apply grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl border border-blue-100/70 bg-blue-50/70 text-brand shadow-sm; }
.integration-logo { @apply h-8 w-8 object-contain; }
.integration-icon svg { @apply w-6 h-6; }
/* Dashboard */
.dashboard-layout { @apply flex flex-col gap-6 overflow-y-auto pr-2 pb-8 h-full; overscroll-behavior: contain; }

View file

@ -1,8 +1,9 @@
import { Menu, Tray, type MenuItemConstructorOptions } from "electron";
import { Menu, shell, Tray, type MenuItemConstructorOptions } from "electron";
import { getAppStateSnapshot } from "./app-state.js";
import { createTrayIcon } from "./assets.js";
import { hideDefaultPet, isDefaultPetVisible, setDefaultPetPaused, showDefaultPet } from "./default-pet-controller.js";
import { t } from "./i18n/index.js";
import { quitOpenPets } from "./lifecycle.js";
import { info, openLogsFolder } from "./logger.js";
import { shellState, togglePaused } from "./state.js";
@ -32,7 +33,7 @@ export function refreshTrayMenu(): void {
const state = getAppStateSnapshot();
const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId && !pet.broken) ?? state.pets.installed[0];
const defaultPetName = defaultPet?.displayName ?? "Built-in Pet";
const defaultPetName = defaultPet?.displayName ?? t("common.builtInPet");
const menu = Menu.buildFromTemplate([
{
@ -42,11 +43,11 @@ export function refreshTrayMenu(): void {
...createUpdateMenuItems(),
{ type: "separator" },
{
label: `Default Pet: ${defaultPetName}`,
label: t("tray.defaultPet", { name: defaultPetName }),
click: () => openControlCenterWindow("pets"),
},
{
label: isDefaultPetVisible() ? "Hide Default Pet" : "Show Default Pet",
label: isDefaultPetVisible() ? t("tray.hideDefaultPet") : t("tray.showDefaultPet"),
click: () => {
if (isDefaultPetVisible()) {
hideDefaultPet();
@ -58,7 +59,7 @@ export function refreshTrayMenu(): void {
},
},
{
label: shellState.paused ? "Resume All Pets" : "Pause All Pets",
label: shellState.paused ? t("tray.resumeAllPets") : t("tray.pauseAllPets"),
click: () => {
const paused = togglePaused();
setDefaultPetPaused(paused);
@ -69,32 +70,37 @@ export function refreshTrayMenu(): void {
},
{ type: "separator" },
{
label: "Manage Pets...",
label: t("tray.managePets"),
click: () => openControlCenterWindow("pets"),
},
{
label: "Control Center...",
label: t("tray.controlCenter"),
click: () => openControlCenterWindow(),
},
{
label: "Integrations...",
label: t("tray.integrations"),
click: () => openControlCenterWindow("integrations"),
},
{
label: "Plugins...",
label: t("tray.plugins"),
click: () => openControlCenterWindow("plugins"),
},
{
label: "Settings...",
label: t("tray.settings"),
click: () => openControlCenterWindow("settings"),
},
{ type: "separator" },
{
label: "Open Logs Folder...",
label: t("tray.website"),
click: () => { void shell.openExternal("https://openpets.dev/"); },
},
{
label: t("tray.openLogsFolder"),
click: () => { void openLogsFolder(); },
},
{ type: "separator" },
{
label: "Quit OpenPets",
label: t("tray.quit"),
click: () => quitOpenPets(),
},
]);
@ -107,7 +113,7 @@ function createUpdateMenuItems(): MenuItemConstructorOptions[] {
if (status.state !== "available") return [];
return [
{
label: `Update available: ${status.latestVersion ?? "latest"}...`,
label: t("tray.updateAvailable", { version: status.latestVersion ?? t("common.latest") }),
click: () => { void openUpdateReleasePage(); },
},
];

View file

@ -9,11 +9,13 @@ import { getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferen
import { createAppIcon } from "./assets.js";
import { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
import { getCodexPetsUiState, importCodexPet, readCodexPetSpritesheet } from "./codex-pets.js";
import { getActiveLocale, getActiveMessages, isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, setLocaleFromPreference, t, type Locale, type LocalePreference } from "./i18n/index.js";
import { recoverDefaultPetMouseInterop, refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js";
import { installPet, installPetFromFolder, installPetFromZipFile, removePet, setDefaultInstalledPet } from "./pet-installation.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { debug, error as logError, warn } from "./logger.js";
import { getPluginService, type PluginServiceResult } from "./plugin-service.js";
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
import { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js";
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
import { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";
@ -77,6 +79,20 @@ function getSettingsStateSnapshot(): {
};
}
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(),
};
}
async function getDashboardSnapshot(): Promise<{
readonly defaultPet: { readonly id: string; readonly displayName: string; readonly previewSpriteUrl: string };
readonly installedPetCount: number;
@ -138,6 +154,11 @@ export function installInternalUiHandlers(): void {
return getSettingsStateSnapshot();
});
ipcMain.handle("openpets:get-i18n", (event) => {
assertAllowedSender(event, ["control-center"]);
return getI18nSnapshot();
});
ipcMain.handle("openpets:get-dashboard-snapshot", async (event) => {
assertAllowedSender(event, ["control-center"]);
return getDashboardSnapshot();
@ -165,16 +186,35 @@ export function installInternalUiHandlers(): void {
return getPluginService().saveConfig(id, config);
});
ipcMain.handle("openpets:plugins-pick-config-sound", async (event, id: unknown): Promise<PluginConfigSoundPickResult> => {
assertAllowedSender(event, ["control-center"]);
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(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 && 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("openpets:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(event, ["control-center"]);
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin reload request.");
return getPluginService().reload(id);
});
ipcMain.handle("openpets:plugins-execute-command", async (event, id: unknown, commandId: unknown): Promise<PluginServiceResult> => {
ipcMain.handle("openpets:plugins-execute-command", async (event, id: unknown, commandId: unknown, args: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(event, ["control-center"]);
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || typeof commandId !== "string" || !/^[A-Za-z0-9._:-]{1,64}$/.test(commandId)) return pluginUiError("Invalid plugin command request.");
return getPluginService().executeCommand(id, commandId);
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || typeof commandId !== "string" || !/^[A-Za-z0-9._:-]{1,64}$/.test(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("openpets:plugins-load-local", async (event): Promise<PluginServiceResult> => {
@ -270,12 +310,20 @@ export function installInternalUiHandlers(): void {
assertAllowedSender(event, ["control-center"]);
const previousScale = getAppStateSnapshot().preferences.petScale;
const previousOverrides = JSON.stringify(getAppStateSnapshot().preferences.reactionAnimationOverrides ?? {});
const previousLocale = getActiveLocale();
const state = updatePreferences(validatePreferencePatch(patch));
const nextOverrides = JSON.stringify(state.preferences.reactionAnimationOverrides ?? {});
if (state.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
refreshDefaultPetContent();
refreshAgentPetContent();
}
if (setLocaleFromPreference(state.preferences.locale) !== previousLocale) {
// Tray labels are rendered eagerly, so rebuild the menu in the new language.
void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu());
// Control Center plugin labels are resolved at display time; nudge it to re-fetch the
// SafePluginRecords so manifest/config labels re-render in the new language.
broadcastPluginRecordsRefresh();
}
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : state;
});
@ -572,6 +620,13 @@ function sendControlCenterRoute(window: BrowserWindow, route: ControlCenterRoute
window.webContents.send("openpets:control-center-route", route);
}
/** Tell the open Control Center to re-fetch the plugin snapshot (e.g. after a locale change). */
function broadcastPluginRecordsRefresh(): void {
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) {
controlCenterWindow.webContents.send("openpets:plugins-refresh");
}
}
function routeControlCenterWindow(window: BrowserWindow, route: ControlCenterRoute): void {
pendingControlCenterRoute = route;
if (window.webContents.isLoading()) return;
@ -595,6 +650,24 @@ function pluginUiError(error: string): PluginServiceResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
async function logUiAction<T>(operation: string, pluginId: string | undefined, fn: () => Promise<T>, extra: Record<string, unknown> = {}): Promise<T> {
const started = Date.now();
logPluginDiagnostic((level, message, fields) => (level === "warn" || level === "error" ? warn("ui", message, fields) : debug("ui", message, fields)), "debug", "plugin ui action", { operation, pluginId, phase: "request", ...extra });
try {
const result = await fn();
const ok = typeof result === "object" && result !== null && "ok" in result ? Boolean((result as { ok?: unknown }).ok) : true;
logPluginDiagnostic((level, message, fields) => (level === "warn" || level === "error" ? warn("ui", message, fields) : debug("ui", message, fields)), ok ? "debug" : "warn", "plugin ui action", { operation, pluginId, phase: "result", ok, durationMs: Date.now() - started, reason: ok ? undefined : (result as { error?: string }).error, ...extra });
return result;
} catch (error) {
logPluginDiagnostic((level, message, fields) => (level === "warn" || level === "error" ? warn("ui", message, fields) : debug("ui", message, fields)), "warn", "plugin ui action", { operation, pluginId, phase: "fail", ok: false, durationMs: Date.now() - started, reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), ...extra });
throw error;
}
}
function pluginUiSoundError(error: string): PluginConfigSoundPickResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
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;
@ -639,8 +712,16 @@ async function getReactionAnimationSettingsSnapshot(): Promise<unknown> {
const state = getAppStateSnapshot();
const preview = await getDefaultPetPreviewSpriteInfo();
return {
reactions: reactionAnimationMetadata,
animations: selectableAnimationMetadata,
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: `openpets-pet-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
@ -666,18 +747,23 @@ async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
}
function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides> } {
function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; locale?: LocalePreference; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides> } {
if (!isRecord(value)) {
throw new Error("Invalid preferences patch.");
}
const patch: { openDefaultPetOnLaunch?: boolean; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides> } = {};
const patch: { openDefaultPetOnLaunch?: boolean; locale?: LocalePreference; petScale?: number; reactionAnimationOverrides?: ReturnType<typeof validateReactionAnimationOverrides> } = {};
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 pet scale value.");

View file

@ -16,6 +16,12 @@ assertInvalidReplacement({ mood: "unknown" }, "invalid_select_value");
assertInvalidReplacement(new Date(), "invalid_config");
assertInvalidReplacement(new Map(), "invalid_config");
assertInvalidReplacement([], "invalid_config");
const soundManifest = manifest({ configSchema: { sound: { type: "sound" } } });
assert.equal(validatePluginConfigReplacement(soundManifest, { sound: "alert" }).ok, true);
assert.equal(validatePluginConfigReplacement(soundManifest, { sound: { kind: "user-sound", id: "a".repeat(32), name: "Ding" } }).ok, true);
assert.equal(validatePluginConfigReplacement(soundManifest, { sound: { kind: "user-sound", id: "../secret" } }).ok, false);
assert.equal(validatePluginConfigReplacement(soundManifest, { sound: { kind: "user-sound", id: "not-a-hash" } }).ok, false);
assert.equal(validatePluginConfigReplacement(soundManifest, { sound: "/tmp/ding.wav" }).ok, false);
assert.deepEqual(validatePluginConfigReplacement(base, { message: "Move", details: "Now", intervalMinutes: 12, enabled: false, mood: "active" }), {
ok: true,
config: { details: "Now", enabled: false, intervalMinutes: 12, message: "Move", mood: "active" },

View file

@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PluginSdkBridge, type PluginHostCapabilities } from "../src/plugin-sdk-bridge.js";
import { pluginSdkQuotas } from "../src/plugin-sdk-quotas.js";
import { PluginStateStore, type PluginStateRecord } from "../src/plugin-state.js";
import type { OpenPetsJavascriptPluginManifest } from "../src/plugin-manifest.js";
import { sanitizePluginDiagnosticsFields } from "../src/plugin-diagnostics.js";
await scenario("storage.subscribe receives set value and delete as undefined", async ({ api }) => {
const values: unknown[] = [];
api.storage.subscribe("counter", (value: unknown) => values.push(value));
api.storage.set("counter", 3);
api.storage.delete("counter");
await Promise.resolve();
assert.equal(values.length, 2);
assert.equal(values[0], 3);
assert.equal(values[1], undefined);
});
await scenario("storage subscription quota is enforced", async ({ api }) => {
for (let i = 0; i < pluginSdkQuotas.storageSubscriptions; i += 1) {
api.storage.subscribe(`key-${i}`, () => undefined);
}
assert.throws(
() => api.storage.subscribe("one-too-many", () => undefined),
/Plugin storage subscription quota exceeded\./,
);
});
await scenario("config.onChange disposer removes listener", async ({ api, bridge, store }) => {
const seen: unknown[] = [];
const dispose = api.config.onChange((config: Record<string, unknown>) => seen.push(config.value));
store.replaceConfig("plug", { value: "first" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
dispose();
store.replaceConfig("plug", { value: "second" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
assert.deepEqual(seen, ["first"]);
});
await scenario("diagnostics sanitizer redacts paths tokens and URL queries", () => {
const safe = sanitizePluginDiagnosticsFields({ reason: "failed /Users/alvin/secrets/token.txt https://example.com/path?token=abc123 sk-1234567890123456", host: "example.com", ignored: "secret" });
assert.equal(safe.host, "example.com");
assert.equal("ignored" in safe, false);
const reason = String(safe.reason);
assert.equal(reason.includes("/Users/alvin"), false);
assert.equal(reason.includes("abc123"), false);
assert.equal(reason.includes("sk-1234567890123456"), false);
});
await scenario("events.on config:changed uses config listener path", async ({ api, bridge, store, capabilities }) => {
const seen: unknown[] = [];
const sub = api.events.on("config:changed", (config: Record<string, unknown>) => seen.push(config.value));
store.replaceConfig("plug", { value: "first" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
api.events.off(sub.subscriptionId);
store.replaceConfig("plug", { value: "second" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
assert.deepEqual(seen, ["first"]);
assert.deepEqual(capabilities.events.subscribed, []);
});
type ScenarioContext = {
api: ReturnType<PluginSdkBridge["createApi"]>;
bridge: PluginSdkBridge;
store: PluginStateStore;
capabilities: TestCapabilities;
};
async function scenario(name: string, run: (context: ScenarioContext) => Promise<void> | void): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "openpets-plugin-sdk-"));
try {
const store = new PluginStateStore({ statePath: join(root, "state.json") });
store.initialize();
const record: PluginStateRecord = {
id: "plug",
version: "1.0.0",
manifestPath: join(root, "openpets.plugin.json"),
installPath: root,
source: "local",
manifestVersion: 3,
runtime: "javascript",
sdkVersion: "3.0.0",
enabled: true,
approvedPermissions: ["events", "storage"],
config: {},
};
store.upsertRecord(record);
const capabilities = createTestCapabilities();
const bridge = new PluginSdkBridge({
stateStore: store,
petApi: { speak() {}, react() {}, moveBy() {}, wander() {}, moveToHome() {} },
scheduler: { setTimeout: () => ({ cancel() {} }) },
capabilities,
});
const api = bridge.createApi(record, manifest());
await run({ api, bridge, store, capabilities });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
type TestCapabilities = PluginHostCapabilities & { events: PluginHostCapabilities["events"] & { subscribed: string[] } };
function createTestCapabilities(): TestCapabilities {
return {
bubbles: { show: async () => ({ id: "bubble", update: async () => undefined, dismiss: async () => undefined, pin: async () => undefined, unpin: async () => undefined }) },
audio: { play: async () => undefined, importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), forgetUserSound: async () => undefined, stop: async () => undefined },
events: { subscribed: [], subscribe(event) { this.subscribed.push(event); return () => undefined; } },
pets: {
list: () => [],
spawn: async () => "pet",
close: async () => undefined,
show: async () => undefined,
hide: async () => undefined,
react: async () => undefined,
setAnimation: async () => undefined,
setScale: async () => undefined,
setStatusReaction: async () => undefined,
moveBy: async () => undefined,
wander: async () => undefined,
moveToHome: async () => undefined,
moveTo: async () => undefined,
followCursor: async () => undefined,
physics: async () => undefined,
getState: async () => ({ position: { x: 0, y: 0 }, bounds: { x: 0, y: 0, width: 0, height: 0 }, currentAnimation: "idle", visible: true, dragging: false }),
onTick: () => () => undefined,
onChange: () => () => undefined,
},
toast: async () => undefined,
notify: async () => undefined,
panels: { open: async () => ({ id: "panel", show: async () => undefined, hide: async () => undefined, postMessage: async () => undefined, close: async () => undefined }) },
secrets: { get: async () => undefined, set: async () => undefined, delete: async () => undefined, has: async () => false },
ai: { available: async () => false, complete: async () => ({ text: "" }), stream: async () => ({ text: "" }) },
voice: { speak: async () => undefined, listen: async () => ({ text: "" }) },
auth: { oauth: async () => ({ accessToken: "" }), refresh: async () => ({ accessToken: "" }), signOut: async () => undefined },
files: { pick: async () => [], read: async () => "", save: async () => undefined },
system: { info: async () => ({ platform: "mac", locale: "en-US", timezone: "UTC", theme: "light", appVersion: "0.0.0", online: true }), metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }), openExternal: async () => undefined, readClipboardText: async () => "", writeClipboardText: async () => undefined },
settings: { audioAllowed: () => true, dynamicSpeechAllowed: () => false, voiceAllowed: () => true, listenAllowed: () => false, inQuietHours: () => false },
};
}
function manifest(): OpenPetsJavascriptPluginManifest {
return {
manifestVersion: 3,
id: "plug",
name: "Plug",
version: "1.0.0",
runtime: "javascript",
sdkVersion: "3.0.0",
entry: "index.js",
permissions: ["events", "storage"],
};
}

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsDeclarativePluginManifest } from "../src/plugin-manifest.js";
import { PluginService, executeDefaultPetPluginCommand, getDefaultPetPluginCommands, setPluginServiceForTests, stopPluginService } from "../src/plugin-service.js";
@ -13,14 +13,15 @@ let lastRoot = "";
class FakeRuntime {
reloads: string[] = [];
logs: Array<{ level: string; message: string; fields?: Record<string, unknown> }> = [];
commandState: Record<string, Array<{ id: string; title: string }>> = {};
commandState: Record<string, Array<{ id: string; title: string; description?: string; form?: { submitLabel?: string; fields: Array<{ id: string; type: string; label: string }> } }>> = {};
executed: Array<{ pluginId: string; commandId: string }> = [];
commandError: Error | null = null;
stopped = false;
async start(): Promise<void> {}
stop(): void { this.stopped = true; }
async reloadPlugin(id: string): Promise<void> { this.reloads.push(id); }
getPluginState(id: string): { commands: Array<{ id: string; title: string }> } { return { commands: this.commandState[id] ?? [] }; }
async executeCommand(pluginId: string, commandId: string): Promise<void> { this.executed.push({ pluginId, commandId }); }
getPluginState(id: string): { commands: Array<{ id: string; title: string; description?: string; form?: { submitLabel?: string; fields: Array<{ id: string; type: string; label: string }> } }> } { return { commands: this.commandState[id] ?? [] }; }
async executeCommand(pluginId: string, commandId: string): Promise<void> { this.executed.push({ pluginId, commandId }); if (this.commandError) throw this.commandError; }
log(level: string, message: string, fields?: Record<string, unknown>): void { this.logs.push({ level, message, fields }); }
}
@ -90,6 +91,60 @@ await scenario("config save replaces and reloads", async ({ service, store, runt
assert.deepEqual(runtime.reloads, ["plug"]);
});
await scenario("config save reload preserves plugin user sounds", async ({ root, userData, store, runtime }) => {
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, allowedPluginRoots: [root] });
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0", config: { customSound: { kind: "user-sound", id: "a".repeat(32), name: "Bell" } } }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
const soundDir = join(userData, "plugin-user-sounds", "plug");
const soundPath = join(soundDir, `${"a".repeat(32)}.ogg`);
mkdirSync(soundDir, { recursive: true });
writeFileSync(soundPath, "sound");
const result = await service.saveConfig("plug", { customSound: { kind: "user-sound", id: "a".repeat(32), name: "Bell" } });
assert.equal(result.ok, true);
assert.deepEqual(runtime.reloads, ["plug"]);
assert.equal(existsSync(soundPath), true);
});
await scenario("pickConfigSound logs stages and useful unsupported format error", async ({ root, store, runtime }) => {
const selectedPath = join(root, "tone.flac");
writeFileSync(selectedPath, "sound");
const service = new PluginService({
stateStore: store,
runtime: runtime as never,
allowedPluginRoots: [root],
showSoundOpenDialog: async () => ({ canceled: false, filePaths: [selectedPath] }),
capabilities: { audio: { importUserSoundFromPath: async () => { throw new Error("Plugin sound format is not supported."); } } } as never,
});
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0" }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
const result = await service.pickConfigSound("plug");
assert.equal(result.ok, false);
assert.equal(result.error, "Plugin sound format is not supported.");
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound pick requested." && entry.fields?.pluginId === "plug"), true);
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound picker opened."), true);
const selectedLog = runtime.logs.find((entry) => entry.message === "Plugin config sound file selected.");
assert.equal(selectedLog?.fields?.basename, basename(selectedPath));
assert.equal(selectedLog?.fields?.ext, ".flac");
assert.equal(selectedLog?.fields?.sizeBytes, 5);
assert.equal(Object.values(selectedLog?.fields ?? {}).some((value) => typeof value === "string" && value.includes(root)), false);
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound import failed." && entry.fields?.reason === "Plugin sound format is not supported."), true);
});
await scenario("pickConfigSound returns opaque sound and logs success", async ({ root, store, runtime }) => {
const selectedPath = join(root, "ding.ogg");
writeFileSync(selectedPath, "sound");
const service = new PluginService({
stateStore: store,
runtime: runtime as never,
allowedPluginRoots: [root],
showSoundOpenDialog: async () => ({ canceled: false, filePaths: [selectedPath] }),
capabilities: { audio: { importUserSoundFromPath: async (pluginId: string, path: string) => ({ kind: "user-sound", id: "abc123", name: basename(path) }) } } as never,
});
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0" }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
const result = await service.pickConfigSound("plug");
assert.equal(result.ok, true);
assert.deepEqual(result.sound, { kind: "user-sound", id: "abc123", name: "ding.ogg" });
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound import succeeded." && entry.fields?.pluginId === "plug" && entry.fields?.soundId === "abc123" && entry.fields?.name === "ding.ogg"), true);
});
await scenario("enable disable persists and reloads", async ({ service, store, runtime }) => {
addPlugin(store, { enabled: false });
const result = await service.setEnabled("plug", true);
@ -104,6 +159,20 @@ await scenario("reload unknown is safe error", async ({ service }) => {
assert.match(result.error, /not installed/);
});
await scenario("uninstall clears plugin user sounds", async ({ userData, root, store }) => {
mkdirSync(join(userData, "plugins"), { recursive: true });
mkdirSync(join(userData, "plugins-dev"), { recursive: true });
const runtime = new FakeRuntime();
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, allowedPluginRoots: [root] });
addPlugin(store, { source: "catalog", installPath: join(userData, "plugins", "plug") });
const soundDir = join(userData, "plugin-user-sounds", "plug");
mkdirSync(soundDir, { recursive: true });
writeFileSync(join(soundDir, "a".repeat(32) + ".ogg"), "sound");
const result = await service.uninstall("plug");
assert.equal(result.ok, true);
assert.equal(existsSync(soundDir), false);
});
await scenario("stop cancels runtime", async ({ service, runtime }) => {
service.stop();
assert.equal(runtime.stopped, true);
@ -246,23 +315,69 @@ await localScenario("loadLocal snapshots javascript entry", async ({ service, so
assert.equal(readFileSync(join(install, "index.mjs"), "utf8"), "export default {};\n");
});
await localScenario("loadLocal snapshots v3 locale catalogs for translated UI", async ({ service, source, store, userData, runtime }) => {
writeManifest(source, {
manifestVersion: 3,
id: "i18n-local",
name: "$t:plugin.name",
description: "$t:plugin.description",
version: "1.0.0",
runtime: "javascript",
sdkVersion: "3.0.0",
entry: "index.js",
permissions: ["pet:speak"],
configSchema: {
enabled: { type: "boolean", default: true, label: "$t:config.enabled.label", description: "$t:config.enabled.description" },
},
});
writeFileSync(join(source, "index.js"), "OpenPetsPlugin.register({ start() {} });\n", "utf8");
mkdirSync(join(source, "locales"), { recursive: true });
writeFileSync(join(source, "locales", "en.json"), JSON.stringify({
"plugin.name": "Translated Plugin",
"plugin.description": "Translated description.",
"config.enabled.label": "Translated toggle",
"config.enabled.description": "Translated toggle description.",
"command.run.title": "Translated command",
"command.run.description": "Translated command description.",
"command.run.submit": "Translated submit",
"form.message.label": "Translated message",
}), "utf8");
const result = await service.loadLocal();
assert.equal(result.ok, true);
const install = store.getRecord("i18n-local")?.installPath ?? join(userData, "plugins-dev", "i18n-local");
assert.equal(existsSync(join(install, "locales", "en.json")), true);
const plugin = result.snapshot.plugins[0];
assert.equal(plugin.name, "Translated Plugin");
assert.equal(plugin.description, "Translated description.");
assert.equal(plugin.configSchema?.enabled?.label, "Translated toggle");
assert.equal(plugin.configSchema?.enabled?.description, "Translated toggle description.");
runtime.commandState["i18n-local"] = [{ id: "run", title: "$t:command.run.title", description: "$t:command.run.description", form: { submitLabel: "$t:command.run.submit", fields: [{ id: "message", type: "text", label: "$t:form.message.label" }] } } as never];
const refreshed = (await service.getSnapshot()).plugins[0];
assert.equal(refreshed.commands?.[0]?.title, "Translated command");
assert.equal(refreshed.commands?.[0]?.description, "Translated command description.");
assert.equal(refreshed.commands?.[0]?.form?.submitLabel, "Translated submit");
assert.equal(refreshed.commands?.[0]?.form?.fields[0]?.label, "Translated message");
});
await localScenario("bundled seeding copies manifest and preserves user choices", async ({ userData, root, store }) => {
const official = join(root, "official");
const source = join(official, "openpets.break-buddy");
writeManifest(source, { manifestVersion: 2, id: "openpets.break-buddy", name: "Break Buddy", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"], configSchema: { minutes: { type: "number", default: 30 } } });
const source = join(official, "openpets.reminders");
writeManifest(source, { manifestVersion: 2, id: "openpets.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"], configSchema: { minutes: { type: "number", default: 30 } } });
writeFileSync(join(source, "index.js"), "OpenPetsPlugin.register({ start() {} });\n", "utf8");
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [official] });
await service.start();
let record = store.getRecord("openpets.break-buddy");
let record = store.getRecord("openpets.reminders");
assert.equal(record?.source, "catalog");
assert.equal(record?.bundled, true);
assert.equal(record?.enabled, true);
assert.equal(readFileSync(join(record?.installPath ?? "", "index.js"), "utf8"), "OpenPetsPlugin.register({ start() {} });\n");
store.replaceConfig("openpets.break-buddy", { minutes: 45 });
store.setEnabled("openpets.break-buddy", false);
writeManifest(source, { manifestVersion: 2, id: "openpets.break-buddy", name: "Break Buddy", version: "2.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak", "pet:reaction"] });
store.replaceConfig("openpets.reminders", { minutes: 45 });
store.setEnabled("openpets.reminders", false);
writeManifest(source, { manifestVersion: 2, id: "openpets.reminders", name: "Quick Reminders", version: "2.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak", "pet:reaction"] });
await service.seedBundledPlugins();
record = store.getRecord("openpets.break-buddy");
record = store.getRecord("openpets.reminders");
assert.equal(record?.version, "2.0.0");
assert.equal(record?.enabled, false);
assert.deepEqual(record?.config, { minutes: 45 });
@ -274,16 +389,16 @@ await localScenario("bundled seeding prunes stale ids and blocks uninstall updat
const oldManifest = writeManifest(oldInstall, manifest({ id: "openpets.pomodoro" }));
store.upsertRecord({ id: "openpets.pomodoro", version: "1.0.0", installPath: oldInstall, manifestPath: oldManifest, source: "catalog", enabled: true, approvedPermissions: ["timer", "pet:speak"], config: {} });
const official = join(root, "official");
const source = join(official, "openpets.github-notifications");
writeManifest(source, { manifestVersion: 2, id: "openpets.github-notifications", name: "GitHub", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["api.github.com"] } });
const source = join(official, "openpets.reminders");
writeManifest(source, { manifestVersion: 2, id: "openpets.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["api.github.com"] } });
writeFileSync(join(source, "index.js"), "OpenPetsPlugin.register({ start() {} });\n", "utf8");
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [official] });
await service.start();
assert.equal(store.getRecord("openpets.pomodoro"), undefined);
assert.equal(store.getRecord("openpets.github-notifications")?.enabled, false);
assert.deepEqual(store.getRecord("openpets.github-notifications")?.approvedNetworkHosts, ["api.github.com"]);
assert.equal((await service.uninstall("openpets.github-notifications")).ok, false);
const update = await service.updateCatalog("openpets.github-notifications");
assert.equal(store.getRecord("openpets.reminders")?.enabled, true);
assert.deepEqual(store.getRecord("openpets.reminders")?.approvedNetworkHosts, ["api.github.com"]);
assert.equal((await service.uninstall("openpets.reminders")).ok, false);
const update = await service.updateCatalog("openpets.reminders");
assert.equal(update.ok, false);
assert.match(update.error, /Bundled plugins update/);
});
@ -318,25 +433,25 @@ await localScenario("bundled seeding rejects plugins root symlink", async ({ use
mkdirSync(outsideRoot, { recursive: true });
symlinkSync(outsideRoot, join(userData, "plugins"), "dir");
const official = join(root, "official");
const source = join(official, "openpets.break-buddy");
writeManifest(source, { manifestVersion: 2, id: "openpets.break-buddy", name: "Break Buddy", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"] });
const source = join(official, "openpets.reminders");
writeManifest(source, { manifestVersion: 2, id: "openpets.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"] });
writeFileSync(join(source, "index.js"), "OpenPetsPlugin.register({ start() {} });\n", "utf8");
const runtime = new FakeRuntime();
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, bundledPluginSourceDirs: [official] });
await service.seedBundledPlugins();
assert.equal(store.getRecord("openpets.break-buddy"), undefined);
assert.equal(existsSync(join(outsideRoot, "openpets.break-buddy")), false);
assert.equal(store.getRecord("openpets.reminders"), undefined);
assert.equal(existsSync(join(outsideRoot, "openpets.reminders")), false);
assert.equal(runtime.logs.some((entry) => entry.message.includes("Bundled plugin seed failed")), true);
});
await localScenario("start skips bundled seeding when disabled", async ({ userData, root, store }) => {
const official = join(root, "official");
const source = join(official, "openpets.break-buddy");
writeManifest(source, { manifestVersion: 2, id: "openpets.break-buddy", name: "Break Buddy", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"] });
const source = join(official, "openpets.reminders");
writeManifest(source, { manifestVersion: 2, id: "openpets.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["pet:speak"] });
writeFileSync(join(source, "index.js"), "OpenPetsPlugin.register({ start() {} });\n", "utf8");
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [official], seedBundledPlugins: false });
await service.start();
assert.equal(store.getRecord("openpets.break-buddy"), undefined);
assert.equal(store.getRecord("openpets.reminders"), undefined);
});
await scenario("catalog metadata ignores bundled records", async ({ userData, store, runtime }) => {
@ -485,6 +600,14 @@ await scenario("right-click command helper groups caps and ignores stale command
stopPluginService();
});
await scenario("executeCommand returns plugin command validation errors", async ({ service, store, runtime }) => {
addPlugin(store);
runtime.commandError = new Error("Message is required.");
const result = await service.executeCommand("plug", "set-reminder");
assert.equal(result.ok, false);
assert.equal(result.error, "Message is required.");
});
console.error("Plugin service validation passed.");
async function scenario(name: string, fn: (ctx: { root: string; userData: string; store: PluginStateStore; service: PluginService; runtime: FakeRuntime }) => Promise<void>): Promise<void> {

View file

@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { UserSoundStore } from "../src/plugin-user-sound-store.js";
const root = mkdtempSync(join(tmpdir(), "openpets-user-sounds-"));
const source = join(root, "ding.ogg");
writeFileSync(source, Buffer.from("OggS test sound", "utf8"));
const store = new UserSoundStore(join(root, "store"));
const ref = await store.importFromPath("plugin-a", source, { name: "Ding" });
assert.match(ref.id, /^[a-f0-9]{32}$/);
assert.equal(existsSync(await store.resolvePath("plugin-a", ref.id)), true);
await assert.rejects(() => store.resolvePath("plugin-b", ref.id), /invalid/);
await assert.rejects(() => store.resolvePath("plugin-a", "../bad"), /invalid/);
await store.clearPlugin("plugin-a");
await assert.rejects(() => store.resolvePath("plugin-a", ref.id), /invalid/);
console.error("Plugin user sound store validation passed.");

View file

@ -47,7 +47,15 @@ manifests may carry `$schema` for editor validation.
(`plugin-assets.ts` strips script/foreignObject/event handlers/external hrefs).
- `panels`: `{ name: relative .html path }` (max 8). Panel HTML gets a strict
CSP injected at install.
- New config field types `date` and `secret` (masked input; no defaults allowed).
- New config field types `date`, `sound`, and `secret` (masked input; no defaults allowed).
Minimum unblock note: Control Center `sound` config fields now support importing
`.ogg`, `.mp3`, and `.wav` files through the host picker. Imported sounds are
stored as opaque `{ kind: "user-sound", id, name }` refs; raw filesystem paths
are rejected by config validation. Near-term debt remains to split
`plugin-sdk-bridge.ts` by namespace, centralize preload/host route contracts,
extract the user-sound store from host capabilities, and add parity tests for
renderer preload routes.
## Permissions (v3)
@ -72,7 +80,7 @@ No signing tier (deliberate — see `docs/superplugins.md` §15).
Types: `packages/sdk/src/index.ts` (`@open-pets/plugin-sdk`, v3). The
namespaces on `ctx`: `pets`, `pet` (alias of `pets.default`), `ui` (bubbles,
toast, panel, dynamic menu), `audio`, `events`, `assets`, `bus`, `schedule`
alert, toast, panel, dynamic menu), `audio`, `events`, `assets`, `bus`, `schedule`
(`once`/`every`/`daily`/`cron`/`at`/`list`), `storage` (now with `keys` +
`subscribe`, ~5 MB quota), `config`, `net` (`fetch` with non-GET +
`stream`), `notify`, `ai`, `secrets`, `voice`, `auth`, `files`, `system`,
@ -81,6 +89,18 @@ toast, panel, dynamic menu), `audio`, `events`, `assets`, `bus`, `schedule`
Hard lines kept regardless of permissions:
- The render rule above (no raw markup into pet windows).
- The privacy line (§3.1): no keystrokes, no screen contents, no other apps'
- `ctx.ui.alert(...)` is the must-not-miss delivery helper: it renders a sticky,
high-priority pet bubble and can optionally request `sound`, `notify`, actions,
`dismissOn`, and rich bubble content (`text`, limited `markdown`, `icon`, `svg`,
`image`, `tone`). Alerts require `pet:speak`; `pet:interact` is only needed for
actions/input, `audio` only when `sound` is set, and `notify` only when `notify`
is set. The returned handle behaves like a bubble handle and adds
`acknowledge()`.
- Config schemas may use `type: "sound"` for host-managed plugin sound
preferences. The saved value is a named host sound, an opaque user sound ref,
or empty; plugins never receive raw filesystem paths.
- The privacy line (§3.1): no keystrokes, no screen contents, no other apps'
window titles, no ambient clipboard/microphone/filesystem. Clipboard read is
allowed only *inside a user-invoked command handler*; STT is one-shot
@ -90,6 +110,32 @@ Hard lines kept regardless of permissions:
Quotas live in `pluginSdkQuotas` (`plugin-sdk-bridge.ts`).
## Plugin i18n
A plugin ships its translations as `locales/<locale>.json` — one file per
supported locale, the same convention as the host catalog: a flat map of dotted
keys to strings, with `{var}` interpolation. `locales/en.json` is the source and
the fallback; missing locales (or missing keys within a locale) fall back to
`en`, then to the raw key. The host packages and loads any present `locales/`;
no file is required.
Two ways to use those keys:
- **`$t:key` references in manifest static fields** — wherever the host renders
a plugin-authored string at display time: `name`, `description`, `configSchema`
labels/descriptions/option labels, command titles/descriptions, and dynamic
menu item titles. Write the value as `"$t:plugin.name"`; the host resolves it
against the plugin's catalog for the active locale (→ plugin `en` → raw key) at
display time, so labels re-render translated when the user switches language.
- **`ctx.t(key, vars?)` + `ctx.locale`** — for strings the plugin composes at
runtime (bubble / notify / status bodies with interpolation). `ctx.t` reads the
active locale live and interpolates `{var}` placeholders; `ctx.locale` is the
current locale string. Example: `ctx.t("reminder.due", { message })`.
Keep placeholders intact across locales and leave brand names untranslated. The
reference implementation is the `openpets.reminders` ("Quick Reminders") default
plugin, mirrored by the CLI `reminder` template.
## Bubbles & the arbiter
`ctx.ui.bubble(spec)` / `pet.speak(spec)` accept a string or a descriptor

View file

@ -446,6 +446,14 @@ export function scaffoldPlugin(options: PluginNewOptions): { readonly dir: strin
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
writeFileSync(entryPath, template.entry(templateContext), { encoding: "utf8", flag: "wx" });
writeFileSync(join(targetDir, "test.js"), template.test(templateContext), { encoding: "utf8", flag: "wx" });
// Templates that localize host-rendered strings ($t:) or runtime bodies
// (ctx.t) ship a source locales/en.json; the host loads locales/<locale>.json
// and falls back to en. Write it whenever the template declares one.
if (template.locales) {
const localesDir = join(targetDir, "locales");
mkdirSync(localesDir, { recursive: true });
writeFileSync(join(localesDir, "en.json"), `${JSON.stringify(template.locales(templateContext), null, 2)}\n`, { encoding: "utf8", flag: "wx" });
}
const packageJsonPath = join(targetDir, "package.json");
if (!existsSync(packageJsonPath)) {
writeFileSync(packageJsonPath, `${JSON.stringify({ name: slugifyPluginName(options.name) || "openpets-plugin", private: true, type: "module", scripts: { test: "node test.js" }, devDependencies: { "@open-pets/plugin-sdk": "^3.0.0" } }, null, 2)}\n`, { encoding: "utf8" });

View file

@ -16,6 +16,12 @@ export type PluginTemplate = {
readonly configSchema: Record<string, unknown>;
readonly entry: (ctx: PluginTemplateContext) => string;
readonly test: (ctx: PluginTemplateContext) => string;
/**
* Templates that localize host-rendered strings (`$t:` manifest refs) and
* runtime-composed bodies (`ctx.t(...)`) ship a source `locales/en.json`.
* Returns the flat dotted key map; the scaffolder writes it verbatim.
*/
readonly locales?: (ctx: PluginTemplateContext) => Record<string, string>;
};
const sharedTestHeader = `import assert from "node:assert/strict";
@ -59,70 +65,372 @@ console.log("blank template tests passed.");
},
reminder: {
description: "One-shot reminders with a form, notifications, and cron routines.",
permissions: ["pet:speak", "pet:reaction", "commands", "status", "schedule", "storage", "notify"],
description:
"Quick local reminders delivered with ctx.ui.alert: sound, a sticky bubble you can snooze, and optional notification. Fully localized via $t: + ctx.t().",
permissions: ["pet:speak", "pet:interact", "audio", "schedule", "storage", "commands", "status", "notify"],
configSchema: {
morningSummary: { type: "boolean", label: "Morning summary", default: false },
soundEnabled: { type: "boolean", label: "$t:config.soundEnabled.label", description: "$t:config.soundEnabled.description", default: true },
osNotification: { type: "boolean", label: "$t:config.osNotification.label", description: "$t:config.osNotification.description", default: true },
customSound: { type: "sound", label: "$t:config.customSound.label", description: "$t:config.customSound.description" },
},
entry: ({ name }) => `/// <reference types="@open-pets/plugin-sdk" />
entry: () => `/// <reference types="@open-pets/plugin-sdk" />
//
// Quick reminders that mirror the shipped openpets.reminders reference plugin.
// Keeps a "Set reminder…" form plus 15/30/60-minute presets, but delivers with
// the acknowledge pattern: ctx.ui.alert(...) with Done / Snooze 5m actions,
// optional custom sound, and optional OS notification. Host-rendered
// static strings use $t: manifest refs; every runtime-composed body flows
// through ctx.t(key, vars) so the host can localize it.
export const MAX_REMINDERS = 10;
export const MAX_MESSAGE_LENGTH = 140;
export const MAX_DELAY_MS = 24 * 60 * 60 * 1000;
export const SNOOZE_MS = 5 * 60 * 1000;
export function cleanMessage(value, fallback = "Reminder time.") {
const text =
typeof value === "string"
? value.trim().replace(/[\\r\\n]+/g, " ").replace(/\\s+/g, " ")
: "";
return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback;
}
export function durationMs(values = {}) {
const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0))));
const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0))));
const ms = (hours * 60 + minutes) * 60_000;
if (ms < 60_000 || ms > MAX_DELAY_MS) {
throw new Error("Reminder duration must be 1 minute to 24 hours.");
}
return ms;
}
export async function getReminders(ctx) {
const reminders = await ctx.storage.get("reminders");
return Array.isArray(reminders)
? reminders
.filter(
(r) =>
r &&
typeof r.id === "string" &&
typeof r.dueAt === "number" &&
typeof r.message === "string",
)
.slice(0, MAX_REMINDERS)
: [];
}
async function saveReminders(ctx, reminders) {
const list = reminders.slice(0, MAX_REMINDERS);
await ctx.storage.set("reminders", list);
await updateStatus(ctx, list.length);
return list;
}
async function updateStatus(ctx, count) {
const text = count > 0 ? ctx.t("status.active", { count }) : ctx.t("status.none");
await ctx.status.set({ text, tone: "info" });
}
export async function scheduleReminder(ctx, reminder) {
const delay = Math.max(1, reminder.dueAt - Date.now());
await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id));
}
export async function addReminder(ctx, message, delayMs) {
const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now());
if (reminders.length >= MAX_REMINDERS) {
throw new Error(ctx.t("error.tooMany", { max: MAX_REMINDERS }));
}
const reminder = {
id: \`reminder-\${Date.now().toString(36)}-\${Math.floor(Math.random() * 1e6).toString(36)}\`.slice(0, 64),
message: cleanMessage(message, ctx.t("reminder.defaultMessage")),
dueAt: Date.now() + delayMs,
};
reminders.push(reminder);
await saveReminders(ctx, reminders);
await scheduleReminder(ctx, reminder);
await ctx.pet.speak(ctx.t("speech.set", { minutes: Math.max(1, Math.round(delayMs / 60_000)) }));
return reminder;
}
async function deliver(ctx, message, { missed = false } = {}) {
const config = (await ctx.config.get()) ?? {};
const soundEnabled = config.soundEnabled !== false;
const osNotification = config.osNotification !== false;
const text = missed ? ctx.t("bubble.missed", { message }) : ctx.t("bubble.due", { message });
let alert;
try {
alert = await ctx.ui.alert({
text,
icon: "bell",
tone: "info",
sound: soundEnabled ? config.customSound || "alert" : undefined,
notify: osNotification
? { title: ctx.t("notify.title"), body: missed ? ctx.t("notify.bodyMissed", { message }) : message }
: undefined,
dismissOn: ["petClick", "click", "action"],
actions: [
{ id: "done", label: ctx.t("action.done"), style: "primary" },
{ id: "snooze", label: ctx.t("action.snooze") },
],
});
} catch {
try {
await ctx.pet.speak(text);
} catch {
// last resort already attempted.
}
}
if (alert) {
alert.onAction(async (actionId) => {
if (actionId === "snooze") {
await addReminder(ctx, message, SNOOZE_MS);
}
});
}
}
export async function fireReminder(ctx, id) {
const reminders = await getReminders(ctx);
const item = reminders.find((r) => r.id === id);
await saveReminders(ctx, reminders.filter((r) => r.id !== id));
if (!item) return false;
await deliver(ctx, item.message);
return true;
}
export async function reconcile(ctx) {
await ctx.schedule.cancelAll();
const now = Date.now();
const reminders = await getReminders(ctx);
const future = reminders.filter((r) => r.dueAt > now);
const overdue = reminders.filter((r) => r.dueAt <= now);
await saveReminders(ctx, future);
for (const item of future) await scheduleReminder(ctx, item);
for (const item of overdue) await deliver(ctx, item.message, { missed: true });
}
async function showReminderList(ctx) {
const reminders = await getReminders(ctx);
const now = Date.now();
const pending = reminders.filter((r) => r.dueAt > now);
if (!pending.length) {
await ctx.pet.speak(ctx.t("speech.none"));
await ctx.ui.menu.setItems([]);
return;
}
await ctx.ui.menu.setItems(
pending.slice(0, MAX_REMINDERS).map((reminder) => ({
id: \`cancel:\${reminder.id}\`.slice(0, 64),
title: ctx.t("menu.item", {
minutes: Math.max(1, Math.ceil((reminder.dueAt - now) / 60_000)),
message: reminder.message,
}),
icon: "bell",
onSelect: async () => {
await ctx.schedule.cancel(reminder.id);
const remaining = (await getReminders(ctx)).filter((r) => r.id !== reminder.id);
await saveReminders(ctx, remaining);
await ctx.pet.speak(ctx.t("speech.cancelled", { message: reminder.message }));
await showReminderList(ctx);
},
})),
);
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await ctx.status.set({ text: "Reminders ready", tone: "info" });
await reconcile(ctx);
await ctx.commands.register(
{
id: "remind-me",
title: "Remind me…",
description: "Set a one-shot reminder.",
id: "set-reminder",
title: "$t:command.setReminder.title",
description: "$t:command.setReminder.description",
form: {
submitLabel: "$t:command.setReminder.submit",
fields: [
{ id: "message", type: "text", label: "Reminder", maxLength: 120, required: true },
{ id: "minutes", type: "number", label: "In minutes", default: 10, min: 1, max: 720 },
{ id: "message", type: "textarea", label: "$t:form.message.label", required: true, maxLength: MAX_MESSAGE_LENGTH },
{ id: "hours", type: "number", label: "$t:form.hours.label", default: 0, min: 0, max: 23 },
{ id: "minutes", type: "number", label: "$t:form.minutes.label", default: 15, min: 0, max: 59 },
],
submitLabel: "Set reminder",
},
},
async (values) => {
const minutes = Number(values?.minutes ?? 10);
const message = String(values?.message ?? "Reminder");
const id = "reminder-" + Math.random().toString(36).slice(2, 8);
await ctx.schedule.once(id, minutes * 60_000, async () => {
await ctx.notify.notify({ title: ${JSON.stringify(name)}, body: message });
const bubble = await ctx.pet.speak({ text: message, sticky: true, icon: "bell", actions: [{ id: "ok", label: "Done", style: "primary" }] });
bubble.onAction(() => bubble.dismiss());
await ctx.pet.react("waving");
});
await ctx.pet.speak("Reminder set.");
},
async (values) => addReminder(ctx, values.message, durationMs(values)),
);
const config = await ctx.config.get();
if (config.morningSummary) {
await ctx.schedule.cron("morning-summary", "0 9 * * 1-5", async () => {
await ctx.pet.speak("Good morning. Ready when you are.");
});
}
await ctx.commands.register(
{ id: "reminder-15", title: "$t:command.reminder15.title", description: "$t:command.reminder15.description" },
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 15 * 60_000),
);
await ctx.commands.register(
{ id: "reminder-30", title: "$t:command.reminder30.title", description: "$t:command.reminder30.description" },
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 30 * 60_000),
);
await ctx.commands.register(
{ id: "reminder-60", title: "$t:command.reminder60.title", description: "$t:command.reminder60.description" },
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 60 * 60_000),
);
await ctx.commands.register(
{ id: "view-reminders", title: "$t:command.viewReminders.title", description: "$t:command.viewReminders.description" },
() => showReminderList(ctx),
);
await ctx.commands.register(
{ id: "clear-reminders", title: "$t:command.clearReminders.title", description: "$t:command.clearReminders.description" },
async () => {
await ctx.schedule.cancelAll();
await saveReminders(ctx, []);
await ctx.ui.menu.setItems([]);
await ctx.pet.speak(ctx.t("speech.cleared"));
},
);
},
async stop() {},
});
}
`,
test: () => `${sharedTestHeader}
const h = createTestHarness(register, {
permissions: ["pet:speak", "pet:reaction", "commands", "status", "schedule", "storage", "notify", "pet:interact"],
config: { morningSummary: true },
});
await h.start();
h.expectScheduled("morning-summary");
await h.runCommand("remind-me", { message: "Stretch!", minutes: 5 });
h.expectSpoke(/reminder set/i);
await h.clock.advance("5m");
h.expectNotified(/stretch/i);
h.expectSpoke(/stretch/i);
h.expectNoErrors();
test: () => `import assert from "node:assert/strict";
import { createTestHarness } from "@open-pets/plugin-sdk/testing";
import { register, cleanMessage, durationMs, MAX_REMINDERS } from "./index.js";
const PERMISSIONS = [
"pet:speak",
"pet:interact",
"audio",
"schedule",
"storage",
"commands",
"status",
"notify",
];
const LOCALES = {
en: JSON.parse(
await (await import("node:fs/promises")).readFile(new URL("./locales/en.json", import.meta.url), "utf8"),
),
};
// --- pure helper unit checks --------------------------------------------
assert.equal(cleanMessage(" hello\\nthere "), "hello there");
assert.equal(cleanMessage("", "fallback"), "fallback");
assert.equal(durationMs({ hours: 1, minutes: 30 }), 90 * 60_000);
assert.throws(() => durationMs({ hours: 0, minutes: 0 }));
assert.equal(MAX_REMINDERS, 10);
// 1) Setting a reminder via the form schedules it, then fires with the
// acknowledge pattern (sound + bubble + notification).
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
config: { soundEnabled: true, osNotification: true, customSound: "gong" },
locales: LOCALES,
nowMs: 1_000_000,
});
await h.start();
await h.runCommand("set-reminder", { message: "Drink water", hours: 0, minutes: 30 });
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 1 && v[0].message === "Drink water");
await h.clock.advance("31m");
h.expectBubble({ icon: "bell", tone: "info", sticky: true, priority: "high" });
h.expectBubble({ textMatch: /Drink water/ });
h.expectNotified(/Drink water/);
assert.equal(h.calls.alerts.length, 1, "expected ctx.ui.alert delivery");
assert.ok(h.calls.sounds.some((s) => s.sound === "gong"), "expected the custom alert sound to play");
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
h.expectNoErrors();
}
// 2) A preset fires and the Snooze action reschedules +5m.
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
config: { soundEnabled: false, osNotification: false },
locales: LOCALES,
nowMs: 2_000_000,
});
await h.start();
await h.runCommand("reminder-15");
h.expectStored("reminders", (v) => v.length === 1);
await h.clock.advance("16m");
const bubble = h.calls.bubbles[h.calls.bubbles.length - 1];
assert.deepEqual(bubble.spec.actions?.map((a) => a.id), ["done", "snooze"]);
await h.fireBubbleAction(bubble.handle.id, "snooze");
h.expectStored("reminders", (v) => v.length === 1 && v[0].dueAt > h.clock.now());
h.expectNoErrors();
}
// 3) clear-reminders cancels everything.
{
const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES });
await h.start();
await h.runCommand("reminder-15");
await h.runCommand("clear-reminders");
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
h.expectSpoke(/cleared/i);
h.expectNoErrors();
}
console.log("reminder template tests passed.");
`,
locales: () => ({
"config.soundEnabled.label": "Play a sound",
"config.soundEnabled.description": "Play an alert sound when a reminder is due.",
"config.osNotification.label": "Show a system notification",
"config.osNotification.description": "Also post a desktop notification when a reminder is due.",
"config.customSound.label": "Custom alert sound",
"config.customSound.description": "Optional sound to play instead of the default alert sound.",
"command.setReminder.title": "Set reminder…",
"command.setReminder.description": "Create a quick local reminder.",
"command.setReminder.submit": "Set Reminder",
"command.reminder15.title": "15 min reminder",
"command.reminder15.description": "Set a reminder for 15 minutes from now.",
"command.reminder30.title": "30 min reminder",
"command.reminder30.description": "Set a reminder for 30 minutes from now.",
"command.reminder60.title": "1 hour reminder",
"command.reminder60.description": "Set a reminder for 1 hour from now.",
"command.viewReminders.title": "View reminders",
"command.viewReminders.description": "List pending reminders and cancel any of them.",
"command.clearReminders.title": "Clear reminders",
"command.clearReminders.description": "Cancel all pending reminders.",
"form.message.label": "Message",
"form.hours.label": "Hours",
"form.minutes.label": "Minutes",
"reminder.defaultMessage": "Reminder time.",
"status.active": "{count} reminder(s) active",
"status.none": "No active reminders",
"speech.set": "Reminder set for {minutes} min from now.",
"speech.none": "No active reminders.",
"speech.cleared": "Reminders cleared.",
"speech.cancelled": "Cancelled: {message}",
"bubble.due": "{message}",
"bubble.missed": "Missed while away: {message}",
"action.done": "Done",
"action.snooze": "Snooze 5m",
"menu.item": "in {minutes} min: {message}",
"notify.title": "Quick Reminders",
"notify.bodyMissed": "Missed while away: {message}",
"error.tooMany": "Quick Reminders can keep up to {max} active reminders.",
}),
},
ambient: {

View file

@ -10,6 +10,7 @@ import type {
OpenPetsBubble,
OpenPetsBubbleHandle,
OpenPetsContext,
OpenPetsPickedFile,
OpenPetsPluginDefinition,
OpenPetsStatus,
} from "./index.js";
@ -39,6 +40,10 @@ const plugin: OpenPetsPluginDefinition = {
await ctx.status.set({ text: "Ready", tone: "info" });
await ctx.pet.speak("Hello!");
await ctx.pet.react("waving");
await ctx.pet.setStatusReaction("thinking");
const alert = await ctx.ui.alert({ text: "Heads up", markdown: "**Check complete**", icon: "bell", tone: "info", sound: "alert" });
alert.onAction(() => undefined);
await alert.acknowledge();
const bubble: OpenPetsBubbleHandle = await ctx.ui.bubble({
text: "Break in 5:00",
sticky: true,
@ -58,26 +63,39 @@ const plugin: OpenPetsPluginDefinition = {
});
ctx.storage.subscribe("lastTick", () => undefined);
await ctx.bus.publish("sample/mood", { mood: "happy" });
const picked: OpenPetsPickedFile = (await ctx.files.pick({ accept: ["audio/*"] }))[0]!;
const sound = await ctx.audio.importUserSound(picked, { name: "Bell" });
await ctx.audio.play(sound);
await ctx.audio.forgetUserSound(sound);
await ctx.commands.register({ id: "greet", title: "Greet" }, async () => {
await ctx.pet.speak("Hi again!");
});
// i18n surface: ctx.locale (active host locale) + ctx.t (runtime translation).
await ctx.status.set({ text: `${ctx.locale}:${ctx.t("greeting", { name: "Pet" })}` });
},
};
const { ctx, calls, harness } = createMockContext();
harness.files.provide([{ name: "bell.wav", bytes: new Uint8Array([1, 2, 3]) }]);
await plugin.start(ctx);
assert.deepEqual(calls.speak, ["Hello!", "Break in 5:00"]);
assert.deepEqual(calls.speak, ["Hello!", "Heads up", "Break in 5:00"]);
assert.deepEqual(calls.react, ["waving"]);
assert.equal(calls.status.length, 1);
assert.deepEqual(calls.statusReactions, ["thinking"]);
assert.equal(calls.status.length, 2);
assert.ok(calls.schedules.has("tick"));
assert.ok(calls.schedules.has("daily-summary"));
assert.ok(calls.commands.has("greet"));
assert.equal(calls.bubbles.length, 2, "speak + ui.bubble both produce bubbles");
assert.equal(calls.bubbles.length, 3, "speak + ui.alert + ui.bubble all produce bubbles");
assert.equal(calls.alerts.length, 1);
assert.equal(calls.alerts[0]!.acknowledged, true);
assert.equal(calls.sounds[0]!.sound, "alert");
assert.equal(calls.busPublishes.length, 1);
assert.equal(calls.importedUserSounds.length, 1);
assert.equal(calls.forgottenUserSounds.length, 1);
// Bubble interactions round-trip.
const live = calls.bubbles[1]!;
const live = calls.bubbles[2]!;
assert.equal(live.spec.sticky, true);
await harness.fireBubbleAction(live.handle.id, "done");
assert.ok(calls.dismissedBubbles.includes(live.handle.id), "onAction('done') dismissed the bubble");
@ -91,9 +109,23 @@ await harness.emit("pet:clicked", { petId: "default" });
assert.deepEqual(calls.react, ["waving", "celebrating"]);
await calls.commands.get("greet")?.handler();
assert.deepEqual(calls.speak, ["Hello!", "Break in 5:00", "Hi again!"]);
assert.deepEqual(calls.speak, ["Hello!", "Heads up", "Break in 5:00", "Hi again!"]);
const status: OpenPetsStatus = calls.status[0]!;
assert.ok(typeof status === "object" && status.text === "Ready");
// ctx.t / ctx.locale drift checks: the runtime bridge must expose both members.
assert.equal(ctx.locale, "en", "ctx.locale defaults to en");
assert.equal(ctx.t("missing.key", { name: "Pet" }), "missing.key", "ctx.t echoes unknown keys");
const i18nStatus = calls.status[1]!;
assert.ok(typeof i18nStatus === "object" && i18nStatus.text === "en:greeting", "no-catalog ctx.t echoes key, ctx.locale is en");
// A harness with catalogs resolves active locale -> en -> key, then interpolates.
const i18n = createMockContext({ locales: { en: { greeting: "Hi {name}" }, ja: { greeting: "やあ {name}" } } });
assert.equal(i18n.ctx.t("greeting", { name: "Pet" }), "Hi Pet", "default locale uses en catalog");
i18n.harness.system.set({ locale: "ja" });
assert.equal(i18n.ctx.locale, "ja", "ctx.locale follows system.set({ locale })");
assert.equal(i18n.ctx.t("greeting", { name: "Pet" }), "やあ Pet", "active locale catalog wins");
assert.equal(i18n.ctx.t("absent"), "absent", "unknown key echoes even with catalogs");
console.log("Plugin SDK contract tests passed.");

View file

@ -118,8 +118,15 @@ export interface OpenPetsAssetRef {
/** A named host icon (curated set) or a bundled icon asset reference. */
export type OpenPetsIconRef = string | OpenPetsAssetRef;
/** A named host sound (curated set) or a bundled sound asset reference. */
export type OpenPetsSoundRef = string | OpenPetsAssetRef;
/** A JSON-safe reference to a user-imported sound. */
export interface OpenPetsUserSoundRef {
readonly kind: "user-sound";
readonly id: string;
readonly name?: string;
}
/** A named host sound, bundled sound asset, or user-imported sound reference. */
export type OpenPetsSoundRef = string | OpenPetsAssetRef | OpenPetsUserSoundRef;
/** Resolve manifest-declared assets to opaque references. */
export interface OpenPetsAssetsApi {
@ -248,12 +255,31 @@ export interface OpenPetsPanelHandle {
close(): Promise<void>;
}
/**
* Must-not-miss pet alert. Alerts render as sticky high-priority bubbles and
* may also request best-effort sound and OS notification delivery.
*/
export interface OpenPetsAlert extends Omit<OpenPetsBubble, "sticky" | "priority"> {
/** Optional sound to play with the alert. Requires `audio` only when set. */
sound?: OpenPetsSoundRef;
/** Optional OS notification. Requires `notify` only when set. */
notify?: { title: string; body?: string; sound?: boolean };
}
/** Handle to a live alert bubble. */
export interface OpenPetsAlertHandle extends OpenPetsBubbleHandle {
/** Mark the alert acknowledged and dismiss it. */
acknowledge(): Promise<void>;
}
/** Bubbles, toasts, panels, and the dynamic context-menu section. */
export interface OpenPetsUiApi {
/** Show a bubble on the default pet. Requires `pet:speak`. */
bubble(spec: string | OpenPetsBubble): Promise<OpenPetsBubbleHandle>;
/** Show a transient host toast. Requires `ui:toast`. */
toast(spec: { text: string; tone?: OpenPetsStatusTone; durationMs?: number }): Promise<void>;
/** Show a sticky high-priority alert bubble. Requires `pet:speak`. */
alert(spec: OpenPetsAlert): Promise<OpenPetsAlertHandle>;
/** Open a sandboxed plugin webview panel. Requires `ui:panel`. */
panel(spec: OpenPetsPanelOptions): Promise<OpenPetsPanelHandle>;
/** Fully dynamic context-menu section. Requires `commands`. */
@ -278,6 +304,10 @@ export interface OpenPetsAudioApi {
* quiet hours.
*/
play(sound: OpenPetsSoundRef, options?: { volume?: number }): Promise<void>;
/** Import a user-picked sound into plugin-owned host storage. Requires `audio` and `files`. */
importUserSound(file: OpenPetsPickedFile, opts?: { name?: string }): Promise<OpenPetsUserSoundRef>;
/** Forget a previously imported user sound. Requires `audio`. */
forgetUserSound(ref: OpenPetsUserSoundRef): Promise<void>;
stop(handle?: string): Promise<void>;
}
@ -406,8 +436,8 @@ export interface OpenPetsPetHandle {
setAnimation(state: OpenPetsAnimationState): Promise<void>;
/** Bounded by the host (0.52). Requires `pet:animate`. */
setScale(scale: number): Promise<void>;
/** Show a status badge (or clear with null). Requires `pet:reaction`. */
badge(badge: OpenPetsReaction | null): Promise<void>;
/** Show a status reaction (or clear with null). Requires `pet:reaction`. */
setStatusReaction(reaction: OpenPetsReaction | null): Promise<void>;
/** Requires `pet:move`. Movement is bounded to the work area. */
moveBy(options: OpenPetsMoveByOptions): Promise<void>;
/** Requires `pet:move`. */
@ -852,6 +882,31 @@ export interface OpenPetsContext {
/** v2 alias of `net` (GET-only). */
http: OpenPetsHttpApi;
log: OpenPetsLogApi;
/**
* Translate a key against the plugin's own `locales/<locale>.json` catalogs
* for the active host locale, falling back to the plugin's `en` catalog and
* then to the raw key. `{var}` placeholders are interpolated from `vars`.
*
* Use this for strings the plugin *composes at runtime* (bubble/notify/status
* bodies). Static manifest strings (`name`, `description`, `configSchema`
* labels, command titles) should instead use the `$t:key` reference form,
* which the host resolves at display time.
*
* ```ts
* await ctx.ui.bubble({ text: ctx.t("reminder.fire", { message: "Stretch" }) })
* // locales/ja.json: { "reminder.fire": "リマインダー: {message}" } -> "リマインダー: Stretch"
* ```
*/
t: (key: string, vars?: Record<string, string | number>) => string;
/**
* The active host locale string (e.g. `"en"`, `"ja"`, `"pt-BR"`, `"zh-Hans"`).
* Read it to branch on language at runtime; `ctx.t` already follows it.
*
* ```ts
* if (ctx.locale.startsWith("ja")) { /* ... *\/ }
* ```
*/
readonly locale: string;
}
/** The object you pass to {@link OpenPetsPluginApi.register}. */
@ -890,6 +945,7 @@ export type OpenPetsConfigFieldType =
| "date"
| "list"
| "multiselect"
| "sound"
/** Masked input, encrypted at rest, never logged or echoed (v3). */
| "secret";

View file

@ -22,6 +22,8 @@ import type {
OpenPetsBubble,
OpenPetsBubbleDismissReason,
OpenPetsBubbleHandle,
OpenPetsAlert,
OpenPetsAlertHandle,
OpenPetsCommand,
OpenPetsCommandHandler,
OpenPetsContext,
@ -37,6 +39,7 @@ import type {
OpenPetsReaction,
OpenPetsScheduleHandler,
OpenPetsStatus,
OpenPetsUserSoundRef,
} from "./index.js";
// ---------------------------------------------------------------------------
@ -52,6 +55,15 @@ export interface RecordedBubble {
dismissed: boolean;
}
export interface RecordedAlert {
spec: OpenPetsAlert;
handle: OpenPetsAlertHandle;
bubble: RecordedBubble;
updates: Array<Partial<OpenPetsBubble>>;
dismissed: boolean;
acknowledged: boolean;
}
export interface RecordedSchedule {
type: "once" | "every" | "daily" | "cron" | "at";
handler: OpenPetsScheduleHandler;
@ -72,16 +84,20 @@ export interface RecordedNetCall {
export interface MockCalls {
speak: string[];
react: string[];
statusReactions: Array<string | null>;
status: OpenPetsStatus[];
storage: Map<string, unknown>;
schedules: Map<string, RecordedSchedule>;
commands: Map<string, { meta: OpenPetsCommand; handler: OpenPetsCommandHandler }>;
menuItems: OpenPetsMenuItem[];
bubbles: RecordedBubble[];
alerts: RecordedAlert[];
dismissedBubbles: string[];
toasts: Array<{ text: string; tone?: string }>;
notifications: Array<{ title: string; body?: string }>;
sounds: Array<{ sound: unknown; volume?: number }>;
importedUserSounds: Array<{ ref: OpenPetsUserSoundRef; fileName: string; name?: string }>;
forgottenUserSounds: OpenPetsUserSoundRef[];
busPublishes: Array<{ topic: string; payload: unknown }>;
netCalls: RecordedNetCall[];
aiCalls: Array<{ system?: string; messages: Array<{ role: string; content: string }> }>;
@ -95,6 +111,12 @@ export interface MockCalls {
errors: string[];
}
const allowedReactions = new Set<string>(["idle", "thinking", "working", "editing", "running", "testing", "waiting", "waving", "success", "error", "celebrating"]);
function assertReaction(value: unknown): void {
if (typeof value !== "string" || !allowedReactions.has(value)) throw new Error("Invalid pet reaction.");
}
export interface MockContextOptions {
/** Approved permissions. Omit to approve everything (legacy default). */
permissions?: readonly OpenPetsPermission[];
@ -106,6 +128,19 @@ export interface MockContextOptions {
* harness clock; pin it explicitly for fully reproducible cron/daily tests.
*/
nowMs?: number;
/**
* Optional in-memory plugin catalogs keyed by locale, mirroring the host's
* `locales/<locale>.json` files. When provided, `ctx.t(key, vars)` resolves
* against the active locale's catalog, then the `en` catalog, then echoes the
* key. When omitted, `ctx.t` simply echoes the key with `{vars}` interpolated.
*
* ```ts
* createTestHarness(register, {
* locales: { en: { "greet": "Hi {name}" }, ja: { "greet": "やあ {name}" } },
* })
* ```
*/
locales?: Record<string, Record<string, string>>;
}
// ---------------------------------------------------------------------------
@ -156,6 +191,12 @@ export class FakeClock {
}
}
/** Replace `{name}` placeholders, mirroring the host catalog's `interpolate`. */
function interpolateVars(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in vars ? String(vars[key]) : whole));
}
function parseDuration(amount: number | string): number {
if (typeof amount === "number") return amount;
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(amount.trim());
@ -253,8 +294,8 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
const approved = options.permissions === undefined ? null : new Set(options.permissions);
let config = { ...(options.config ?? {}) };
const calls: MockCalls = {
speak: [], react: [], status: [], storage: new Map(), schedules: new Map(), commands: new Map(), menuItems: [],
bubbles: [], dismissedBubbles: [], toasts: [], notifications: [], sounds: [], busPublishes: [], netCalls: [],
speak: [], react: [], statusReactions: [], status: [], storage: new Map(), schedules: new Map(), commands: new Map(), menuItems: [],
bubbles: [], alerts: [], dismissedBubbles: [], toasts: [], notifications: [], sounds: [], importedUserSounds: [], forgottenUserSounds: [], busPublishes: [], netCalls: [],
aiCalls: [], voiceSpeaks: [], openedExternal: [], clipboardWrites: [], spawnedPets: [], panelMessages: [],
savedFiles: [], secrets: new Map(), errors: [],
};
@ -275,6 +316,24 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
let nextId = 0;
const newId = (prefix: string) => `${prefix}-${++nextId}`;
// ---- i18n: ctx.t / ctx.locale ----
// `ctx.locale` defaults to "en" and follows `harness.system.set({ locale })`.
// `ctx.t` resolves an optional in-memory catalog (active locale -> exact, then
// language prefix, then `en`), then echoes the key, finally interpolating {vars}.
const localeCatalogs = options.locales;
const activeLocale = (): string => {
const raw = systemInfo.locale;
return raw && raw !== "en-US" ? raw : "en";
};
const lookupCatalog = (key: string): string | undefined => {
if (!localeCatalogs) return undefined;
const locale = activeLocale();
const lang = locale.split(/[-_]/)[0]!;
return localeCatalogs[locale]?.[key] ?? localeCatalogs[lang]?.[key] ?? localeCatalogs.en?.[key];
};
const translate = (key: string, vars?: Record<string, string | number>): string =>
interpolateVars(lookupCatalog(key) ?? key, vars);
const requirePermission = (permission: OpenPetsPermission): void => {
if (approved !== null && !approved.has(permission)) throw new Error(`Plugin permission is not approved: ${permission}`);
};
@ -311,6 +370,41 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
};
const bubbleCallbacks = new Map<string, { record: RecordedBubble; callbacks: { onAction?: (actionId: string) => void | Promise<void>; onSubmit?: (values: Record<string, string | number>) => void | Promise<void>; onDismiss?: (reason: OpenPetsBubbleDismissReason) => void } }>();
const makeAlert = (spec: OpenPetsAlert): OpenPetsAlertHandle => {
if (spec.sound !== undefined) requirePermission("audio");
if (spec.notify !== undefined) requirePermission("notify");
const { sound, notify, ...bubbleSpec } = spec;
const bubble = makeBubble("default", {
...bubbleSpec,
sticky: true,
priority: "high",
});
const bubbleRecord = calls.bubbles[calls.bubbles.length - 1]!;
if (sound !== undefined) calls.sounds.push({ sound });
if (notify !== undefined) calls.notifications.push({ title: notify.title, body: notify.body });
const id = newId("alert");
const record: RecordedAlert = {
spec: { ...spec, actions: spec.actions?.map((action) => ({ ...action })) },
bubble: bubbleRecord,
updates: [],
dismissed: false,
acknowledged: false,
handle: {
id,
update: async (patch) => { record.updates.push(patch); Object.assign(record.spec, patch); await bubble.update(patch); },
dismiss: async () => { if (!record.dismissed) { record.dismissed = true; await bubble.dismiss(); } },
pin: async () => bubble.pin(),
unpin: async () => bubble.unpin(),
onAction: (handler) => { bubble.onAction(handler); },
onSubmit: (handler) => { bubble.onSubmit(handler); },
onDismiss: (handler) => { bubble.onDismiss(handler); },
acknowledge: async () => { record.acknowledged = true; await record.handle.dismiss(); },
},
};
calls.alerts.push(record);
return record.handle;
};
const petInfos: OpenPetsPetInfo[] = [{ id: "default", name: "Default pet", kind: "default", visible: true }];
const petState: OpenPetsPetState = { position: { x: 100, y: 100 }, bounds: { x: 100, y: 100, width: 220, height: 240 }, currentAnimation: "idle", visible: true, dragging: false };
const tickHandlers = new Set<(dtMs: number) => void>();
@ -318,10 +412,10 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
const makePetHandle = (petId: string): OpenPetsPetHandle => ({
id: petId,
speak: async (spec) => makeBubble(petId, spec),
react: async (reaction: OpenPetsReaction) => { requirePermission("pet:reaction"); calls.react.push(String(reaction)); },
react: async (reaction: OpenPetsReaction) => { requirePermission("pet:reaction"); assertReaction(reaction); calls.react.push(String(reaction)); },
setAnimation: async (state) => { if (typeof state === "string") { requirePermission("pet:reaction"); calls.react.push(String(state)); } else { requirePermission("pet:animate"); petState.currentAnimation = `sprite:${state.sprite.name}`; } },
setScale: async () => { requirePermission("pet:animate"); },
badge: async () => { requirePermission("pet:reaction"); },
setStatusReaction: async (reaction) => { requirePermission("pet:reaction"); if (reaction !== null) assertReaction(reaction); calls.statusReactions.push(reaction === null ? null : String(reaction)); },
moveBy: async () => { requirePermission("pet:move"); },
wander: async () => { requirePermission("pet:move"); },
moveToHome: async () => { requirePermission("pet:move"); },
@ -349,6 +443,7 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
ui: {
bubble: async (spec) => makeBubble("default", spec),
toast: async (spec) => { requirePermission("ui:toast"); calls.toasts.push({ text: spec.text, tone: spec.tone }); },
alert: async (spec) => makeAlert(spec),
panel: async () => {
requirePermission("ui:panel");
const id = newId("panel");
@ -368,6 +463,14 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
},
audio: {
play: async (sound, options) => { requirePermission("audio"); calls.sounds.push({ sound, volume: options?.volume }); },
importUserSound: async (file, opts) => {
requirePermission("audio");
requirePermission("files");
const ref: OpenPetsUserSoundRef = { kind: "user-sound", id: newId("sound"), name: opts?.name ?? file.name };
calls.importedUserSounds.push({ ref, fileName: file.name, name: opts?.name });
return ref;
},
forgetUserSound: async (ref) => { requirePermission("audio"); calls.forgottenUserSounds.push(ref); },
stop: async () => { requirePermission("audio"); },
},
events: {
@ -523,6 +626,8 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
warn: async () => undefined,
error: async () => undefined,
},
t: (key, vars) => translate(key, vars),
get locale() { return activeLocale(); },
};
const harness: MockHarnessCore = {
@ -550,7 +655,7 @@ export function createMockContext(optionsOrConfig: MockContextOptions | Record<s
}
function isMockOptions(value: MockContextOptions | Record<string, unknown>): value is MockContextOptions {
return "permissions" in value || "config" in value || "nowMs" in value;
return "permissions" in value || "config" in value || "nowMs" in value || "locales" in value;
}
// ---------------------------------------------------------------------------

View file

@ -1,75 +0,0 @@
export const MAX_MESSAGE_LENGTH = 140;
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
export const FREQUENCY_MINUTES = { low: 240, normal: 150, lively: 90 };
export const COZY_MESSAGES = ["Still here.", "I am keeping watch.", "Nice and quiet.", "Tiny stretch?", "You have been at it a while."];
export function safeText(value, fallback = "Still here.") {
const text = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = text.length > MAX_MESSAGE_LENGTH ? text.slice(0, MAX_MESSAGE_LENGTH).trim() : text;
return !capped || UNSAFE_MESSAGE_PATTERN.test(capped) ? fallback : capped;
}
export function normalizeTime(value, fallback) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value ?? ""));
if (!match) return fallback;
const h = Number(match[1]);
const m = Number(match[2]);
return h >= 0 && h <= 23 && m >= 0 && m <= 59 ? `${match[1]}:${match[2]}` : fallback;
}
export function normalizeConfig(config = {}) {
return {
frequency: Object.hasOwn(FREQUENCY_MINUTES, config.frequency) ? config.frequency : "low",
quietHoursEnabled: config.quietHoursEnabled !== false,
quietStart: normalizeTime(config.quietStart, "22:00"),
quietEnd: normalizeTime(config.quietEnd, "08:00"),
greetingsEnabled: config.greetingsEnabled !== false,
};
}
export function isQuietNow(config, now = new Date()) {
if (!config.quietHoursEnabled) return false;
const current = now.getHours() * 60 + now.getMinutes();
const start = Number(config.quietStart.slice(0, 2)) * 60 + Number(config.quietStart.slice(3));
const end = Number(config.quietEnd.slice(0, 2)) * 60 + Number(config.quietEnd.slice(3));
return start <= end ? current >= start && current < end : current >= start || current < end;
}
export function greetingFor(now = new Date()) {
const hour = now.getHours();
if (hour < 12) return "Good morning.";
if (hour < 18) return "Good afternoon.";
if (hour < 22) return "Good evening.";
return "Late session? I am keeping watch.";
}
export function pickMessage(random = Math.random) {
return COZY_MESSAGES[Math.min(COZY_MESSAGES.length - 1, Math.floor(random() * COZY_MESSAGES.length))];
}
export async function speakCozy(ctx, config = normalizeConfig(), message = pickMessage()) {
if (isQuietNow(config)) return false;
await ctx.pet.speak(safeText(message));
await ctx.pet.react("waving");
await ctx.storage.set("lastAmbientMessageAt", new Date().toISOString());
return true;
}
export async function reschedule(ctx, config = normalizeConfig()) {
await ctx.schedule.cancelAll();
const minutes = FREQUENCY_MINUTES[config.frequency] || FREQUENCY_MINUTES.low;
await ctx.schedule.every("ambient-cozy-message", minutes * 60_000, () => speakCozy(ctx, config));
await ctx.status.set({ text: `Ambient companion ${config.frequency}`, tone: "info" });
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
const config = normalizeConfig(await ctx.config.get());
await reschedule(ctx, config);
if (config.greetingsEnabled && !isQuietNow(config)) await speakCozy(ctx, config, greetingFor());
ctx.config.onChange?.(async (next) => reschedule(ctx, normalizeConfig(next)));
},
async stop() {}
});
}

View file

@ -1,19 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.ambient-companion",
"name": "Ambient Companion",
"description": "Adds gentle greetings and occasional background check-ins so your pet feels present while you work.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "sparkles",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "status"],
"configSchema": {
"frequency": { "type": "select", "label": "Message frequency", "default": "low", "options": [{ "label": "Low", "value": "low" }, { "label": "Normal", "value": "normal" }, { "label": "Lively", "value": "lively" }] },
"greetingsEnabled": { "type": "boolean", "label": "Time-of-day greetings", "default": true },
"quietHoursEnabled": { "type": "boolean", "label": "Quiet hours", "default": true },
"quietStart": { "type": "time", "label": "Quiet start", "default": "22:00" },
"quietEnd": { "type": "time", "label": "Quiet end", "default": "08:00" }
}
}

View file

@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { FREQUENCY_MINUTES, greetingFor, isQuietNow, normalizeConfig, pickMessage, register, reschedule, safeText, speakCozy } from "./index.js";
assert.equal(safeText("hello\nthere"), "hello there");
assert.equal(safeText("https://example.test"), "Still here.");
assert.equal(normalizeConfig({ frequency: "wild", quietStart: "bad" }).frequency, "low");
assert.equal(isQuietNow(normalizeConfig({}), new Date("2024-01-01T23:00:00")), true);
assert.equal(greetingFor(new Date("2024-01-01T09:00:00")), "Good morning.");
assert.equal(pickMessage(() => 0), "Still here.");
function createCtx(config = {}) {
const calls = { speak: [], react: [], set: [], every: [], cancelAll: 0 };
return { calls, ctx: {
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) },
storage: { set: async (...v) => calls.set.push(v), get: async () => undefined },
schedule: { cancelAll: async () => calls.cancelAll++, every: async (id, interval, fn) => calls.every.push({ id, interval, fn }) },
status: { set: async (v) => calls.set.push(v) },
config: { get: async () => config, onChange: () => {} },
}};
}
{
const h = createCtx({ frequency: "normal" });
await reschedule(h.ctx, normalizeConfig(await h.ctx.config.get()));
assert.equal(h.calls.every[0].interval, FREQUENCY_MINUTES.normal * 60_000);
}
{
const h = createCtx();
await speakCozy(h.ctx, { ...normalizeConfig(), quietHoursEnabled: false }, "Nice and quiet.");
assert.equal(h.calls.speak[0], "Nice and quiet.");
}
{
const h = createCtx({ greetingsEnabled: false });
const plugin = { register(def) { this.def = def; } };
register(plugin);
await plugin.def.start(h.ctx);
assert.equal(h.calls.every.length, 1);
assert.equal(h.calls.speak.length, 0);
}
console.log("Ambient Companion plugin tests passed.");

View file

@ -1,136 +0,0 @@
export const MAX_MESSAGE_LENGTH = 140;
export const DEFAULT_MESSAGE = "Rest your eyes for a moment.";
export const DEFAULT_SNOOZE_MINUTES = 15;
export const MAX_ID_LENGTH = 64;
export const VALID_REACTIONS = ["waving", "waiting", "success", "celebrating"];
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
export const DEFAULT_BREAKS = [
{ id: "eye-rest", enabled: true, message: "Rest your eyes for a moment.", reaction: "waiting", intervalMinutes: 50 },
{ id: "tiny-stretch", enabled: true, message: "Tiny stretch break.", reaction: "waving", intervalMinutes: 90 },
{ id: "water-check", enabled: false, message: "Water check.", reaction: "success", intervalMinutes: 150 },
];
export function cleanText(value, fallback = DEFAULT_MESSAGE) {
const text = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = text.length > MAX_MESSAGE_LENGTH ? text.slice(0, MAX_MESSAGE_LENGTH).trim() : text;
return !capped || UNSAFE_MESSAGE_PATTERN.test(capped) ? fallback : capped;
}
export function clampMinutes(value, fallback, min = 10, max = 1440) {
const n = Number(value);
return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback;
}
export function sanitizeId(value, index = 0) {
const raw = typeof value === "string" && value.trim() ? value.trim() : `break-${index + 1}`;
return raw.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, MAX_ID_LENGTH) || `break-${index + 1}`;
}
export function normalizeTime(value, fallback) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value ?? ""));
if (!match) return fallback;
const h = Number(match[1]);
const m = Number(match[2]);
return h >= 0 && h <= 23 && m >= 0 && m <= 59 ? `${match[1]}:${match[2]}` : fallback;
}
export function isQuietNow(config = {}, now = new Date()) {
if (config.quietHoursEnabled === false) return false;
const start = normalizeTime(config.quietStart, "22:00");
const end = normalizeTime(config.quietEnd, "08:00");
const current = now.getHours() * 60 + now.getMinutes();
const s = Number(start.slice(0, 2)) * 60 + Number(start.slice(3));
const e = Number(end.slice(0, 2)) * 60 + Number(end.slice(3));
return s <= e ? current >= s && current < e : current >= s || current < e;
}
export function normalizeBreak(value, index) {
const item = value && typeof value === "object" ? value : {};
return {
id: sanitizeId(item.id, index),
enabled: item.enabled !== false,
message: cleanText(item.message),
reaction: VALID_REACTIONS.includes(item.reaction) ? item.reaction : "waiting",
intervalMinutes: clampMinutes(item.intervalMinutes, 60, 10, 1440),
};
}
export function getBreaks(config = {}) {
const source = Array.isArray(config.breaks) ? config.breaks : DEFAULT_BREAKS;
return source.map(normalizeBreak).filter((item) => item.enabled);
}
export function statusText(breaks) {
if (!breaks.length) return { text: "No break reminders enabled", tone: "warning" };
const next = Math.min(...breaks.map((item) => item.intervalMinutes));
return { text: `${breaks.length} break reminder${breaks.length === 1 ? "" : "s"} enabled · next every ${next} min`, tone: "info" };
}
export function scheduleSummary(breaks) {
if (!breaks.length) return "No break reminders enabled.";
return breaks.map((item) => `${item.id}: every ${item.intervalMinutes} min`).join("; ");
}
export async function fireBreak(ctx, item, config = {}) {
if (isQuietNow(config)) return false;
await ctx.pet.speak(item.message);
await ctx.pet.react(item.reaction);
await ctx.storage.set("lastBreak", { id: item.id, message: item.message, reaction: item.reaction, at: new Date().toISOString() });
return true;
}
export function makeScheduleIds(breaks) {
const seen = new Set();
return breaks.map((item, index) => {
const base = `break-${sanitizeId(item.id, index)}`;
let id = base.slice(0, MAX_ID_LENGTH);
let count = 2;
while (seen.has(id)) id = `${base.slice(0, MAX_ID_LENGTH - String(count).length - 1)}-${count++}`;
seen.add(id);
return id;
});
}
export async function reschedule(ctx, config = {}) {
await ctx.schedule.cancelAll();
const breaks = getBreaks(config);
const ids = makeScheduleIds(breaks);
let failed = false;
for (const [index, item] of breaks.entries()) {
try {
await ctx.schedule.every(ids[index], item.intervalMinutes * 60_000, () => fireBreak(ctx, item, config));
} catch (error) {
failed = true;
ctx.log?.warn?.("Break Buddy schedule failed", ids[index], error?.message || String(error));
}
}
await ctx.status.set(failed ? { text: "Break schedule registration failed", tone: "error" } : statusText(breaks));
}
export async function snoozeReminder(ctx, config = {}) {
const last = await ctx.storage.get("lastBreak");
if (!last || typeof last !== "object" || !last.id) {
await ctx.pet.speak("No break reminder to snooze yet.");
return false;
}
const item = normalizeBreak(last, 0);
const minutes = clampMinutes(config.snoozeMinutes, DEFAULT_SNOOZE_MINUTES, 1, 120);
await ctx.schedule.once(`snooze-${sanitizeId(item.id, 0)}-${Date.now()}`.slice(0, MAX_ID_LENGTH), minutes * 60_000, () => fireBreak(ctx, item, config));
await ctx.pet.speak(`Snoozed ${item.id} for ${minutes} minutes.`);
return true;
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await reschedule(ctx, await ctx.config.get());
await ctx.commands.register({ id: "take-tiny-break", title: "Take a tiny break", description: "Start a short stretch and eye-rest cue." }, async () => fireBreak(ctx, { id: "tiny-break", message: "Tiny stretch break. Relax your shoulders.", reaction: "waving", intervalMinutes: 10 }, { quietHoursEnabled: false }));
await ctx.commands.register({ id: "snooze-reminder", title: "Snooze reminder", description: "Delay the last break reminder." }, async () => snoozeReminder(ctx, await ctx.config.get()));
await ctx.commands.register({ id: "preview-next-break", title: "Preview next break", description: "Preview the next enabled break reminder." }, async () => { const item = getBreaks(await ctx.config.get())[0]; if (item) await fireBreak(ctx, item, { quietHoursEnabled: false }); });
await ctx.commands.register({ id: "show-break-schedule", title: "Show break schedule", description: "Speak the current break reminder schedule." }, async () => ctx.pet.speak(cleanText(scheduleSummary(getBreaks(await ctx.config.get())), "Break schedule is quiet right now.")));
ctx.config.onChange?.(async (next) => reschedule(ctx, next));
},
async stop() {}
});
}

View file

@ -1,36 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.break-buddy",
"name": "Break Buddy",
"description": "Reminds you to rest your eyes, stretch, hydrate, and take healthy breaks on a schedule you control.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "bell",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"],
"configSchema": {
"quietHoursEnabled": { "type": "boolean", "label": "Quiet hours", "default": true },
"quietStart": { "type": "time", "label": "Quiet start", "default": "22:00" },
"quietEnd": { "type": "time", "label": "Quiet end", "default": "08:00" },
"snoozeMinutes": { "type": "number", "label": "Snooze minutes", "default": 15, "min": 1, "max": 120, "step": 5 },
"breaks": {
"type": "list",
"label": "Break reminders",
"description": "Stretch, eye-rest, and hydration reminders.",
"maxItems": 8,
"default": [
{ "id": "eye-rest", "enabled": true, "message": "Rest your eyes for a moment.", "reaction": "waiting", "intervalMinutes": 50 },
{ "id": "tiny-stretch", "enabled": true, "message": "Tiny stretch break.", "reaction": "waving", "intervalMinutes": 90 },
{ "id": "water-check", "enabled": false, "message": "Water check.", "reaction": "success", "intervalMinutes": 150 }
],
"itemSchema": {
"id": { "type": "text", "label": "ID", "default": "break", "maxLength": 48 },
"enabled": { "type": "boolean", "label": "Enabled", "default": true },
"message": { "type": "textarea", "label": "Message", "default": "Rest your eyes for a moment.", "maxLength": 140 },
"reaction": { "type": "select", "label": "Reaction", "default": "waiting", "options": [{ "label": "Waving", "value": "waving" }, { "label": "Waiting", "value": "waiting" }, { "label": "Success", "value": "success" }, { "label": "Celebrating", "value": "celebrating" }] },
"intervalMinutes": { "type": "number", "label": "Interval minutes", "default": 60, "min": 10, "max": 1440, "step": 5 }
}
}
}
}

View file

@ -1,57 +0,0 @@
import assert from "node:assert/strict";
import { cleanText, DEFAULT_BREAKS, getBreaks, isQuietNow, makeScheduleIds, normalizeBreak, register, reschedule, scheduleSummary, snoozeReminder, statusText } from "./index.js";
function createCtx(config = {}) {
const store = new Map();
const calls = { speak: [], react: [], every: [], once: [], cancelAll: 0, status: [], commands: new Map(), warnings: [] };
return { store, calls, ctx: {
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) },
storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v) },
schedule: { cancelAll: async () => calls.cancelAll++, every: async (id, interval, fn) => calls.every.push({ id, interval, fn }), once: async (id, delay, fn) => calls.once.push({ id, delay, fn }) },
status: { set: async (v) => calls.status.push(v) },
commands: { register: async (cmd, fn) => calls.commands.set(cmd.id, { cmd, fn }) },
config: { get: async () => config, onChange: () => {} },
log: { warn: (...args) => calls.warnings.push(args) },
}};
}
assert.equal(getBreaks({}).length, DEFAULT_BREAKS.filter((b) => b.enabled).length);
assert.equal(cleanText("line one\nline two"), "line one line two");
assert.equal(cleanText("token leak"), "Rest your eyes for a moment.");
assert.equal(normalizeBreak({ id: "x".repeat(100), intervalMinutes: 1, reaction: "bad" }, 0).intervalMinutes, 10);
assert.equal(isQuietNow({ quietStart: "22:00", quietEnd: "08:00" }, new Date("2024-01-01T23:00:00")), true);
assert.equal(isQuietNow({ quietHoursEnabled: false }, new Date("2024-01-01T23:00:00")), false);
assert.equal(new Set(makeScheduleIds(getBreaks({ breaks: [{ id: "same" }, { id: "same" }] }))).size, 2);
{
const h = createCtx({ breaks: [{ id: "eye", intervalMinutes: 15 }] });
await reschedule(h.ctx, await h.ctx.config.get());
assert.equal(h.calls.cancelAll, 1);
assert.equal(h.calls.every[0].interval, 15 * 60_000);
assert.ok(h.calls.status.at(-1).text.includes("break reminder"));
}
{
const h = createCtx();
const plugin = { register(def) { this.def = def; } };
register(plugin);
await plugin.def.start(h.ctx);
for (const id of ["take-tiny-break", "snooze-reminder", "preview-next-break", "show-break-schedule"]) assert.ok(h.calls.commands.has(id), id);
await h.calls.commands.get("take-tiny-break").fn();
assert.ok(h.calls.speak.at(-1).includes("Tiny stretch"));
await h.calls.commands.get("show-break-schedule").fn();
assert.ok(h.calls.speak.at(-1).includes("eye-rest"));
}
{
const h = createCtx({ snoozeMinutes: 999 });
await snoozeReminder(h.ctx, await h.ctx.config.get());
assert.equal(h.calls.once.length, 0);
h.store.set("lastBreak", { id: "eye-rest", message: "Rest your eyes for a moment.", reaction: "waiting" });
await snoozeReminder(h.ctx, await h.ctx.config.get());
assert.equal(h.calls.once[0].delay, 120 * 60_000);
}
assert.deepEqual(statusText([]), { text: "No break reminders enabled", tone: "warning" });
assert.ok(scheduleSummary(getBreaks({})).includes("eye-rest"));
console.log("Break Buddy plugin tests passed.");

View file

@ -1,262 +0,0 @@
export const STATE_KEY = "focusBuddyState";
export const SCHEDULE_ID = "phase-end";
const MIN_DELAY_MS = 1;
const MAX_MESSAGE_LENGTH = 140;
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
const DEFAULTS = {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLongBreak: 4,
autoStartBreaks: false,
autoStartFocus: false,
focusStartMessage: "Focus time! Pick one task and protect your attention.",
focusCompleteMessage: "Focus session complete. Nice work!",
breakStartMessage: "Break time. Stretch, hydrate, and rest your eyes.",
breakCompleteMessage: "Break complete. Ready for the next focus block?",
focusStartReaction: "waving",
focusCompleteReaction: "success",
breakStartReaction: "waiting",
breakCompleteReaction: "waving",
};
export function clampNumber(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.round(number)));
}
export function normalizeConfig(config = {}) {
return {
focusMinutes: clampNumber(config.focusMinutes, DEFAULTS.focusMinutes, 1, 180),
shortBreakMinutes: clampNumber(config.shortBreakMinutes, DEFAULTS.shortBreakMinutes, 1, 60),
longBreakMinutes: clampNumber(config.longBreakMinutes, DEFAULTS.longBreakMinutes, 1, 120),
sessionsBeforeLongBreak: clampNumber(config.sessionsBeforeLongBreak, DEFAULTS.sessionsBeforeLongBreak, 1, 12),
autoStartBreaks: config.autoStartBreaks === true,
autoStartFocus: config.autoStartFocus === true,
focusStartMessage: text(config.focusStartMessage, DEFAULTS.focusStartMessage),
focusCompleteMessage: text(config.focusCompleteMessage, DEFAULTS.focusCompleteMessage),
breakStartMessage: text(config.breakStartMessage, DEFAULTS.breakStartMessage),
breakCompleteMessage: text(config.breakCompleteMessage, DEFAULTS.breakCompleteMessage),
focusStartReaction: text(config.focusStartReaction, DEFAULTS.focusStartReaction),
focusCompleteReaction: text(config.focusCompleteReaction, DEFAULTS.focusCompleteReaction),
breakStartReaction: text(config.breakStartReaction, DEFAULTS.breakStartReaction),
breakCompleteReaction: text(config.breakCompleteReaction, DEFAULTS.breakCompleteReaction),
};
}
function text(value, fallback) {
const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message;
if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback;
return capped;
}
export function today() {
return new Date().toISOString().slice(0, 10);
}
export function idleState(completedSessions = 0, completedToday = 0) {
return { phase: "idle", completedSessions, completedToday, lastActiveDate: today() };
}
export async function getState(ctx) {
const saved = await ctx.storage.get(STATE_KEY);
if (!saved || typeof saved !== "object") return idleState();
const activeDate = typeof saved.lastActiveDate === "string" ? saved.lastActiveDate : today();
const sameDay = activeDate === today();
return {
phase: typeof saved.phase === "string" ? saved.phase : "idle",
previousPhase: typeof saved.previousPhase === "string" ? saved.previousPhase : undefined,
endAt: typeof saved.endAt === "string" ? saved.endAt : undefined,
remainingMs: Number.isFinite(Number(saved.remainingMs)) ? Number(saved.remainingMs) : undefined,
pendingBreakPhase: ["shortBreak", "longBreak"].includes(saved.pendingBreakPhase) ? saved.pendingBreakPhase : undefined,
completedSessions: Math.max(0, Math.round(Number(saved.completedSessions) || 0)),
completedToday: sameDay ? Math.max(0, Math.round(Number(saved.completedToday) || 0)) : 0,
lastCompletedAt: typeof saved.lastCompletedAt === "string" ? saved.lastCompletedAt : undefined,
lastActiveDate: today(),
};
}
export async function setState(ctx, state) {
await ctx.storage.set(STATE_KEY, { ...state, lastActiveDate: today() });
await updateStatus(ctx, state);
}
function phaseLabel(phase) {
if (phase === "focus") return "Focus";
if (phase === "shortBreak") return "Short break";
if (phase === "longBreak") return "Long break";
if (phase === "paused") return "Paused";
return "Idle";
}
export async function updateStatus(ctx, state) {
if (state.phase === "idle") {
if (state.pendingBreakPhase) {
await ctx.status.set({ text: `${phaseLabel(state.pendingBreakPhase)} ready (${state.completedToday || 0} completed today)`, tone: "success" });
return;
}
await ctx.status.set({ text: `Focus Buddy idle (${state.completedToday || 0} completed today)`, tone: "info" });
return;
}
if (state.phase === "paused") {
await ctx.status.set({ text: `Paused with ${formatMs(state.remainingMs || 0)} left`, tone: "warning" });
return;
}
const end = state.endAt ? new Date(state.endAt) : undefined;
await ctx.status.set({ text: `${phaseLabel(state.phase)} until ${end && !Number.isNaN(end.getTime()) ? end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}`, tone: "success" });
}
export function formatMs(ms) {
const minutes = Math.max(1, Math.ceil(ms / 60_000));
return `${minutes} min`;
}
export function durationForPhase(phase, config) {
if (phase === "focus") return config.focusMinutes * 60_000;
if (phase === "longBreak") return config.longBreakMinutes * 60_000;
return config.shortBreakMinutes * 60_000;
}
export function nextBreakPhase(completedSessions, config) {
return completedSessions > 0 && completedSessions % config.sessionsBeforeLongBreak === 0 ? "longBreak" : "shortBreak";
}
async function announce(ctx, message, reaction) {
await ctx.pet.speak(message);
await ctx.pet.react(reaction);
}
export async function schedulePhaseEnd(ctx, state) {
await ctx.schedule.cancel(SCHEDULE_ID);
if (!state.endAt || !["focus", "shortBreak", "longBreak"].includes(state.phase)) return;
const delay = new Date(state.endAt).getTime() - Date.now();
if (!Number.isFinite(delay) || delay < MIN_DELAY_MS) return;
await ctx.schedule.once(SCHEDULE_ID, Math.max(MIN_DELAY_MS, delay), () => completePhase(ctx));
}
export async function startPhase(ctx, phase, durationMs, options = {}) {
const previous = await getState(ctx);
const state = { phase, endAt: new Date(Date.now() + Math.max(MIN_DELAY_MS, durationMs)).toISOString(), remainingMs: undefined, completedSessions: previous.completedSessions || 0, completedToday: previous.completedToday || 0, lastCompletedAt: previous.lastCompletedAt, lastActiveDate: today() };
await setState(ctx, state);
await schedulePhaseEnd(ctx, state);
if (options.announce !== false) {
const config = normalizeConfig(await ctx.config.get());
if (phase === "focus") await announce(ctx, config.focusStartMessage, config.focusStartReaction);
else await announce(ctx, config.breakStartMessage, config.breakStartReaction);
}
}
export async function completePhase(ctx) {
await ctx.schedule.cancel(SCHEDULE_ID);
const config = normalizeConfig(await ctx.config.get());
const state = await getState(ctx);
if (state.phase === "focus") {
const completedSessions = (state.completedSessions || 0) + 1;
const completedToday = (state.completedToday || 0) + 1;
const lastCompletedAt = new Date().toISOString();
await announce(ctx, config.focusCompleteMessage, config.focusCompleteReaction);
const breakPhase = nextBreakPhase(completedSessions, config);
await setState(ctx, { ...idleState(completedSessions, completedToday), lastCompletedAt, phase: "idle", pendingBreakPhase: config.autoStartBreaks ? undefined : breakPhase });
if (config.autoStartBreaks) await startPhase(ctx, breakPhase, durationForPhase(breakPhase, config), { announce: false });
return;
}
if (state.phase === "shortBreak" || state.phase === "longBreak") {
await announce(ctx, config.breakCompleteMessage, config.breakCompleteReaction);
const completedSessions = state.completedSessions || 0;
await setState(ctx, { ...idleState(completedSessions, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt });
if (config.autoStartFocus) await startPhase(ctx, "focus", durationForPhase("focus", config));
}
}
export async function pause(ctx) {
const state = await getState(ctx);
if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt) return;
await ctx.schedule.cancel(SCHEDULE_ID);
await setState(ctx, { phase: "paused", previousPhase: state.phase, remainingMs: Math.max(MIN_DELAY_MS, new Date(state.endAt).getTime() - Date.now()), completedSessions: state.completedSessions || 0, completedToday: state.completedToday || 0, lastCompletedAt: state.lastCompletedAt, lastActiveDate: today() });
}
export async function resume(ctx) {
const state = await getState(ctx);
if (state.phase !== "paused") return;
const phase = ["focus", "shortBreak", "longBreak"].includes(state.previousPhase) ? state.previousPhase : "focus";
await startPhase(ctx, phase, Math.max(MIN_DELAY_MS, state.remainingMs || MIN_DELAY_MS), { announce: false });
}
export async function stop(ctx) {
const state = await getState(ctx);
await ctx.schedule.cancel(SCHEDULE_ID);
await setState(ctx, { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt });
}
export function statusSummary(state) {
if (state.phase === "paused") return `Focus paused with ${formatMs(state.remainingMs || 0)} left.`;
if (["focus", "shortBreak", "longBreak"].includes(state.phase)) return `${phaseLabel(state.phase)} running until ${state.endAt ? new Date(state.endAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}. ${state.completedToday || 0} completed today.`;
if (state.pendingBreakPhase) return `Focus Buddy idle. ${phaseLabel(state.pendingBreakPhase)} is ready. ${state.completedToday || 0} focus sessions completed today.`;
return `Focus Buddy idle. ${state.completedToday || 0} focus sessions completed today.`;
}
export async function reconcileStartup(ctx) {
const state = await getState(ctx);
if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt || new Date(state.endAt).getTime() > Date.now()) return state;
await ctx.schedule.cancel(SCHEDULE_ID);
const config = normalizeConfig(await ctx.config.get());
if (state.phase === "focus") {
const completedSessions = (state.completedSessions || 0) + 1;
const completedToday = (state.completedToday || 0) + 1;
const pendingBreakPhase = nextBreakPhase(completedSessions, config);
const next = { ...idleState(completedSessions, completedToday), lastCompletedAt: new Date().toISOString(), pendingBreakPhase };
await setState(ctx, next);
await announce(ctx, "Focus ended while you were away. Your next break is ready.", config.focusCompleteReaction);
return next;
}
const next = { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt };
await setState(ctx, next);
await announce(ctx, "Break ended while you were away.", config.breakCompleteReaction);
return next;
}
export async function startNextBreak(ctx) {
const state = await getState(ctx);
const phase = ["shortBreak", "longBreak"].includes(state.pendingBreakPhase) ? state.pendingBreakPhase : undefined;
if (!phase) { await ctx.pet.speak("No pending focus break is ready."); return false; }
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, phase, durationForPhase(phase, config));
return true;
}
export async function resetCount(ctx) {
const state = await getState(ctx);
await setState(ctx, { ...state, completedSessions: 0, completedToday: 0, lastCompletedAt: undefined });
await ctx.pet.speak("Focus counts reset for today.");
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
const state = await reconcileStartup(ctx);
await updateStatus(ctx, state);
await schedulePhaseEnd(ctx, state);
await ctx.commands.register({ id: "start-focus", title: "Start focus", description: "Start a focus session." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "focus", durationForPhase("focus", config));
});
await ctx.commands.register({ id: "start-short-break", title: "Start short break", description: "Start a short focus break." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "shortBreak", durationForPhase("shortBreak", config));
});
await ctx.commands.register({ id: "start-long-break", title: "Start long break", description: "Start a long focus break." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "longBreak", durationForPhase("longBreak", config));
});
await ctx.commands.register({ id: "pause-focus", title: "Pause focus", description: "Pause the current focus timer." }, () => pause(ctx));
await ctx.commands.register({ id: "resume-focus", title: "Resume focus", description: "Resume a paused focus timer." }, () => resume(ctx));
await ctx.commands.register({ id: "stop-focus", title: "Stop focus", description: "Stop and return to idle." }, () => stop(ctx));
await ctx.commands.register({ id: "show-focus-status", title: "Show focus status", description: "Speak the current timer phase and daily count." }, async () => ctx.pet.speak(statusSummary(await getState(ctx))));
},
async stop() {}
});
}

View file

@ -1,125 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.focus-buddy",
"name": "Focus Buddy",
"description": "Adds pet-menu commands for focus sessions, short breaks, long breaks, pause, resume, and status updates.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "timer",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"],
"configSchema": {
"focusMinutes": {
"type": "number",
"label": "Focus minutes",
"description": "Length of a focus session.",
"default": 25,
"min": 1,
"max": 180,
"step": 1
},
"shortBreakMinutes": {
"type": "number",
"label": "Short break minutes",
"default": 5,
"min": 1,
"max": 60,
"step": 1
},
"longBreakMinutes": {
"type": "number",
"label": "Long break minutes",
"default": 15,
"min": 1,
"max": 120,
"step": 1
},
"sessionsBeforeLongBreak": {
"type": "number",
"label": "Sessions before long break",
"default": 4,
"min": 1,
"max": 12,
"step": 1
},
"autoStartBreaks": {
"type": "boolean",
"label": "Auto-start breaks",
"default": false
},
"autoStartFocus": {
"type": "boolean",
"label": "Auto-start focus after breaks",
"default": false
},
"focusStartMessage": {
"type": "textarea",
"label": "Focus start message",
"default": "Focus time! Pick one task and protect your attention.",
"maxLength": 140
},
"focusCompleteMessage": {
"type": "textarea",
"label": "Focus complete message",
"default": "Focus session complete. Nice work!",
"maxLength": 140
},
"breakStartMessage": {
"type": "textarea",
"label": "Break start message",
"default": "Break time. Stretch, hydrate, and rest your eyes.",
"maxLength": 140
},
"breakCompleteMessage": {
"type": "textarea",
"label": "Break complete message",
"default": "Break complete. Ready for the next focus block?",
"maxLength": 140
},
"focusStartReaction": {
"type": "select",
"label": "Focus start reaction",
"default": "waving",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"focusCompleteReaction": {
"type": "select",
"label": "Focus complete reaction",
"default": "success",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"breakStartReaction": {
"type": "select",
"label": "Break start reaction",
"default": "waiting",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"breakCompleteReaction": {
"type": "select",
"label": "Break complete reaction",
"default": "waving",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
}
}
}

View file

@ -1,76 +0,0 @@
import assert from "node:assert/strict";
import { completePhase, getState, normalizeConfig, pause, reconcileStartup, register, resetCount, resume, startPhase, statusSummary } from "./index.js";
function ctx(config = {}) {
const store = new Map();
const calls = { speak: [], react: [], status: [], cancel: [], once: [], commands: new Map() };
return { calls, store, ctx: {
config: { get: async () => config },
storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v) },
schedule: { cancel: async (id) => calls.cancel.push(id), once: async (id, ms, fn) => calls.once.push({ id, ms, fn }) },
status: { set: async (v) => calls.status.push(v) },
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) },
commands: { register: async (cmd, fn) => calls.commands.set(cmd.id, { cmd, fn }) },
}};
}
assert.equal(normalizeConfig({ focusMinutes: 999, focusStartMessage: "token leak" }).focusMinutes, 180);
assert.equal(normalizeConfig({ focusStartMessage: "token leak" }).focusStartMessage, "Focus time! Pick one task and protect your attention.");
{
const h = ctx({ focusMinutes: 1 });
await startPhase(h.ctx, "focus", 60_000);
assert.equal(h.calls.once.length, 1);
assert.equal((await getState(h.ctx)).phase, "focus");
await completePhase(h.ctx);
const state = await getState(h.ctx);
assert.equal(state.completedSessions, 1);
assert.equal(state.completedToday, 1);
assert.equal(state.pendingBreakPhase, "shortBreak");
assert.ok(statusSummary(state).includes("ready"));
}
{
const h = ctx({ autoStartBreaks: true });
await startPhase(h.ctx, "focus", 60_000, { announce: false });
await completePhase(h.ctx);
assert.equal(h.calls.speak.length, 1, "auto transition avoids double speech");
assert.equal((await getState(h.ctx)).phase, "shortBreak");
}
{
const h = ctx();
await startPhase(h.ctx, "focus", 60_000, { announce: false });
await pause(h.ctx);
assert.equal((await getState(h.ctx)).phase, "paused");
await resume(h.ctx);
assert.equal((await getState(h.ctx)).phase, "focus");
}
{
const h = ctx();
const plugin = { register(def) { this.def = def; } };
register(plugin);
await plugin.def.start(h.ctx);
for (const id of ["start-focus", "start-short-break", "start-long-break", "pause-focus", "resume-focus", "stop-focus", "show-focus-status"]) assert.ok(h.calls.commands.has(id), id);
await h.calls.commands.get("show-focus-status").fn();
assert.ok(h.calls.speak.at(-1).includes("Focus Buddy"));
assert.ok(statusSummary(await getState(h.ctx)).includes("idle"));
h.store.set("focusBuddyState", { phase: "idle", pendingBreakPhase: "shortBreak", completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) });
await h.calls.commands.get("start-short-break").fn();
assert.equal((await getState(h.ctx)).phase, "shortBreak");
await resetCount(h.ctx);
assert.equal((await getState(h.ctx)).completedToday, 0);
}
{
const h = ctx();
h.store.set("focusBuddyState", { phase: "focus", endAt: new Date(Date.now() - 1000).toISOString(), completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) });
const settled = await reconcileStartup(h.ctx);
assert.equal(settled.phase, "idle");
assert.equal(settled.completedToday, 2);
assert.equal(settled.pendingBreakPhase, "shortBreak");
assert.equal(h.calls.speak.length, 1);
}
console.log("Focus Buddy plugin tests passed.");

View file

@ -1,205 +0,0 @@
export const MAX_REPOS = 10;
export const MAX_MESSAGE_LENGTH = 140;
export const DEFAULT_NOTIFICATION_MESSAGE = "New GitHub notification.";
export const EMPTY_BASELINE = "__openpets_empty__";
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
let checkRunning = false;
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await ctx.commands.register({ id: "check-now", title: "Check GitHub now", description: "Check configured public repositories now." }, async () => {
void checkNow(ctx, true).catch((error) => ctx.log?.warn?.("GitHub manual check failed", error?.message || String(error)));
await ctx.status.set({ text: "GitHub: checking now…", tone: "info" });
});
await ctx.commands.register({ id: "reset-baseline", title: "Reset GitHub baseline", description: "Mark current releases, issues, pull requests, and failed workflows as seen." }, async () => {
void resetBaseline(ctx).catch((error) => ctx.log?.warn?.("GitHub baseline reset failed", error?.message || String(error)));
await ctx.status.set({ text: "GitHub: resetting baseline…", tone: "info" });
});
await ctx.commands.register({ id: "show-last-check", title: "Show last GitHub check", description: "Speak the latest GitHub notification check summary." }, async () => await showLastCheck(ctx));
await scheduleNext(ctx);
void checkNow(ctx, false).catch((error) => ctx.log?.warn?.("GitHub initial check failed", error?.message || String(error)));
},
});
}
if (typeof globalThis.OpenPetsPlugin !== "undefined") register(globalThis.OpenPetsPlugin);
export async function scheduleNext(ctx) {
const config = await ctx.config.get();
const interval = Math.max(10, Number(config.pollIntervalMinutes || 30));
await ctx.schedule.cancel("poll");
await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false));
await ctx.status.set({ text: `GitHub: next check ${new Date(Date.now() + interval * 60 * 1000).toLocaleTimeString()}`, tone: "info" });
}
export async function checkNow(ctx, manual) {
if (checkRunning) {
if (manual) await ctx.pet.speak("GitHub check already running.");
return { at: new Date().toISOString(), repos: 0, notifications: 0, failures: 0, skipped: true, reason: "already-running" };
}
checkRunning = true;
try {
const config = await ctx.config.get();
const parsed = parseReposDetailed(config.repositories);
const repoLimit = repoLimitForConfig(config);
const repos = parsed.repos.slice(0, repoLimit);
const truncated = parsed.truncated || parsed.repos.length > repoLimit;
if (repos.length === 0) { await ctx.status.set({ text: parsed.invalid.length ? "GitHub: fix invalid repositories" : "GitHub: add public repositories", tone: "warning" }); if (manual && parsed.invalid.length) await ctx.pet.speak(`Invalid GitHub repositories ignored: ${parsed.invalid.slice(0, 3).join(", ")}.`); return { repos: 0, notifications: 0, failures: 0, invalid: parsed.invalid, truncated }; }
const baseline = (await ctx.storage.get("baselineComplete")) === true;
const events = [];
let failures = 0;
let backoffSkipped = 0;
for (const repo of repos) {
try {
const backoffUntil = Number(await ctx.storage.get(`backoff:${repo}`) || 0);
if (backoffUntil > Date.now()) { backoffSkipped += 1; continue; }
if (config.notifyReleases !== false) events.push(...await checkRelease(ctx, repo, config, baseline));
if (config.notifyFailedWorkflows !== false) events.push(...await checkWorkflow(ctx, repo, config, baseline));
if (config.notifyIssues === true) events.push(...await checkIssue(ctx, repo, config, baseline));
if (config.notifyPullRequests === true) events.push(...await checkPullRequest(ctx, repo, config, baseline));
} catch (error) {
failures += 1;
if (isBackoffError(error)) await ctx.storage.set(`backoff:${repo}`, Date.now() + 15 * 60 * 1000);
ctx.log?.warn?.("GitHub repo check failed", repo, error?.message || String(error));
}
}
await notifyBatch(ctx, events);
if (!baseline) await ctx.storage.set("baselineComplete", true);
const notifications = events.length;
const summary = { at: new Date().toISOString(), repos: repos.length, notifications, failures, invalid: parsed.invalid, truncated, backoffSkipped };
await ctx.storage.set("lastCheck", summary);
await ctx.status.set({ text: `GitHub: checked ${repos.length}, ${notifications} new${failures ? `, ${failures} failed` : ""}`, tone: failures ? "warning" : notifications ? "success" : "info" });
if (manual) {
if (parsed.invalid.length || truncated) await ctx.pet.speak(`GitHub ignored ${parsed.invalid.length} invalid${truncated ? " and extra" : ""} repository entries.`);
else if (notifications === 0 && failures === 0) await ctx.pet.speak(backoffSkipped ? `No new GitHub notifications. ${backoffSkipped} repo checks are cooling down.` : "No new GitHub notifications.");
else if (failures) await ctx.pet.speak(notifications ? `GitHub check found ${notifications} new notifications, with ${failures} repo failures.` : `GitHub check had ${failures} repo failures and no new notifications.`);
}
const interval = Math.max(10, Number(config.pollIntervalMinutes || 30));
await ctx.schedule.cancel("poll");
await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false));
return summary;
} finally { checkRunning = false; }
}
export async function showLastCheck(ctx) {
const last = await ctx.storage.get("lastCheck");
if (!last || typeof last !== "object") { await ctx.pet.speak("No GitHub check has completed yet."); return; }
await ctx.pet.speak(`Last GitHub check: ${last.repos || 0} repos, ${last.notifications || 0} new, ${last.failures || 0} failed.`);
}
export async function resetBaseline(ctx) {
await ctx.storage.delete("baselineComplete");
const summary = await checkNow(ctx, false);
if (summary.failures || summary.backoffSkipped) await ctx.pet.speak(`GitHub baseline partially reset. ${summary.failures || 0} failed, ${summary.backoffSkipped || 0} cooling down.`);
else await ctx.pet.speak("GitHub notification baseline reset.");
}
async function checkRelease(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/releases?per_page=1`, `etag:release:${repo}`);
if (res.notModified) return [];
const release = Array.isArray(res.json) ? res.json[0] : undefined;
return handleNewest(ctx, `release:${repo}`, release && String(release.id || release.tag_name || ""), baseline, { type: "release", repo, message: format(config.releaseMessage || "New release: {repo} {tag}", { repo, tag: release?.tag_name || "" }), reaction: config.releaseReaction || "celebrating" });
}
async function checkWorkflow(ctx, repo, config, baseline) {
const branch = await defaultBranch(ctx, repo);
const res = await github(ctx, `/repos/${repo}/actions/runs?status=completed&per_page=10&branch=${encodeURIComponent(branch)}`, `etag:workflow:${repo}`);
if (res.notModified) return [];
const run = (res.json?.workflow_runs || []).find((item) => ["failure", "timed_out", "action_required"].includes(item?.conclusion));
return handleNewest(ctx, `workflow:${repo}`, run && String(run.id || ""), baseline, { type: "workflow", repo, message: format(config.workflowMessage || "Workflow failed in {repo}: {name}", { repo, name: run?.name || "workflow" }), reaction: config.workflowReaction || "error" });
}
async function checkIssue(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/issues?state=open&per_page=10&sort=created&direction=desc`, `etag:issue:${repo}`);
if (res.notModified) return [];
const issue = (Array.isArray(res.json) ? res.json : []).find((item) => item && !item.pull_request);
return handleNewest(ctx, `issue:${repo}`, issue && String(issue.id || issue.number || ""), baseline, { type: "issue", repo, message: format(config.issueMessage || "New issue in {repo}: {name}", { repo, name: issue?.title || `#${issue?.number}` }), reaction: config.issueReaction || "thinking" });
}
async function checkPullRequest(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/pulls?state=open&per_page=1&sort=created&direction=desc`, `etag:pr:${repo}`);
if (res.notModified) return [];
const pr = Array.isArray(res.json) ? res.json[0] : undefined;
return handleNewest(ctx, `pr:${repo}`, pr && String(pr.id || pr.number || ""), baseline, { type: "pr", repo, message: format(config.pullRequestMessage || "New pull request in {repo}: {name}", { repo, name: pr?.title || `#${pr?.number}` }), reaction: config.pullRequestReaction || "waving" });
}
async function handleNewest(ctx, key, id, baseline, event) {
const previous = String((await ctx.storage.get(key)) || "");
const next = id || EMPTY_BASELINE;
if (!baseline || !previous) {
await ctx.storage.set(key, next);
return [];
}
if (!id) {
if (previous !== EMPTY_BASELINE) await ctx.storage.set(key, EMPTY_BASELINE);
return [];
}
await ctx.storage.set(key, id);
if (id === previous) return [];
return [event];
}
async function defaultBranch(ctx, repo) {
const key = `repo:${repo}`;
const cached = await ctx.storage.get(key);
if (cached?.default_branch) return cached.default_branch;
const res = await github(ctx, `/repos/${repo}`, `etag:repo:${repo}`);
const branch = res.json?.default_branch || "main";
await ctx.storage.set(key, { default_branch: branch });
return branch;
}
async function notifyBatch(ctx, events) {
if (!events.length) return;
const order = { workflow: 0, release: 1, pr: 2, issue: 3 };
events.sort((a, b) => order[a.type] - order[b.type]);
const first = events[0];
await ctx.pet.react(safeMessage(first.reaction, "idle"));
await ctx.pet.speak(events.length === 1 ? first.message : `GitHub: ${events.length} new notifications. ${first.message}`);
}
function isBackoffError(error) { const text = String(error?.message || error); return /\b(403|429)\b|network|fetch|timeout/i.test(text); }
export async function github(ctx, path, etagKey) {
const headers = { accept: "application/vnd.github+json", "user-agent": "OpenPets GitHub Notifications" };
const etag = await ctx.storage.get(etagKey);
if (typeof etag === "string" && etag) headers["if-none-match"] = etag;
const res = await ctx.http.fetch(`https://api.github.com${path}`, { headers, timeoutMs: 10000 });
if (res.headers?.etag) await ctx.storage.set(etagKey, res.headers.etag);
if (res.status === 304) return { ...res, json: undefined, notModified: true };
if (!res.ok) throw new Error(`GitHub API returned ${res.status}`);
return res;
}
export async function notify(ctx, message, reaction) { await ctx.pet.react(safeMessage(reaction, "idle")); await ctx.pet.speak(safeMessage(message)); }
export function safeMessage(value, fallback = DEFAULT_NOTIFICATION_MESSAGE) {
const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message;
if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback;
return capped;
}
export function parseRepos(value) {
return parseReposDetailed(value).repos;
}
export function parseReposDetailed(value) {
const raw = Array.isArray(value) ? value.join("\n") : String(value || "");
const valid = [];
const invalid = [];
for (const item of raw.split(/[\n,\s]+/).map((x) => x.trim()).filter(Boolean)) (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(item) ? valid : invalid).push(item);
const unique = Array.from(new Set(valid));
return { repos: unique.slice(0, MAX_REPOS), invalid, truncated: unique.length > MAX_REPOS };
}
export function repoLimitForConfig(config = {}) {
let callsPerRepo = 0;
if (config.notifyReleases !== false) callsPerRepo += 1;
if (config.notifyFailedWorkflows !== false) callsPerRepo += 2;
if (config.notifyIssues === true) callsPerRepo += 1;
if (config.notifyPullRequests === true) callsPerRepo += 1;
return Math.max(1, Math.min(MAX_REPOS, Math.floor(28 / Math.max(1, callsPerRepo))));
}
export function format(template, values) { return safeMessage(String(template).replace(/\{(repo|tag|name)\}/g, (_m, key) => safeTemplateValue(values[key] || ""))); }
export function safeTemplateValue(value) { return String(value).replace(/[\r\n]+/g, " ").replace(/\//g, " ").replace(/\s+/g, " ").trim().slice(0, 80); }

View file

@ -1,29 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.github-notifications",
"name": "GitHub Notifications",
"description": "Watches public GitHub repositories and lets your pet notify you about releases and failed workflows.",
"version": "1.1.0",
"runtime": "javascript",
"icon": "github",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["network", "schedule", "storage", "pet:speak", "pet:reaction", "commands", "status"],
"network": { "hosts": ["api.github.com"] },
"configSchema": {
"repositories": { "type": "textarea", "label": "Public repositories", "description": "One owner/repo per line. Public repositories only.", "default": "" },
"pollIntervalMinutes": { "type": "number", "label": "Poll interval minutes", "default": 30, "min": 10, "max": 1440, "step": 5 },
"notifyReleases": { "type": "boolean", "label": "Notify new releases", "default": true },
"notifyFailedWorkflows": { "type": "boolean", "label": "Notify failed workflows", "default": true },
"notifyIssues": { "type": "boolean", "label": "Notify new issues", "default": false },
"notifyPullRequests": { "type": "boolean", "label": "Notify new pull requests", "default": false },
"releaseMessage": { "type": "text", "label": "Release message", "default": "New release: {repo} {tag}", "maxLength": 140 },
"workflowMessage": { "type": "text", "label": "Workflow failure message", "default": "Workflow failed in {repo}: {name}", "maxLength": 140 },
"issueMessage": { "type": "text", "label": "Issue message", "default": "New issue in {repo}: {name}", "maxLength": 140 },
"pullRequestMessage": { "type": "text", "label": "Pull request message", "default": "New pull request in {repo}: {name}", "maxLength": 140 },
"releaseReaction": { "type": "select", "label": "Release reaction", "default": "celebrating", "options": [{ "label": "Celebrating", "value": "celebrating" }, { "label": "Success", "value": "success" }, { "label": "Waving", "value": "waving" }, { "label": "Idle", "value": "idle" }] },
"workflowReaction": { "type": "select", "label": "Workflow reaction", "default": "error", "options": [{ "label": "Error", "value": "error" }, { "label": "Thinking", "value": "thinking" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] },
"issueReaction": { "type": "select", "label": "Issue reaction", "default": "thinking", "options": [{ "label": "Thinking", "value": "thinking" }, { "label": "Waving", "value": "waving" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] },
"pullRequestReaction": { "type": "select", "label": "Pull request reaction", "default": "waving", "options": [{ "label": "Waving", "value": "waving" }, { "label": "Success", "value": "success" }, { "label": "Thinking", "value": "thinking" }, { "label": "Idle", "value": "idle" }] }
}
}

View file

@ -1,115 +0,0 @@
import assert from "node:assert/strict";
import { EMPTY_BASELINE, checkNow, format, parseRepos, parseReposDetailed, register, resetBaseline, repoLimitForConfig, safeMessage, showLastCheck } from "./index.js";
function harness(config, routes) {
const store = new Map();
const calls = { speak: [], react: [], status: [], every: [], cancel: [], commands: new Map(), warnings: [] };
return { store, calls, ctx: {
config: { get: async () => config }, storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v), delete: async (k) => store.delete(k) },
schedule: { cancel: async (id) => calls.cancel.push(id), every: async (id, ms, fn) => calls.every.push({ id, ms, fn }) }, status: { set: async (v) => calls.status.push(v) },
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) }, commands: { register: async (c, f) => calls.commands.set(c.id, { c, f }) }, log: { warn: (...a) => calls.warnings.push(a) },
http: { fetch: async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = routes[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; } }
}};
}
assert.deepEqual(parseRepos("a/b\na/b bad nope c/d"), ["a/b", "c/d"]);
assert.deepEqual(parseReposDetailed("a/b bad").invalid, ["bad"]);
assert.equal(repoLimitForConfig({ notifyIssues: true, notifyPullRequests: true }), 5);
assert.equal(repoLimitForConfig({ notifyIssues: false, notifyPullRequests: false }), 9);
assert.equal(safeMessage("secret token"), "New GitHub notification.");
assert.equal(format("New {repo}: {name}", { repo: "o/r", name: "hello/world" }), "New o r: hello world");
const routes1 = { "/repos/o/r": { default_branch: "trunk" }, "/repos/o/r/releases?per_page=1": [{ id: 1, tag_name: "v1" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 2, name: "ci", conclusion: "failure" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 3, title: "bug" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 4, title: "fix" }] };
{
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1);
await checkNow(h.ctx, false);
assert.equal(h.calls.speak.length, 0, "baseline does not notify");
await checkNow(h.ctx, true);
assert.equal(h.calls.speak.at(-1), "No new GitHub notifications.");
}
{
const emptyRoutes = { ...routes1, "/repos/o/r/releases?per_page=1": [], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [] };
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, emptyRoutes);
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), EMPTY_BASELINE);
h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 4, "first real events after empty baseline notify");
}
{
const h = harness({ repositories: "o/r", notifyIssues: false, notifyPullRequests: false }, routes1);
await checkNow(h.ctx, false);
h.ctx.config.get = async () => ({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 0, "newly enabled event types baseline without announcing old items");
assert.equal(h.store.get("issue:o/r"), "3");
assert.equal(h.store.get("pr:o/r"), "4");
}
{
const h = harness({ repositories: "o/r", notifyFailedWorkflows: false }, routes1);
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), "1");
h.ctx.http.fetch = async () => ({ ok: true, status: 304, headers: {}, json: undefined });
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), "1", "304 does not overwrite existing seen id with empty baseline");
h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 0, "200 -> 304 -> 200 same id does not re-announce");
}
{
const routes2 = { ...routes1, "/repos/o/r/releases?per_page=1": [{ id: 10, tag_name: "v2" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 20, name: "ci", conclusion: "timed_out" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 30, title: "bug2" }, { id: 31, pull_request: {}, title: "pr as issue" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 40, title: "fix2" }] };
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1);
await checkNow(h.ctx, false); h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes2[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 4);
assert.equal(h.calls.speak.length, 1, "batched notifications use one speech");
}
{
const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") });
h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = h.ctx.config && { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; };
const result = await checkNow(h.ctx, true);
assert.equal(result.failures, 1);
assert.ok(h.calls.speak.at(-1).includes("failures"));
await showLastCheck(h.ctx); assert.ok(h.calls.speak.at(-1).includes("Last GitHub check"));
}
{
const h = harness({ repositories: "bad o/r" }, routes1);
const result = await checkNow(h.ctx, true);
assert.equal(result.invalid.length, 1);
assert.ok(h.calls.speak.at(-1).includes("invalid"));
}
{
const h = harness({ repositories: "o/r" }, { ...routes1, "/repos/o/r/releases?per_page=1": new Error("network down") });
await checkNow(h.ctx, false);
assert.ok(Number(h.store.get("backoff:o/r")) > Date.now());
const result = await checkNow(h.ctx, true);
assert.equal(result.backoffSkipped, 1);
}
{
const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") });
h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; };
await resetBaseline(h.ctx);
assert.ok(h.calls.speak.at(-1).includes("partially reset"));
}
{
const h = harness({ repositories: "o/r" }, routes1);
let resolveFetch;
let blocked = true;
h.ctx.http.fetch = async (url) => {
if (blocked) { blocked = false; return await new Promise((resolve) => { resolveFetch = () => resolve({ ok: true, status: 200, headers: {}, json: [] }); }); }
const key = new URL(url).pathname + new URL(url).search;
return { ok: true, status: 200, headers: {}, json: routes1[key] || [] };
};
const first = checkNow(h.ctx, false);
await Promise.resolve(); await Promise.resolve();
const second = await checkNow(h.ctx, true);
assert.equal(second.reason, "already-running");
assert.equal(h.calls.speak.at(-1), "GitHub check already running.");
resolveFetch(); await first;
}
{
const h = harness({}, {}); const plugin = { register(def) { this.def = def; } }; register(plugin); await plugin.def.start(h.ctx); assert.ok(h.calls.commands.has("show-last-check"));
await h.calls.commands.get("check-now").f();
assert.ok(h.calls.status.at(-1).text.includes("checking"));
}
console.log("GitHub Notifications plugin tests passed.");

View file

@ -1,45 +0,0 @@
export const MAX_MESSAGE_LENGTH = 140;
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
export const ACTIONS = {
hello: [{ message: "Hello. I am happy to see you.", reaction: "waving" }, { message: "Tiny wave from your pet.", reaction: "waving" }],
company: [{ message: "I will keep you company.", reaction: "waving" }, { message: "Still here with you.", reaction: "waving" }],
cheer: [{ message: "You got this.", reaction: "success" }, { message: "I am rooting for you.", reaction: "celebrating" }],
trick: [{ message: "Tiny trick complete.", reaction: "celebrating" }, { message: "Ta-da.", reaction: "success" }],
celebrate: [{ message: "Tiny celebration!", reaction: "celebrating" }, { message: "That deserves a happy wiggle.", reaction: "celebrating" }],
calm: [{ message: "Deep breath. Nice and easy.", reaction: "waiting" }, { message: "Slow blink. You are safe.", reaction: "waiting" }],
random: [{ message: "I believe in snack breaks.", reaction: "waving" }, { message: "Soft paws, brave heart.", reaction: "success" }],
};
export function safeText(value, fallback = "Hello.") {
const text = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = text.length > MAX_MESSAGE_LENGTH ? text.slice(0, MAX_MESSAGE_LENGTH).trim() : text;
return !capped || UNSAFE_MESSAGE_PATTERN.test(capped) ? fallback : capped;
}
export function pick(list, random = Math.random) {
return list[Math.min(list.length - 1, Math.floor(random() * list.length))];
}
export async function runAction(ctx, key, random = Math.random) {
const item = pick(ACTIONS[key] || ACTIONS.random, random);
await ctx.pet.speak(safeText(item.message));
await ctx.pet.react(item.reaction);
return item;
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await ctx.status.set({ text: "Ready for playful pet actions", tone: "info" });
await ctx.commands.register({ id: "say-hello", title: "Say hello", description: "Get a friendly greeting." }, () => runAction(ctx, "hello"));
await ctx.commands.register({ id: "keep-me-company", title: "Keep me company", description: "Ask your pet to stay nearby." }, () => runAction(ctx, "company"));
await ctx.commands.register({ id: "cheer-me-up", title: "Cheer me up", description: "Ask your pet for a tiny cheer." }, () => runAction(ctx, "cheer"));
await ctx.commands.register({ id: "do-a-trick", title: "Do a trick", description: "Ask your pet for a tiny trick." }, () => runAction(ctx, "trick"));
await ctx.commands.register({ id: "celebrate", title: "Celebrate", description: "Celebrate a little win." }, () => runAction(ctx, "celebrate"));
await ctx.commands.register({ id: "calm-down", title: "Calm down", description: "Hear a calm little cue." }, () => runAction(ctx, "calm"));
await ctx.commands.register({ id: "random-mood", title: "Random mood", description: "Let your pet choose a mood." }, () => runAction(ctx, "random"));
},
async stop() {}
});
}

View file

@ -1,13 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.pet-pal",
"name": "Pet Pal",
"description": "Adds fun right-click pet actions like say hello, keep me company, cheer me up, do a trick, celebrate, and calm down.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "sparkles",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "commands", "status"],
"configSchema": {}
}

View file

@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import { ACTIONS, pick, register, runAction, safeText } from "./index.js";
assert.equal(safeText("hello\nthere"), "hello there");
assert.equal(safeText("token leak"), "Hello.");
assert.equal(pick(["a", "b"], () => 0.9), "b");
const calls = { speak: [], react: [], status: [], commands: new Map() };
const ctx = { pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) }, status: { set: async (v) => calls.status.push(v) }, commands: { register: async (cmd, fn) => calls.commands.set(cmd.id, { cmd, fn }) } };
await runAction(ctx, "cheer", () => 0);
assert.equal(calls.speak[0], ACTIONS.cheer[0].message);
const plugin = { register(def) { this.def = def; } };
register(plugin);
await plugin.def.start(ctx);
for (const id of ["say-hello", "keep-me-company", "cheer-me-up", "do-a-trick", "celebrate", "calm-down", "random-mood"]) assert.ok(calls.commands.has(id), id);
await calls.commands.get("celebrate").fn();
assert.ok(calls.speak.at(-1).includes("celebration") || calls.speak.at(-1).includes("wiggle"));
console.log("Pet Pal plugin tests passed.");

View file

@ -1,88 +0,0 @@
export const MAX_REMINDERS = 10;
export const MAX_MESSAGE_LENGTH = 140;
export const MAX_DELAY_MS = 24 * 60 * 60 * 1000;
export function cleanMessage(value, fallback = "Reminder time.") {
const text = typeof value === "string" ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : "";
return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback;
}
export function durationMs(values = {}) {
const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0))));
const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0))));
const ms = (hours * 60 + minutes) * 60_000;
if (ms < 60_000 || ms > MAX_DELAY_MS) throw new Error("Reminder duration must be 1 minute to 24 hours.");
return ms;
}
export async function getReminders(ctx) {
const reminders = await ctx.storage.get("reminders");
return Array.isArray(reminders) ? reminders.filter((r) => r && typeof r.id === "string" && typeof r.dueAt === "number" && typeof r.message === "string").slice(0, MAX_REMINDERS) : [];
}
async function saveReminders(ctx, reminders) {
await ctx.storage.set("reminders", reminders.slice(0, MAX_REMINDERS));
await ctx.status.set(reminders.length ? { text: `${reminders.length} reminder${reminders.length === 1 ? "" : "s"} active`, tone: "info" } : { text: "No active reminders", tone: "info" });
}
export async function fireReminder(ctx, id) {
const reminders = await getReminders(ctx);
const item = reminders.find((r) => r.id === id);
await saveReminders(ctx, reminders.filter((r) => r.id !== id));
if (!item) return false;
await ctx.pet.speak(item.message);
await ctx.pet.react("waving");
return true;
}
export async function scheduleReminder(ctx, reminder) {
const delay = Math.max(1_000, reminder.dueAt - Date.now());
await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id));
}
export async function addReminder(ctx, message, delayMs) {
const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now());
if (reminders.length >= MAX_REMINDERS) throw new Error("Quick Reminders can keep up to 10 active reminders.");
const reminder = { id: `reminder-${Date.now().toString(36)}`.slice(0, 64), message: cleanMessage(message), dueAt: Date.now() + delayMs };
reminders.push(reminder);
await saveReminders(ctx, reminders);
await scheduleReminder(ctx, reminder);
await ctx.pet.speak("Reminder set.");
await ctx.pet.react("success");
return reminder;
}
export async function reconcile(ctx) {
await ctx.schedule.cancelAll();
const now = Date.now();
const reminders = await getReminders(ctx);
const future = reminders.filter((r) => r.dueAt > now);
const overdue = reminders.filter((r) => r.dueAt <= now);
await saveReminders(ctx, future);
for (const item of future) await scheduleReminder(ctx, item);
if (overdue.length) await ctx.pet.speak(`${overdue.length} reminder${overdue.length === 1 ? "" : "s"} missed while OpenPets was closed.`);
}
export function summary(reminders, now = Date.now()) {
if (!reminders.length) return "No active reminders.";
return reminders.slice(0, 5).map((r) => `${Math.max(1, Math.ceil((r.dueAt - now) / 60_000))} min: ${r.message}`).join("; ");
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await reconcile(ctx);
await ctx.commands.register({ id: "set-reminder", title: "Set reminder...", description: "Create a quick local reminder.", form: { submitLabel: "Set Reminder", fields: [
{ id: "message", type: "textarea", label: "Message", required: true, maxLength: MAX_MESSAGE_LENGTH, default: "Reminder time." },
{ id: "hours", type: "number", label: "Hours", default: 0, min: 0, max: 23 },
{ id: "minutes", type: "number", label: "Minutes", default: 15, min: 0, max: 59 }
] } }, async (values) => addReminder(ctx, values.message, durationMs(values)));
await ctx.commands.register({ id: "reminder-15", title: "15 min reminder", description: "Set a default reminder for 15 minutes." }, () => addReminder(ctx, "Reminder time.", 15 * 60_000));
await ctx.commands.register({ id: "reminder-30", title: "30 min reminder", description: "Set a default reminder for 30 minutes." }, () => addReminder(ctx, "Reminder time.", 30 * 60_000));
await ctx.commands.register({ id: "reminder-60", title: "1 hour reminder", description: "Set a default reminder for 1 hour." }, () => addReminder(ctx, "Reminder time.", 60 * 60_000));
await ctx.commands.register({ id: "view-reminders", title: "View reminders", description: "Speak active reminders." }, async () => ctx.pet.speak(summary(await getReminders(ctx))));
await ctx.commands.register({ id: "clear-reminders", title: "Clear reminders", description: "Cancel all active reminders." }, async () => { await ctx.schedule.cancelAll(); await saveReminders(ctx, []); await ctx.pet.speak("Reminders cleared."); });
},
async stop() {}
});
}

View file

@ -1,12 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.quick-reminders",
"name": "Quick Reminders",
"description": "Set short local reminders from the pet menu.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "bell",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"]
}

View file

@ -1,7 +0,0 @@
import assert from "node:assert/strict";
import { cleanMessage, durationMs, summary } from "./index.js";
assert.equal(cleanMessage(" hello\nthere "), "hello there");
assert.equal(durationMs({ hours: 1, minutes: 30 }), 90 * 60_000);
assert.throws(() => durationMs({ hours: 0, minutes: 0 }));
assert.equal(summary([]), "No active reminders.");

View file

@ -0,0 +1,314 @@
// Quick Reminders (openpets.reminders) — a self-contained v3 plugin.
//
// Keeps the proven Quick Reminders interaction model (a "Set reminder..." form
// plus 15/30/60-minute presets from the pet menu) but delivers with the §21.3
// acknowledge pattern: ctx.ui.alert(...) with Done / Snooze 5m actions,
// optional custom sound, and optional OS notification. Every
// user-facing composed string flows through ctx.t(key, vars) so the host can
// localize it; nothing English is hardcoded below.
export const MAX_REMINDERS = 10;
export const MAX_MESSAGE_LENGTH = 140;
export const MAX_DELAY_MS = 24 * 60 * 60 * 1000;
export const SNOOZE_MS = 5 * 60 * 1000;
/**
* Normalize a free-text reminder message: collapse whitespace, strip newlines,
* cap length, and fall back when empty. `fallback` is already-resolved text
* (callers pass ctx.t("reminder.defaultMessage")).
*/
export function cleanMessage(value, fallback = "Reminder time.") {
const text =
typeof value === "string"
? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ")
: "";
return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback;
}
/**
* Convert a {hours, minutes} form payload into a delay in ms. Clamps each
* field to its valid range and enforces a 1-minute..24-hour window. Throws on
* an out-of-range total so the command surfaces the error to the user.
*/
export function durationMs(values = {}) {
const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0))));
const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0))));
const ms = (hours * 60 + minutes) * 60_000;
if (ms < 60_000 || ms > MAX_DELAY_MS) {
throw new Error("Reminder duration must be 1 minute to 24 hours.");
}
return ms;
}
/**
* A short, plain-text summary of the pending reminders. `now` defaults to the
* current time. Translation-agnostic on purpose: returns a compact
* "{minutes} min: {message}" join used by the pure-helper unit tests; runtime
* UI uses the dynamic ctx.ui.menu list instead.
*/
export function summary(reminders, now = Date.now()) {
if (!reminders.length) return "No active reminders.";
return reminders
.slice(0, 5)
.map((r) => `${Math.max(1, Math.ceil((r.dueAt - now) / 60_000))} min: ${r.message}`)
.join("; ");
}
// --- storage -------------------------------------------------------------
export async function getReminders(ctx) {
const reminders = await ctx.storage.get("reminders");
return Array.isArray(reminders)
? reminders
.filter(
(r) =>
r &&
typeof r.id === "string" &&
typeof r.dueAt === "number" &&
typeof r.message === "string",
)
.slice(0, MAX_REMINDERS)
: [];
}
async function saveReminders(ctx, reminders) {
const list = reminders.slice(0, MAX_REMINDERS);
await ctx.storage.set("reminders", list);
await updateStatus(ctx, list.length);
return list;
}
async function updateStatus(ctx, count) {
const text =
count > 0
? ctx.t("status.active", { count })
: ctx.t("status.none");
await ctx.status.set({ text, tone: "info" });
}
// --- scheduling + delivery ----------------------------------------------
export async function scheduleReminder(ctx, reminder) {
// schedule.once min delay is 1ms; never schedule in the past.
const delay = Math.max(1, reminder.dueAt - Date.now());
await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id));
}
export async function addReminder(ctx, message, delayMs) {
const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now());
if (reminders.length >= MAX_REMINDERS) {
throw new Error(ctx.t("error.tooMany", { max: MAX_REMINDERS }));
}
const reminder = {
// id matches [A-Za-z0-9._:-]{1,64}
id: `reminder-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.slice(0, 64),
message: cleanMessage(message, ctx.t("reminder.defaultMessage")),
dueAt: Date.now() + delayMs,
};
reminders.push(reminder);
await saveReminders(ctx, reminders);
await scheduleReminder(ctx, reminder);
await ctx.pet.speak(ctx.t("speech.set", { minutes: Math.max(1, Math.round(delayMs / 60_000)) }));
return reminder;
}
/**
* Deliver a reminder using the acknowledge pattern (§21.3). Degrades
* gracefully: a disabled toggle or an unavailable permission must never throw
* the message away the sticky bubble is the guaranteed channel.
*/
async function deliver(ctx, message, { missed = false } = {}) {
const config = (await ctx.config.get()) ?? {};
const soundEnabled = config.soundEnabled !== false;
const osNotification = config.osNotification !== false;
const text = missed
? ctx.t("bubble.missed", { message })
: ctx.t("bubble.due", { message });
let alert;
try {
alert = await ctx.ui.alert({
text,
icon: "bell",
tone: "info",
sound: soundEnabled ? config.customSound || "alert" : undefined,
notify: osNotification
? {
title: ctx.t("notify.title"),
body: missed ? ctx.t("notify.bodyMissed", { message }) : message,
}
: undefined,
dismissOn: ["petClick", "click", "action"],
actions: [
{ id: "done", label: ctx.t("action.done"), style: "primary" },
{ id: "snooze", label: ctx.t("action.snooze") },
],
});
} catch {
// If interactive bubbles aren't permitted, still surface plain speech.
try {
await ctx.pet.speak(text);
} catch {
// last resort already attempted; nothing more to do.
}
}
if (alert) {
alert.onAction(async (actionId) => {
if (actionId === "snooze") {
await addReminder(ctx, message, SNOOZE_MS);
}
// "done" needs no extra work; dismissing the bubble is the acknowledgement.
});
}
}
export async function fireReminder(ctx, id) {
const reminders = await getReminders(ctx);
const item = reminders.find((r) => r.id === id);
await saveReminders(ctx, reminders.filter((r) => r.id !== id));
if (!item) return false;
await deliver(ctx, item.message);
return true;
}
/**
* Reconcile persisted reminders against the wall clock on start(). Schedules
* are in-memory per session, so future reminders are re-registered and overdue
* ones (fired while OpenPets was closed) are delivered as "missed".
*/
export async function reconcile(ctx) {
await ctx.schedule.cancelAll();
const now = Date.now();
const reminders = await getReminders(ctx);
const future = reminders.filter((r) => r.dueAt > now);
const overdue = reminders.filter((r) => r.dueAt <= now);
await saveReminders(ctx, future);
for (const item of future) await scheduleReminder(ctx, item);
for (const item of overdue) await deliver(ctx, item.message, { missed: true });
}
// --- commands ------------------------------------------------------------
async function showReminderList(ctx) {
const reminders = await getReminders(ctx);
const now = Date.now();
const pending = reminders.filter((r) => r.dueAt > now);
if (!pending.length) {
await ctx.pet.speak(ctx.t("speech.none"));
await ctx.ui.menu.setItems([]);
return;
}
await ctx.ui.menu.setItems(
pending.slice(0, MAX_REMINDERS).map((reminder) => ({
id: `cancel:${reminder.id}`.slice(0, 64),
title: ctx.t("menu.item", {
minutes: Math.max(1, Math.ceil((reminder.dueAt - now) / 60_000)),
message: reminder.message,
}),
icon: "bell",
onSelect: async () => {
await ctx.schedule.cancel(reminder.id);
const remaining = (await getReminders(ctx)).filter((r) => r.id !== reminder.id);
await saveReminders(ctx, remaining);
await ctx.pet.speak(ctx.t("speech.cancelled", { message: reminder.message }));
await showReminderList(ctx);
},
})),
);
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await reconcile(ctx);
await ctx.commands.register(
{
id: "set-reminder",
title: "$t:command.setReminder.title",
description: "$t:command.setReminder.description",
form: {
submitLabel: "$t:command.setReminder.submit",
fields: [
{
id: "message",
type: "textarea",
label: "$t:form.message.label",
required: true,
maxLength: MAX_MESSAGE_LENGTH,
},
{
id: "hours",
type: "number",
label: "$t:form.hours.label",
default: 0,
min: 0,
max: 23,
},
{
id: "minutes",
type: "number",
label: "$t:form.minutes.label",
default: 15,
min: 0,
max: 59,
},
],
},
},
async (values) => addReminder(ctx, values.message, durationMs(values)),
);
await ctx.commands.register(
{
id: "reminder-15",
title: "$t:command.reminder15.title",
description: "$t:command.reminder15.description",
},
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 15 * 60_000),
);
await ctx.commands.register(
{
id: "reminder-30",
title: "$t:command.reminder30.title",
description: "$t:command.reminder30.description",
},
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 30 * 60_000),
);
await ctx.commands.register(
{
id: "reminder-60",
title: "$t:command.reminder60.title",
description: "$t:command.reminder60.description",
},
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 60 * 60_000),
);
await ctx.commands.register(
{
id: "view-reminders",
title: "$t:command.viewReminders.title",
description: "$t:command.viewReminders.description",
},
() => showReminderList(ctx),
);
await ctx.commands.register(
{
id: "clear-reminders",
title: "$t:command.clearReminders.title",
description: "$t:command.clearReminders.description",
},
async () => {
await ctx.schedule.cancelAll();
await saveReminders(ctx, []);
await ctx.ui.menu.setItems([]);
await ctx.pet.speak(ctx.t("speech.cleared"));
},
);
},
async stop() {},
});
}

View file

@ -0,0 +1,52 @@
{
"plugin.name": "Quick Reminders",
"plugin.description": "Set short local reminders from the pet menu, delivered with sound and a sticky alert bubble you can snooze.",
"config.soundEnabled.label": "Play a sound",
"config.soundEnabled.description": "Play an alert sound when a reminder is due.",
"config.osNotification.label": "Show a system notification",
"config.osNotification.description": "Also post a desktop notification when a reminder is due.",
"config.customSound.label": "Custom alert sound",
"config.customSound.description": "Optional sound to play instead of the default alert sound.",
"command.setReminder.title": "Set reminder…",
"command.setReminder.description": "Create a quick local reminder.",
"command.setReminder.submit": "Set Reminder",
"command.reminder15.title": "15 min reminder",
"command.reminder15.description": "Set a reminder for 15 minutes from now.",
"command.reminder30.title": "30 min reminder",
"command.reminder30.description": "Set a reminder for 30 minutes from now.",
"command.reminder60.title": "1 hour reminder",
"command.reminder60.description": "Set a reminder for 1 hour from now.",
"command.viewReminders.title": "View reminders",
"command.viewReminders.description": "List pending reminders and cancel any of them.",
"command.clearReminders.title": "Clear reminders",
"command.clearReminders.description": "Cancel all pending reminders.",
"form.message.label": "Message",
"form.hours.label": "Hours",
"form.minutes.label": "Minutes",
"reminder.defaultMessage": "Reminder time.",
"status.active": "{count} reminder(s) active",
"status.none": "No active reminders",
"speech.set": "Reminder set for {minutes} min from now.",
"speech.none": "No active reminders.",
"speech.cleared": "Reminders cleared.",
"speech.cancelled": "Cancelled: {message}",
"bubble.due": "{message}",
"bubble.missed": "Missed while away: {message}",
"action.done": "Done",
"action.snooze": "Snooze 5m",
"menu.item": "in {minutes} min: {message}",
"notify.title": "Quick Reminders",
"notify.bodyMissed": "Missed while away: {message}",
"error.tooMany": "Quick Reminders can keep up to {max} active reminders."
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "Recordatorios rápidos",
"plugin.description": "Crea recordatorios locales cortos desde el menú de la mascota, con sonido, una insignia y una burbuja fija que puedes posponer.",
"config.soundEnabled.label": "Reproducir un sonido",
"config.soundEnabled.description": "Reproduce un sonido de alerta cuando llega la hora de un recordatorio.",
"config.osNotification.label": "Mostrar una notificación del sistema",
"config.osNotification.description": "Muestra también una notificación en el escritorio cuando llega la hora de un recordatorio.",
"command.setReminder.title": "Crear recordatorio…",
"command.setReminder.description": "Crea un recordatorio local rápido.",
"command.setReminder.submit": "Crear recordatorio",
"command.reminder15.title": "Recordatorio de 15 min",
"command.reminder15.description": "Crea un recordatorio para dentro de 15 minutos.",
"command.reminder30.title": "Recordatorio de 30 min",
"command.reminder30.description": "Crea un recordatorio para dentro de 30 minutos.",
"command.reminder60.title": "Recordatorio de 1 hora",
"command.reminder60.description": "Crea un recordatorio para dentro de 1 hora.",
"command.viewReminders.title": "Ver recordatorios",
"command.viewReminders.description": "Muestra los recordatorios pendientes y cancela cualquiera de ellos.",
"command.clearReminders.title": "Borrar recordatorios",
"command.clearReminders.description": "Cancela todos los recordatorios pendientes.",
"form.message.label": "Mensaje",
"form.hours.label": "Horas",
"form.minutes.label": "Minutos",
"reminder.defaultMessage": "Hora del recordatorio.",
"status.active": "{count} recordatorio(s) activo(s)",
"status.none": "No hay recordatorios activos",
"speech.set": "Recordatorio creado para dentro de {minutes} min.",
"speech.none": "No hay recordatorios activos.",
"speech.cleared": "Recordatorios borrados.",
"speech.cancelled": "Cancelado: {message}",
"bubble.due": "{message}",
"bubble.missed": "Te lo perdiste mientras no estabas: {message}",
"action.done": "Listo",
"action.snooze": "Posponer 5 min",
"menu.item": "en {minutes} min: {message}",
"notify.title": "Recordatorios rápidos",
"notify.bodyMissed": "Te lo perdiste mientras no estabas: {message}",
"error.tooMany": "Recordatorios rápidos puede mantener hasta {max} recordatorios activos."
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "クイックリマインダー",
"plugin.description": "ペットメニューから短いローカルリマインダーを設定。サウンド、バッジ、スヌーズできる固定バブルでお知らせします。",
"config.soundEnabled.label": "サウンドを再生",
"config.soundEnabled.description": "リマインダーの時間になったら通知音を鳴らします。",
"config.osNotification.label": "システム通知を表示",
"config.osNotification.description": "リマインダーの時間になったらデスクトップ通知も表示します。",
"command.setReminder.title": "リマインダーを設定…",
"command.setReminder.description": "短いローカルリマインダーを作成します。",
"command.setReminder.submit": "リマインダーを設定",
"command.reminder15.title": "15分後のリマインダー",
"command.reminder15.description": "今から15分後のリマインダーを設定します。",
"command.reminder30.title": "30分後のリマインダー",
"command.reminder30.description": "今から30分後のリマインダーを設定します。",
"command.reminder60.title": "1時間後のリマインダー",
"command.reminder60.description": "今から1時間後のリマインダーを設定します。",
"command.viewReminders.title": "リマインダーを表示",
"command.viewReminders.description": "保留中のリマインダーを一覧表示し、キャンセルできます。",
"command.clearReminders.title": "リマインダーを消去",
"command.clearReminders.description": "保留中のリマインダーをすべてキャンセルします。",
"form.message.label": "メッセージ",
"form.hours.label": "時間",
"form.minutes.label": "分",
"reminder.defaultMessage": "リマインダーの時間です。",
"status.active": "{count}件のリマインダーが有効",
"status.none": "有効なリマインダーはありません",
"speech.set": "今から{minutes}分後にリマインダーを設定しました。",
"speech.none": "有効なリマインダーはありません。",
"speech.cleared": "リマインダーを消去しました。",
"speech.cancelled": "キャンセルしました: {message}",
"bubble.due": "{message}",
"bubble.missed": "離席中に見逃しました: {message}",
"action.done": "完了",
"action.snooze": "5分スヌーズ",
"menu.item": "{minutes}分後: {message}",
"notify.title": "クイックリマインダー",
"notify.bodyMissed": "離席中に見逃しました: {message}",
"error.tooMany": "クイックリマインダーで有効にできるのは最大{max}件までです。"
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "빠른 알림",
"plugin.description": "펫 메뉴에서 간단한 로컬 알림을 설정하면 소리, 배지, 그리고 미루기 가능한 고정 말풍선으로 알려드려요.",
"config.soundEnabled.label": "소리 재생",
"config.soundEnabled.description": "알림 시간이 되면 알림음을 재생해요.",
"config.osNotification.label": "시스템 알림 표시",
"config.osNotification.description": "알림 시간이 되면 데스크톱 알림도 함께 표시해요.",
"command.setReminder.title": "알림 설정…",
"command.setReminder.description": "간단한 로컬 알림을 만들어요.",
"command.setReminder.submit": "알림 설정",
"command.reminder15.title": "15분 알림",
"command.reminder15.description": "지금부터 15분 뒤로 알림을 설정해요.",
"command.reminder30.title": "30분 알림",
"command.reminder30.description": "지금부터 30분 뒤로 알림을 설정해요.",
"command.reminder60.title": "1시간 알림",
"command.reminder60.description": "지금부터 1시간 뒤로 알림을 설정해요.",
"command.viewReminders.title": "알림 보기",
"command.viewReminders.description": "대기 중인 알림을 확인하고 취소할 수 있어요.",
"command.clearReminders.title": "알림 모두 지우기",
"command.clearReminders.description": "대기 중인 알림을 모두 취소해요.",
"form.message.label": "메시지",
"form.hours.label": "시간",
"form.minutes.label": "분",
"reminder.defaultMessage": "알림 시간이에요.",
"status.active": "알림 {count}개 활성화됨",
"status.none": "활성화된 알림 없음",
"speech.set": "지금부터 {minutes}분 뒤로 알림을 설정했어요.",
"speech.none": "활성화된 알림이 없어요.",
"speech.cleared": "알림을 모두 지웠어요.",
"speech.cancelled": "취소됨: {message}",
"bubble.due": "{message}",
"bubble.missed": "자리 비운 사이 놓침: {message}",
"action.done": "완료",
"action.snooze": "5분 미루기",
"menu.item": "{minutes}분 뒤: {message}",
"notify.title": "빠른 알림",
"notify.bodyMissed": "자리 비운 사이 놓침: {message}",
"error.tooMany": "빠른 알림은 최대 {max}개까지 활성화할 수 있어요."
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "Lembretes Rápidos",
"plugin.description": "Crie lembretes locais rápidos pelo menu do pet, com som, um selo e um balão fixo que você pode adiar.",
"config.soundEnabled.label": "Tocar um som",
"config.soundEnabled.description": "Toca um som de alerta quando um lembrete vence.",
"config.osNotification.label": "Mostrar uma notificação do sistema",
"config.osNotification.description": "Também exibe uma notificação na área de trabalho quando um lembrete vence.",
"command.setReminder.title": "Criar lembrete…",
"command.setReminder.description": "Crie um lembrete local rápido.",
"command.setReminder.submit": "Criar lembrete",
"command.reminder15.title": "Lembrete em 15 min",
"command.reminder15.description": "Cria um lembrete para daqui a 15 minutos.",
"command.reminder30.title": "Lembrete em 30 min",
"command.reminder30.description": "Cria um lembrete para daqui a 30 minutos.",
"command.reminder60.title": "Lembrete em 1 hora",
"command.reminder60.description": "Cria um lembrete para daqui a 1 hora.",
"command.viewReminders.title": "Ver lembretes",
"command.viewReminders.description": "Liste os lembretes pendentes e cancele qualquer um deles.",
"command.clearReminders.title": "Limpar lembretes",
"command.clearReminders.description": "Cancele todos os lembretes pendentes.",
"form.message.label": "Mensagem",
"form.hours.label": "Horas",
"form.minutes.label": "Minutos",
"reminder.defaultMessage": "Hora do lembrete.",
"status.active": "{count} lembrete(s) ativo(s)",
"status.none": "Nenhum lembrete ativo",
"speech.set": "Lembrete definido para daqui a {minutes} min.",
"speech.none": "Nenhum lembrete ativo.",
"speech.cleared": "Lembretes limpos.",
"speech.cancelled": "Cancelado: {message}",
"bubble.due": "{message}",
"bubble.missed": "Perdido enquanto você estava fora: {message}",
"action.done": "Concluído",
"action.snooze": "Adiar 5 min",
"menu.item": "em {minutes} min: {message}",
"notify.title": "Lembretes Rápidos",
"notify.bodyMissed": "Perdido enquanto você estava fora: {message}",
"error.tooMany": "Os Lembretes Rápidos podem manter até {max} lembretes ativos."
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "快速提醒",
"plugin.description": "从宠物菜单设置简短的本地提醒,到点时会通过声音、角标和可稍后提醒的悬浮气泡通知你。",
"config.soundEnabled.label": "播放声音",
"config.soundEnabled.description": "提醒到点时播放提示音。",
"config.osNotification.label": "显示系统通知",
"config.osNotification.description": "提醒到点时同时弹出桌面通知。",
"command.setReminder.title": "设置提醒…",
"command.setReminder.description": "创建一个快速的本地提醒。",
"command.setReminder.submit": "设置提醒",
"command.reminder15.title": "15 分钟后提醒",
"command.reminder15.description": "设置一个 15 分钟后的提醒。",
"command.reminder30.title": "30 分钟后提醒",
"command.reminder30.description": "设置一个 30 分钟后的提醒。",
"command.reminder60.title": "1 小时后提醒",
"command.reminder60.description": "设置一个 1 小时后的提醒。",
"command.viewReminders.title": "查看提醒",
"command.viewReminders.description": "列出待处理的提醒,并可取消其中任意一个。",
"command.clearReminders.title": "清除提醒",
"command.clearReminders.description": "取消所有待处理的提醒。",
"form.message.label": "内容",
"form.hours.label": "小时",
"form.minutes.label": "分钟",
"reminder.defaultMessage": "提醒时间到了。",
"status.active": "有 {count} 个提醒进行中",
"status.none": "没有进行中的提醒",
"speech.set": "已设置 {minutes} 分钟后的提醒。",
"speech.none": "没有进行中的提醒。",
"speech.cleared": "提醒已清除。",
"speech.cancelled": "已取消:{message}",
"bubble.due": "{message}",
"bubble.missed": "离开时错过:{message}",
"action.done": "完成",
"action.snooze": "稍后提醒 5 分钟",
"menu.item": "{minutes} 分钟后:{message}",
"notify.title": "快速提醒",
"notify.bodyMissed": "离开时错过:{message}",
"error.tooMany": "快速提醒最多可保留 {max} 个进行中的提醒。"
}

View file

@ -0,0 +1,50 @@
{
"plugin.name": "快速提醒",
"plugin.description": "從寵物選單設定簡短的本機提醒,到時會以音效、徽章和可延後的常駐泡泡提醒你。",
"config.soundEnabled.label": "播放音效",
"config.soundEnabled.description": "提醒到時播放提示音效。",
"config.osNotification.label": "顯示系統通知",
"config.osNotification.description": "提醒到時同時發送桌面通知。",
"command.setReminder.title": "設定提醒…",
"command.setReminder.description": "建立一個快速的本機提醒。",
"command.setReminder.submit": "設定提醒",
"command.reminder15.title": "15 分鐘提醒",
"command.reminder15.description": "設定 15 分鐘後的提醒。",
"command.reminder30.title": "30 分鐘提醒",
"command.reminder30.description": "設定 30 分鐘後的提醒。",
"command.reminder60.title": "1 小時提醒",
"command.reminder60.description": "設定 1 小時後的提醒。",
"command.viewReminders.title": "查看提醒",
"command.viewReminders.description": "列出待處理的提醒,並可取消其中任何一個。",
"command.clearReminders.title": "清除提醒",
"command.clearReminders.description": "取消所有待處理的提醒。",
"form.message.label": "訊息",
"form.hours.label": "小時",
"form.minutes.label": "分鐘",
"reminder.defaultMessage": "提醒時間到了。",
"status.active": "有 {count} 個提醒進行中",
"status.none": "沒有進行中的提醒",
"speech.set": "已設定 {minutes} 分鐘後的提醒。",
"speech.none": "沒有進行中的提醒。",
"speech.cleared": "提醒已清除。",
"speech.cancelled": "已取消:{message}",
"bubble.due": "{message}",
"bubble.missed": "離開時錯過了:{message}",
"action.done": "完成",
"action.snooze": "延後 5 分鐘",
"menu.item": "{minutes} 分鐘後:{message}",
"notify.title": "快速提醒",
"notify.bodyMissed": "離開時錯過了:{message}",
"error.tooMany": "快速提醒最多只能保留 {max} 個進行中的提醒。"
}

View file

@ -0,0 +1,40 @@
{
"manifestVersion": 3,
"id": "openpets.reminders",
"name": "$t:plugin.name",
"description": "$t:plugin.description",
"version": "1.0.0",
"runtime": "javascript",
"icon": "bell",
"sdkVersion": "3.0.0",
"entry": "index.js",
"permissions": [
"pet:speak",
"pet:interact",
"audio",
"schedule",
"storage",
"commands",
"status",
"notify"
],
"configSchema": {
"soundEnabled": {
"type": "boolean",
"default": true,
"label": "$t:config.soundEnabled.label",
"description": "$t:config.soundEnabled.description"
},
"osNotification": {
"type": "boolean",
"default": true,
"label": "$t:config.osNotification.label",
"description": "$t:config.osNotification.description"
},
"customSound": {
"type": "sound",
"label": "$t:config.customSound.label",
"description": "$t:config.customSound.description"
}
}
}

View file

@ -0,0 +1,177 @@
// Golden test for openpets.reminders.
//
// Runs two ways:
// * `node test.js` (via scripts/test-plugins.mjs) — pure-helper unit checks
// plus the harness-driven golden test.
// * authored against `@open-pets/plugin-sdk/testing`; when that bare
// specifier isn't resolvable from this directory we fall back to the
// built workspace dist so the test still runs standalone.
import assert from "node:assert/strict";
import {
cleanMessage,
durationMs,
summary,
register,
MAX_REMINDERS,
} from "./index.js";
let createTestHarness;
try {
({ createTestHarness } = await import("@open-pets/plugin-sdk/testing"));
} catch {
({ createTestHarness } = await import(
new URL("../../../packages/sdk/dist/testing.js", import.meta.url)
));
}
// --- pure helper unit checks --------------------------------------------
assert.equal(cleanMessage(" hello\nthere "), "hello there");
assert.equal(cleanMessage("", "fallback"), "fallback");
assert.equal(cleanMessage("x".repeat(500)).length, 140);
assert.equal(durationMs({ hours: 1, minutes: 30 }), 90 * 60_000);
assert.equal(durationMs({ minutes: 15 }), 15 * 60_000);
assert.throws(() => durationMs({ hours: 0, minutes: 0 }));
// Out-of-range fields are clamped, not rejected: 25h -> 23h is still valid.
assert.equal(durationMs({ hours: 25, minutes: 0 }), 23 * 60 * 60_000);
assert.equal(summary([]), "No active reminders.");
assert.equal(
summary([{ id: "a", message: "tea", dueAt: 1_000 + 5 * 60_000 }], 1_000),
"5 min: tea",
);
// --- golden harness test -------------------------------------------------
const PERMISSIONS = [
"pet:speak",
"pet:interact",
"audio",
"schedule",
"storage",
"commands",
"status",
"notify",
];
const LOCALES = {
en: JSON.parse(
await (await import("node:fs/promises")).readFile(
new URL("./locales/en.json", import.meta.url),
"utf8",
),
),
};
// 1) Setting a reminder via the form schedules it and stores it.
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
config: { soundEnabled: true, osNotification: true, customSound: "gong" },
locales: LOCALES,
nowMs: 1_000_000,
});
await h.start();
await h.runCommand("set-reminder", { message: "Drink water", hours: 0, minutes: 30 });
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 1 && v[0].message === "Drink water");
h.expectSpoke(/30 min/);
assert.equal(h.calls.schedules.size, 1, "expected one scheduled reminder");
// Advancing past the due time fires the acknowledge-pattern delivery.
await h.clock.advance("31m");
h.expectBubble({ icon: "bell", tone: "info", sticky: true, priority: "high" });
h.expectBubble({ textMatch: /Drink water/ });
h.expectNotified(/Drink water/);
assert.equal(h.calls.alerts.length, 1, "expected ctx.ui.alert delivery");
assert.ok(h.calls.sounds.some((s) => s.sound === "gong"), "expected the custom alert sound to play");
// Fired reminder is removed from storage.
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
h.expectNoErrors();
}
// 2) A preset reminder fires and the Snooze action reschedules +5m.
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
config: { soundEnabled: false, osNotification: false },
locales: LOCALES,
nowMs: 2_000_000,
});
await h.start();
await h.runCommand("reminder-15");
h.expectStored("reminders", (v) => v.length === 1);
assert.equal(h.calls.sounds.length, 0, "sound disabled — nothing should play");
await h.clock.advance("16m");
const bubble = h.calls.bubbles[h.calls.bubbles.length - 1];
assert.ok(bubble, "expected a delivery bubble");
assert.deepEqual(
bubble.spec.actions?.map((a) => a.id),
["done", "snooze"],
"expected Done + Snooze actions",
);
assert.equal(h.calls.alerts.length, 1, "preset delivery should use ctx.ui.alert");
assert.equal(h.calls.notifications.length, 0, "osNotification disabled — no notification");
// Snooze: reschedules a fresh reminder ~5 minutes out.
await h.fireBubbleAction(bubble.handle.id, "snooze");
h.expectStored("reminders", (v) => v.length === 1 && v[0].dueAt > h.clock.now());
h.expectNoErrors();
}
// 3) view-reminders lists pending items with a per-item cancel.
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
locales: LOCALES,
nowMs: 3_000_000,
});
await h.start();
await h.runCommand("reminder-30");
await h.runCommand("reminder-60");
await h.runCommand("view-reminders");
assert.equal(h.calls.menuItems.length, 2, "expected two pending menu items");
// Selecting an item's cancel removes that reminder.
await h.calls.menuItems[0].onSelect();
h.expectStored("reminders", (v) => v.length === 1);
h.expectNoErrors();
}
// 4) reconcile() on start fires overdue reminders as "missed".
{
const h = createTestHarness(register, {
permissions: PERMISSIONS,
config: { soundEnabled: true, osNotification: true },
locales: LOCALES,
nowMs: 4_000_000,
});
// Seed storage with an already-overdue reminder before start().
await h.ctx.storage.set("reminders", [
{ id: "reminder-old", message: "Stand up", dueAt: 4_000_000 - 60_000 },
]);
await h.start();
h.expectBubble({ textMatch: /Stand up/ });
h.expectNotified(/Stand up/);
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
h.expectNoErrors();
}
// 5) clear-reminders cancels everything.
{
const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES });
await h.start();
await h.runCommand("reminder-15");
await h.runCommand("clear-reminders");
h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
assert.equal(h.calls.schedules.size, 0, "expected no scheduled reminders after clear");
h.expectSpoke(/cleared/i);
h.expectNoErrors();
}
// 6) MAX_REMINDERS guard.
assert.equal(MAX_REMINDERS, 10);
console.log("openpets.reminders: all checks passed.");

View file

@ -1,60 +0,0 @@
const INTERVALS = { rare: 20 * 60_000, normal: 15 * 60_000, often: 10 * 60_000 };
const DISTANCES = { small: 60, medium: 110 };
const DURATIONS = { subtle: 900, playful: 650 };
function normalizeTime(value, fallback) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value ?? ""));
if (!match) return fallback;
const hour = Number(match[1]);
const minute = Number(match[2]);
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 ? `${match[1]}:${match[2]}` : fallback;
}
export function isQuietNow(config = {}, now = new Date()) {
if (config.quietHoursEnabled === false) return false;
const start = normalizeTime(config.quietStart, "22:00");
const end = normalizeTime(config.quietEnd, "08:00");
const current = now.getHours() * 60 + now.getMinutes();
const s = Number(start.slice(0, 2)) * 60 + Number(start.slice(3));
const e = Number(end.slice(0, 2)) * 60 + Number(end.slice(3));
return s <= e ? current >= s && current < e : current >= s || current < e;
}
export function movementConfig(config = {}) {
const style = ["off", "subtle", "playful"].includes(config.movementStyle) ? config.movementStyle : "subtle";
const frequency = Object.prototype.hasOwnProperty.call(INTERVALS, config.frequency) ? config.frequency : "rare";
const maxDistance = Object.prototype.hasOwnProperty.call(DISTANCES, config.maxDistance) ? config.maxDistance : "small";
return { style, intervalMs: INTERVALS[frequency], distance: DISTANCES[maxDistance], durationMs: DURATIONS[style] ?? 900 };
}
export async function takeWalk(ctx, config = {}) {
const movement = movementConfig(config);
if (movement.style === "off" || isQuietNow(config)) return false;
await ctx.pet.wander({ distance: movement.distance, durationMs: movement.durationMs });
await ctx.storage.set("lastWalk", { at: new Date().toISOString(), distance: movement.distance });
return true;
}
export async function reschedule(ctx, config = {}) {
await ctx.schedule.cancelAll();
const movement = movementConfig(config);
if (movement.style === "off") {
await ctx.status.set({ text: "Wander Buddy is off", tone: "info" });
return;
}
await ctx.schedule.every("wander", movement.intervalMs, () => takeWalk(ctx, config));
await ctx.status.set({ text: `Wandering ${movement.style} · every ${Math.round(movement.intervalMs / 60_000)} min`, tone: "info" });
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await reschedule(ctx, await ctx.config.get());
await ctx.commands.register({ id: "take-little-walk", title: "Take a little walk", description: "Move the pet a short safe distance." }, async () => { const config = await ctx.config.get(); return takeWalk(ctx, { ...config, quietHoursEnabled: false, movementStyle: movementConfig(config).style === "off" ? "subtle" : config.movementStyle }); });
await ctx.commands.register({ id: "return-home", title: "Return home", description: "Move the pet back to its home corner." }, async () => ctx.pet.moveToHome());
await ctx.commands.register({ id: "stay-still", title: "Stay still for now", description: "Pause Wander Buddy until the plugin is reloaded or settings change." }, async () => { await ctx.schedule.cancelAll(); await ctx.status.set({ text: "Staying still for now", tone: "success" }); });
ctx.config.onChange?.((next) => reschedule(ctx, next));
},
async stop() {}
});
}

View file

@ -1,20 +0,0 @@
{
"manifestVersion": 2,
"id": "openpets.wander-buddy",
"name": "Wander Buddy",
"description": "Lets your default pet take occasional safe little walks while staying quiet and unobtrusive.",
"version": "1.0.0",
"runtime": "javascript",
"icon": "sparkles",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:move", "schedule", "storage", "commands", "status"],
"configSchema": {
"movementStyle": { "type": "select", "label": "Movement style", "default": "subtle", "options": [{ "label": "Off", "value": "off" }, { "label": "Subtle", "value": "subtle" }, { "label": "Playful", "value": "playful" }] },
"frequency": { "type": "select", "label": "Frequency", "description": "How often Wander Buddy tries a safe movement. Often is limited to every 10 minutes.", "default": "rare", "options": [{ "label": "Rare", "value": "rare" }, { "label": "Normal", "value": "normal" }, { "label": "Often", "value": "often" }] },
"maxDistance": { "type": "select", "label": "Max distance", "default": "small", "options": [{ "label": "Small", "value": "small" }, { "label": "Medium", "value": "medium" }] },
"quietHoursEnabled": { "type": "boolean", "label": "Quiet hours", "default": true },
"quietStart": { "type": "time", "label": "Quiet start", "default": "22:00" },
"quietEnd": { "type": "time", "label": "Quiet end", "default": "08:00" }
}
}

View file

@ -1,35 +0,0 @@
import assert from "node:assert/strict";
import { isQuietNow, movementConfig, reschedule, takeWalk } from "./index.js";
assert.equal(isQuietNow({ quietStart: "22:00", quietEnd: "08:00" }, new Date("2026-01-01T23:30:00")), true);
assert.equal(isQuietNow({ quietStart: "22:00", quietEnd: "08:00" }, new Date("2026-01-01T12:00:00")), false);
assert.deepEqual(movementConfig({ movementStyle: "playful", frequency: "often", maxDistance: "medium" }), { style: "playful", intervalMs: 600000, distance: 110, durationMs: 650 });
const calls = [];
const ctx = {
pet: { wander: async (options) => calls.push(["wander", options]), moveToHome: async () => calls.push(["home"]) },
schedule: { cancelAll: async () => calls.push(["cancelAll"]), every: async (id, ms, fn) => calls.push(["every", id, ms, fn]) },
storage: { set: async (key, value) => calls.push(["set", key, value]) },
status: { set: async (status) => calls.push(["status", status]) },
};
assert.equal(await takeWalk(ctx, { quietHoursEnabled: false, movementStyle: "subtle", maxDistance: "small" }), true);
assert.equal(calls[0][0], "wander");
assert.deepEqual(calls[0][1], { distance: 60, durationMs: 900 });
calls.length = 0;
await reschedule(ctx, { movementStyle: "off" });
assert.deepEqual(calls.map((call) => call[0]), ["cancelAll", "status"]);
calls.length = 0;
await reschedule(ctx, { quietHoursEnabled: false, movementStyle: "subtle", frequency: "rare" });
assert.deepEqual(calls.slice(0, 3).map((call) => call[0]), ["cancelAll", "every", "status"]);
assert.equal(calls[1][1], "wander");
assert.equal(calls[1][2] >= 10 * 60_000, true);
calls.length = 0;
await reschedule(ctx, { quietHoursEnabled: false, movementStyle: "playful", frequency: "often", maxDistance: "medium" });
assert.equal(calls[1][2], 10 * 60_000);
console.log("Wander Buddy plugin tests passed.");