@burmese/cli 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * SDK v3 plugin templates for `openpets plugin new --template <name>` (§18.4).
3
+ * Each template ships a typed entry, a valid manifestVersion-3 manifest, a
4
+ * passing test built on `@burmese/plugin-sdk/testing`, and a README.
5
+ */
6
+ export const pluginTemplateNames = ["blank", "reminder", "ambient", "ai-chat", "tamagotchi", "calendar"];
7
+ const sharedTestHeader = `import assert from "node:assert/strict";
8
+ import { createTestHarness } from "@burmese/plugin-sdk/testing";
9
+ import { register } from "./index.js";
10
+ `;
11
+ export const pluginTemplates = {
12
+ blank: {
13
+ description: "A minimal starting point with one command.",
14
+ permissions: ["pet:speak", "pet:reaction", "commands", "status"],
15
+ configSchema: {},
16
+ entry: ({ name }) => `/// <reference types="@burmese/plugin-sdk" />
17
+
18
+ export function register(OpenPetsPlugin) {
19
+ OpenPetsPlugin.register({
20
+ async start(ctx) {
21
+ await ctx.status.set({ text: ${JSON.stringify(`${name} is ready`)}, tone: "info" });
22
+
23
+ await ctx.commands.register(
24
+ { id: "say-hello", title: "Say hello", description: "Get a friendly greeting." },
25
+ async () => {
26
+ await ctx.pet.speak(${JSON.stringify(`Hello from ${name}!`)});
27
+ await ctx.pet.react("waving");
28
+ },
29
+ );
30
+ },
31
+
32
+ async stop() {},
33
+ });
34
+ }
35
+ `,
36
+ test: () => `${sharedTestHeader}
37
+ const h = createTestHarness(register, { permissions: ["pet:speak", "pet:reaction", "commands", "status"] });
38
+ await h.start();
39
+ await h.runCommand("say-hello");
40
+ h.expectSpoke(/hello/i);
41
+ h.expectReacted("waving");
42
+ console.log("blank template tests passed.");
43
+ `,
44
+ },
45
+ reminder: {
46
+ 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().",
47
+ permissions: ["pet:speak", "pet:interact", "audio", "schedule", "storage", "commands", "status", "notify"],
48
+ configSchema: {
49
+ soundEnabled: { type: "boolean", label: "$t:config.soundEnabled.label", description: "$t:config.soundEnabled.description", default: true },
50
+ osNotification: { type: "boolean", label: "$t:config.osNotification.label", description: "$t:config.osNotification.description", default: true },
51
+ customSound: { type: "sound", label: "$t:config.customSound.label", description: "$t:config.customSound.description" },
52
+ },
53
+ entry: () => `/// <reference types="@burmese/plugin-sdk" />
54
+ //
55
+ // Quick reminders that mirror the shipped openpets.reminders reference plugin.
56
+ // Keeps a "Set reminder…" form plus 15/30/60-minute presets, but delivers with
57
+ // the acknowledge pattern: ctx.ui.alert(...) with Done / Snooze 5m actions,
58
+ // optional custom sound, and optional OS notification. Host-rendered
59
+ // static strings use $t: manifest refs; every runtime-composed body flows
60
+ // through ctx.t(key, vars) so the host can localize it.
61
+
62
+ export const MAX_REMINDERS = 10;
63
+ export const MAX_MESSAGE_LENGTH = 140;
64
+ export const MAX_DELAY_MS = 24 * 60 * 60 * 1000;
65
+ export const SNOOZE_MS = 5 * 60 * 1000;
66
+
67
+ export function cleanMessage(value, fallback = "Reminder time.") {
68
+ const text =
69
+ typeof value === "string"
70
+ ? value.trim().replace(/[\\r\\n]+/g, " ").replace(/\\s+/g, " ")
71
+ : "";
72
+ return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback;
73
+ }
74
+
75
+ export function durationMs(values = {}) {
76
+ const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0))));
77
+ const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0))));
78
+ const ms = (hours * 60 + minutes) * 60_000;
79
+ if (ms < 60_000 || ms > MAX_DELAY_MS) {
80
+ throw new Error("Reminder duration must be 1 minute to 24 hours.");
81
+ }
82
+ return ms;
83
+ }
84
+
85
+ export async function getReminders(ctx) {
86
+ const reminders = await ctx.storage.get("reminders");
87
+ return Array.isArray(reminders)
88
+ ? reminders
89
+ .filter(
90
+ (r) =>
91
+ r &&
92
+ typeof r.id === "string" &&
93
+ typeof r.dueAt === "number" &&
94
+ typeof r.message === "string",
95
+ )
96
+ .slice(0, MAX_REMINDERS)
97
+ : [];
98
+ }
99
+
100
+ async function saveReminders(ctx, reminders) {
101
+ const list = reminders.slice(0, MAX_REMINDERS);
102
+ await ctx.storage.set("reminders", list);
103
+ await updateStatus(ctx, list.length);
104
+ return list;
105
+ }
106
+
107
+ async function updateStatus(ctx, count) {
108
+ const text = count > 0 ? ctx.t("status.active", { count }) : ctx.t("status.none");
109
+ await ctx.status.set({ text, tone: "info" });
110
+ }
111
+
112
+ export async function scheduleReminder(ctx, reminder) {
113
+ const delay = Math.max(1, reminder.dueAt - Date.now());
114
+ await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id));
115
+ }
116
+
117
+ export async function addReminder(ctx, message, delayMs) {
118
+ const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now());
119
+ if (reminders.length >= MAX_REMINDERS) {
120
+ throw new Error(ctx.t("error.tooMany", { max: MAX_REMINDERS }));
121
+ }
122
+ const reminder = {
123
+ id: \`reminder-\${Date.now().toString(36)}-\${Math.floor(Math.random() * 1e6).toString(36)}\`.slice(0, 64),
124
+ message: cleanMessage(message, ctx.t("reminder.defaultMessage")),
125
+ dueAt: Date.now() + delayMs,
126
+ };
127
+ reminders.push(reminder);
128
+ await saveReminders(ctx, reminders);
129
+ await scheduleReminder(ctx, reminder);
130
+ await ctx.pet.speak(ctx.t("speech.set", { minutes: Math.max(1, Math.round(delayMs / 60_000)) }));
131
+ return reminder;
132
+ }
133
+
134
+ async function deliver(ctx, message, { missed = false } = {}) {
135
+ const config = (await ctx.config.get()) ?? {};
136
+ const soundEnabled = config.soundEnabled !== false;
137
+ const osNotification = config.osNotification !== false;
138
+
139
+ const text = missed ? ctx.t("bubble.missed", { message }) : ctx.t("bubble.due", { message });
140
+
141
+ let alert;
142
+ try {
143
+ alert = await ctx.ui.alert({
144
+ text,
145
+ icon: "bell",
146
+ tone: "info",
147
+ sound: soundEnabled ? config.customSound || "alert" : undefined,
148
+ notify: osNotification
149
+ ? { title: ctx.t("notify.title"), body: missed ? ctx.t("notify.bodyMissed", { message }) : message }
150
+ : undefined,
151
+ dismissOn: ["petClick", "click", "action"],
152
+ actions: [
153
+ { id: "done", label: ctx.t("action.done"), style: "primary" },
154
+ { id: "snooze", label: ctx.t("action.snooze") },
155
+ ],
156
+ });
157
+ } catch {
158
+ try {
159
+ await ctx.pet.speak(text);
160
+ } catch {
161
+ // last resort already attempted.
162
+ }
163
+ }
164
+
165
+ if (alert) {
166
+ alert.onAction(async (actionId) => {
167
+ if (actionId === "snooze") {
168
+ await addReminder(ctx, message, SNOOZE_MS);
169
+ }
170
+ });
171
+ }
172
+
173
+ }
174
+
175
+ export async function fireReminder(ctx, id) {
176
+ const reminders = await getReminders(ctx);
177
+ const item = reminders.find((r) => r.id === id);
178
+ await saveReminders(ctx, reminders.filter((r) => r.id !== id));
179
+ if (!item) return false;
180
+ await deliver(ctx, item.message);
181
+ return true;
182
+ }
183
+
184
+ export async function reconcile(ctx) {
185
+ await ctx.schedule.cancelAll();
186
+ const now = Date.now();
187
+ const reminders = await getReminders(ctx);
188
+ const future = reminders.filter((r) => r.dueAt > now);
189
+ const overdue = reminders.filter((r) => r.dueAt <= now);
190
+ await saveReminders(ctx, future);
191
+ for (const item of future) await scheduleReminder(ctx, item);
192
+ for (const item of overdue) await deliver(ctx, item.message, { missed: true });
193
+ }
194
+
195
+ async function showReminderList(ctx) {
196
+ const reminders = await getReminders(ctx);
197
+ const now = Date.now();
198
+ const pending = reminders.filter((r) => r.dueAt > now);
199
+ if (!pending.length) {
200
+ await ctx.pet.speak(ctx.t("speech.none"));
201
+ await ctx.ui.menu.setItems([]);
202
+ return;
203
+ }
204
+ await ctx.ui.menu.setItems(
205
+ pending.slice(0, MAX_REMINDERS).map((reminder) => ({
206
+ id: \`cancel:\${reminder.id}\`.slice(0, 64),
207
+ title: ctx.t("menu.item", {
208
+ minutes: Math.max(1, Math.ceil((reminder.dueAt - now) / 60_000)),
209
+ message: reminder.message,
210
+ }),
211
+ icon: "bell",
212
+ onSelect: async () => {
213
+ await ctx.schedule.cancel(reminder.id);
214
+ const remaining = (await getReminders(ctx)).filter((r) => r.id !== reminder.id);
215
+ await saveReminders(ctx, remaining);
216
+ await ctx.pet.speak(ctx.t("speech.cancelled", { message: reminder.message }));
217
+ await showReminderList(ctx);
218
+ },
219
+ })),
220
+ );
221
+ }
222
+
223
+ export function register(OpenPetsPlugin) {
224
+ OpenPetsPlugin.register({
225
+ async start(ctx) {
226
+ await reconcile(ctx);
227
+
228
+ await ctx.commands.register(
229
+ {
230
+ id: "set-reminder",
231
+ title: "$t:command.setReminder.title",
232
+ description: "$t:command.setReminder.description",
233
+ form: {
234
+ submitLabel: "$t:command.setReminder.submit",
235
+ fields: [
236
+ { id: "message", type: "textarea", label: "$t:form.message.label", required: true, maxLength: MAX_MESSAGE_LENGTH },
237
+ { id: "hours", type: "number", label: "$t:form.hours.label", default: 0, min: 0, max: 23 },
238
+ { id: "minutes", type: "number", label: "$t:form.minutes.label", default: 15, min: 0, max: 59 },
239
+ ],
240
+ },
241
+ },
242
+ async (values) => addReminder(ctx, values.message, durationMs(values)),
243
+ );
244
+
245
+ await ctx.commands.register(
246
+ { id: "reminder-15", title: "$t:command.reminder15.title", description: "$t:command.reminder15.description" },
247
+ () => addReminder(ctx, ctx.t("reminder.defaultMessage"), 15 * 60_000),
248
+ );
249
+ await ctx.commands.register(
250
+ { id: "reminder-30", title: "$t:command.reminder30.title", description: "$t:command.reminder30.description" },
251
+ () => addReminder(ctx, ctx.t("reminder.defaultMessage"), 30 * 60_000),
252
+ );
253
+ await ctx.commands.register(
254
+ { id: "reminder-60", title: "$t:command.reminder60.title", description: "$t:command.reminder60.description" },
255
+ () => addReminder(ctx, ctx.t("reminder.defaultMessage"), 60 * 60_000),
256
+ );
257
+
258
+ await ctx.commands.register(
259
+ { id: "view-reminders", title: "$t:command.viewReminders.title", description: "$t:command.viewReminders.description" },
260
+ () => showReminderList(ctx),
261
+ );
262
+
263
+ await ctx.commands.register(
264
+ { id: "clear-reminders", title: "$t:command.clearReminders.title", description: "$t:command.clearReminders.description" },
265
+ async () => {
266
+ await ctx.schedule.cancelAll();
267
+ await saveReminders(ctx, []);
268
+ await ctx.ui.menu.setItems([]);
269
+ await ctx.pet.speak(ctx.t("speech.cleared"));
270
+ },
271
+ );
272
+ },
273
+ async stop() {},
274
+ });
275
+ }
276
+ `,
277
+ test: () => `import assert from "node:assert/strict";
278
+ import { createTestHarness } from "@burmese/plugin-sdk/testing";
279
+ import { register, cleanMessage, durationMs, MAX_REMINDERS } from "./index.js";
280
+
281
+ const PERMISSIONS = [
282
+ "pet:speak",
283
+ "pet:interact",
284
+ "audio",
285
+ "schedule",
286
+ "storage",
287
+ "commands",
288
+ "status",
289
+ "notify",
290
+ ];
291
+
292
+ const LOCALES = {
293
+ en: JSON.parse(
294
+ await (await import("node:fs/promises")).readFile(new URL("./locales/en.json", import.meta.url), "utf8"),
295
+ ),
296
+ };
297
+
298
+ // --- pure helper unit checks --------------------------------------------
299
+ assert.equal(cleanMessage(" hello\\nthere "), "hello there");
300
+ assert.equal(cleanMessage("", "fallback"), "fallback");
301
+ assert.equal(durationMs({ hours: 1, minutes: 30 }), 90 * 60_000);
302
+ assert.throws(() => durationMs({ hours: 0, minutes: 0 }));
303
+ assert.equal(MAX_REMINDERS, 10);
304
+
305
+ // 1) Setting a reminder via the form schedules it, then fires with the
306
+ // acknowledge pattern (sound + bubble + notification).
307
+ {
308
+ const h = createTestHarness(register, {
309
+ permissions: PERMISSIONS,
310
+ config: { soundEnabled: true, osNotification: true, customSound: "gong" },
311
+ locales: LOCALES,
312
+ nowMs: 1_000_000,
313
+ });
314
+ await h.start();
315
+
316
+ await h.runCommand("set-reminder", { message: "Drink water", hours: 0, minutes: 30 });
317
+ h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 1 && v[0].message === "Drink water");
318
+
319
+ await h.clock.advance("31m");
320
+ h.expectBubble({ icon: "bell", tone: "info", sticky: true, priority: "high" });
321
+ h.expectBubble({ textMatch: /Drink water/ });
322
+ h.expectNotified(/Drink water/);
323
+ assert.equal(h.calls.alerts.length, 1, "expected ctx.ui.alert delivery");
324
+ assert.ok(h.calls.sounds.some((s) => s.sound === "gong"), "expected the custom alert sound to play");
325
+ h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
326
+ h.expectNoErrors();
327
+ }
328
+
329
+ // 2) A preset fires and the Snooze action reschedules +5m.
330
+ {
331
+ const h = createTestHarness(register, {
332
+ permissions: PERMISSIONS,
333
+ config: { soundEnabled: false, osNotification: false },
334
+ locales: LOCALES,
335
+ nowMs: 2_000_000,
336
+ });
337
+ await h.start();
338
+ await h.runCommand("reminder-15");
339
+ h.expectStored("reminders", (v) => v.length === 1);
340
+
341
+ await h.clock.advance("16m");
342
+ const bubble = h.calls.bubbles[h.calls.bubbles.length - 1];
343
+ assert.deepEqual(bubble.spec.actions?.map((a) => a.id), ["done", "snooze"]);
344
+ await h.fireBubbleAction(bubble.handle.id, "snooze");
345
+ h.expectStored("reminders", (v) => v.length === 1 && v[0].dueAt > h.clock.now());
346
+ h.expectNoErrors();
347
+ }
348
+
349
+ // 3) clear-reminders cancels everything.
350
+ {
351
+ const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES });
352
+ await h.start();
353
+ await h.runCommand("reminder-15");
354
+ await h.runCommand("clear-reminders");
355
+ h.expectStored("reminders", (v) => Array.isArray(v) && v.length === 0);
356
+ h.expectSpoke(/cleared/i);
357
+ h.expectNoErrors();
358
+ }
359
+
360
+ console.log("reminder template tests passed.");
361
+ `,
362
+ locales: () => ({
363
+ "config.soundEnabled.label": "Play a sound",
364
+ "config.soundEnabled.description": "Play an alert sound when a reminder is due.",
365
+ "config.osNotification.label": "Show a system notification",
366
+ "config.osNotification.description": "Also post a desktop notification when a reminder is due.",
367
+ "config.customSound.label": "Custom alert sound",
368
+ "config.customSound.description": "Optional sound to play instead of the default alert sound.",
369
+ "command.setReminder.title": "Set reminder…",
370
+ "command.setReminder.description": "Create a quick local reminder.",
371
+ "command.setReminder.submit": "Set Reminder",
372
+ "command.reminder15.title": "15 min reminder",
373
+ "command.reminder15.description": "Set a reminder for 15 minutes from now.",
374
+ "command.reminder30.title": "30 min reminder",
375
+ "command.reminder30.description": "Set a reminder for 30 minutes from now.",
376
+ "command.reminder60.title": "1 hour reminder",
377
+ "command.reminder60.description": "Set a reminder for 1 hour from now.",
378
+ "command.viewReminders.title": "View reminders",
379
+ "command.viewReminders.description": "List pending reminders and cancel any of them.",
380
+ "command.clearReminders.title": "Clear reminders",
381
+ "command.clearReminders.description": "Cancel all pending reminders.",
382
+ "form.message.label": "Message",
383
+ "form.hours.label": "Hours",
384
+ "form.minutes.label": "Minutes",
385
+ "reminder.defaultMessage": "Reminder time.",
386
+ "status.active": "{count} reminder(s) active",
387
+ "status.none": "No active reminders",
388
+ "speech.set": "Reminder set for {minutes} min from now.",
389
+ "speech.none": "No active reminders.",
390
+ "speech.cleared": "Reminders cleared.",
391
+ "speech.cancelled": "Cancelled: {message}",
392
+ "bubble.due": "{message}",
393
+ "bubble.missed": "Missed while away: {message}",
394
+ "action.done": "Done",
395
+ "action.snooze": "Snooze 5m",
396
+ "menu.item": "in {minutes} min: {message}",
397
+ "notify.title": "Quick Reminders",
398
+ "notify.bodyMissed": "Missed while away: {message}",
399
+ "error.tooMany": "Quick Reminders can keep up to {max} active reminders.",
400
+ }),
401
+ },
402
+ ambient: {
403
+ description: "Gentle ambient presence driven by the senses bus.",
404
+ permissions: ["pet:speak", "pet:reaction", "schedule", "events", "status"],
405
+ configSchema: {
406
+ checkInMinutes: { type: "number", label: "Check-in minutes", default: 45, min: 10, max: 480 },
407
+ },
408
+ entry: () => `/// <reference types="@burmese/plugin-sdk" />
409
+
410
+ export function register(OpenPetsPlugin) {
411
+ OpenPetsPlugin.register({
412
+ async start(ctx) {
413
+ const config = await ctx.config.get();
414
+ const intervalMinutes = Math.max(10, Number(config.checkInMinutes ?? 45));
415
+
416
+ await ctx.schedule.every("ambient-check-in", intervalMinutes * 60_000, async () => {
417
+ await ctx.pet.speak("Still here with you.");
418
+ });
419
+
420
+ ctx.events.on("idle:exit", () => {
421
+ void ctx.pet.react("waving");
422
+ });
423
+
424
+ ctx.events.on("pet:clicked", () => {
425
+ void ctx.pet.react("celebrating");
426
+ });
427
+
428
+ ctx.events.on("day:partChanged", (event) => {
429
+ if (event.part === "evening") void ctx.pet.speak("Evening already. Pace yourself.");
430
+ });
431
+ },
432
+ });
433
+ }
434
+ `,
435
+ test: () => `${sharedTestHeader}
436
+ const h = createTestHarness(register, {
437
+ permissions: ["pet:speak", "pet:reaction", "schedule", "events", "status"],
438
+ config: { checkInMinutes: 45 },
439
+ });
440
+ await h.start();
441
+ h.expectScheduled("ambient-check-in");
442
+ await h.clock.advance("45m");
443
+ h.expectSpoke(/still here/i);
444
+ await h.emit("pet:clicked", { petId: "default" });
445
+ h.expectReacted("celebrating");
446
+ await h.emit("day:partChanged", { part: "evening" });
447
+ h.expectSpoke(/evening/i);
448
+ h.expectNoErrors();
449
+ console.log("ambient template tests passed.");
450
+ `,
451
+ },
452
+ "ai-chat": {
453
+ description: "A chat pet on the host AI gateway with model-generated speech.",
454
+ permissions: ["pet:speak", "pet:speak:dynamic", "pet:interact", "pet:reaction", "commands", "status", "ai"],
455
+ configSchema: {
456
+ personality: { type: "textarea", label: "Personality", default: "You are a tiny upbeat desktop pet. Reply in one short sentence.", maxLength: 500 },
457
+ },
458
+ entry: () => `/// <reference types="@burmese/plugin-sdk" />
459
+
460
+ export function register(OpenPetsPlugin) {
461
+ OpenPetsPlugin.register({
462
+ async start(ctx) {
463
+ await ctx.commands.register(
464
+ {
465
+ id: "ask-pet",
466
+ title: "Ask the pet…",
467
+ form: { fields: [{ id: "question", type: "text", label: "Question", maxLength: 300, required: true }], submitLabel: "Ask" },
468
+ },
469
+ async (values) => {
470
+ if (!(await ctx.ai.available())) {
471
+ await ctx.pet.speak("No AI provider is set up yet.");
472
+ return;
473
+ }
474
+ const config = await ctx.config.get();
475
+ await ctx.pet.react("thinking");
476
+ const bubble = await ctx.pet.speak({ text: "…", dynamic: true, sticky: true });
477
+ let answer = "";
478
+ await ctx.ai.stream(
479
+ { system: String(config.personality ?? ""), messages: [{ role: "user", content: String(values?.question ?? "") }], maxTokens: 200 },
480
+ (token) => {
481
+ answer += token;
482
+ void bubble.update({ markdown: answer, dynamic: true });
483
+ },
484
+ );
485
+ await bubble.update({ markdown: answer, dynamic: true, sticky: false, durationMs: 12_000 });
486
+ await ctx.pet.react("success");
487
+ },
488
+ );
489
+ },
490
+ });
491
+ }
492
+ `,
493
+ test: () => `${sharedTestHeader}
494
+ const h = createTestHarness(register, {
495
+ permissions: ["pet:speak", "pet:speak:dynamic", "pet:interact", "pet:reaction", "commands", "status", "ai"],
496
+ });
497
+ h.ai.mock(() => "I feel sleepy but happy.");
498
+ await h.start();
499
+ await h.runCommand("ask-pet", { question: "How do you feel?" });
500
+ assert.equal(h.calls.aiCalls.length, 1);
501
+ h.expectReacted("success");
502
+ const live = h.calls.bubbles.find((bubble) => bubble.spec.dynamic);
503
+ assert.ok(live, "asked question produced a dynamic bubble");
504
+ assert.ok(live.updates.length > 0, "streaming updated the bubble in place");
505
+ console.log("ai-chat template tests passed.");
506
+ `,
507
+ },
508
+ tamagotchi: {
509
+ description: "A virtual pet with needs, moods, feeding, and a live stats pin.",
510
+ permissions: ["pet:speak", "pet:interact", "pet:pin", "pet:reaction", "commands", "status", "schedule", "storage", "events", "audio", "notify"],
511
+ configSchema: {
512
+ decayMinutes: { type: "number", label: "Need decay minutes", default: 30, min: 10, max: 240 },
513
+ },
514
+ entry: ({ name }) => `/// <reference types="@burmese/plugin-sdk" />
515
+
516
+ const clamp = (value) => Math.max(0, Math.min(100, Math.round(value)));
517
+
518
+ export function register(OpenPetsPlugin) {
519
+ OpenPetsPlugin.register({
520
+ async start(ctx) {
521
+ const config = await ctx.config.get();
522
+ const decayMinutes = Math.max(10, Number(config.decayMinutes ?? 30));
523
+
524
+ const loadStats = async () => {
525
+ const stats = (await ctx.storage.get("stats")) ?? { hunger: 80, energy: 80, affection: 60, lastSeen: Date.now() };
526
+ // Catch up decay across restarts/sleep from wall-clock time.
527
+ const elapsedTicks = Math.floor((Date.now() - (stats.lastSeen ?? Date.now())) / (decayMinutes * 60_000));
528
+ if (elapsedTicks > 0) {
529
+ stats.hunger = clamp(stats.hunger - elapsedTicks * 6);
530
+ stats.energy = clamp(stats.energy - elapsedTicks * 4);
531
+ stats.affection = clamp(stats.affection - elapsedTicks * 3);
532
+ }
533
+ stats.lastSeen = Date.now();
534
+ await ctx.storage.set("stats", stats);
535
+ return stats;
536
+ };
537
+
538
+ const moodOf = (stats) => {
539
+ if (stats.hunger < 25) return "hungry";
540
+ if (stats.energy < 25) return "sleepy";
541
+ if (stats.affection < 25) return "lonely";
542
+ return "happy";
543
+ };
544
+
545
+ let pinned = null;
546
+ const refreshPin = async (stats) => {
547
+ const mood = moodOf(stats);
548
+ const text = mood === "happy"
549
+ ? \`Mood: happy · 🍖 \${stats.hunger} ⚡ \${stats.energy} ♥ \${stats.affection}\`
550
+ : \`I'm \${mood}! · 🍖 \${stats.hunger} ⚡ \${stats.energy} ♥ \${stats.affection}\`;
551
+ if (pinned) { await pinned.update({ text }); return; }
552
+ pinned = await ctx.pet.speak({ text, pin: true, icon: "heart", priority: mood === "happy" ? "low" : "high" });
553
+ pinned.onDismiss(() => { pinned = null; });
554
+ };
555
+
556
+ const applyMood = async (stats) => {
557
+ const mood = moodOf(stats);
558
+ if (mood === "hungry") await ctx.pet.react("waiting");
559
+ else if (mood === "sleepy") await ctx.pet.react("idle");
560
+ else if (mood === "lonely") await ctx.pet.react("error");
561
+ else await ctx.pet.react("success");
562
+ await refreshPin(stats);
563
+ };
564
+
565
+ const stats = await loadStats();
566
+ await applyMood(stats);
567
+
568
+ await ctx.schedule.every("decay", decayMinutes * 60_000, async () => {
569
+ const current = await loadStats();
570
+ current.hunger = clamp(current.hunger - 6);
571
+ current.energy = clamp(current.energy - 4);
572
+ current.affection = clamp(current.affection - 3);
573
+ await ctx.storage.set("stats", current);
574
+ await applyMood(current);
575
+ if (moodOf(current) !== "happy") {
576
+ await ctx.notify.notify({ title: ${JSON.stringify(name)}, body: "Your pet needs attention." });
577
+ }
578
+ });
579
+
580
+ ctx.events.on("pet:clicked", async () => {
581
+ const current = await loadStats();
582
+ current.affection = clamp(current.affection + 8);
583
+ await ctx.storage.set("stats", current);
584
+ await ctx.pet.speak({ text: "♥", icon: "heart", durationMs: 1500, priority: "low" });
585
+ await applyMood(current);
586
+ });
587
+
588
+ await ctx.commands.register({ id: "feed", title: "Feed", placement: "top", priority: 10 }, async () => {
589
+ const current = await loadStats();
590
+ current.hunger = clamp(current.hunger + 30);
591
+ await ctx.storage.set("stats", current);
592
+ await ctx.audio.play("nom").catch(() => undefined);
593
+ await ctx.pet.speak({ text: "Nom nom!", icon: "food", durationMs: 2500 });
594
+ await applyMood(current);
595
+ });
596
+
597
+ await ctx.commands.register({ id: "play", title: "Play", placement: "top", priority: 9 }, async () => {
598
+ const current = await loadStats();
599
+ current.affection = clamp(current.affection + 15);
600
+ current.energy = clamp(current.energy - 10);
601
+ await ctx.storage.set("stats", current);
602
+ await ctx.pet.react("celebrating");
603
+ await applyMood(current);
604
+ });
605
+
606
+ await ctx.commands.register({ id: "nap", title: "Nap time" }, async () => {
607
+ const current = await loadStats();
608
+ current.energy = clamp(current.energy + 40);
609
+ await ctx.storage.set("stats", current);
610
+ await ctx.pet.speak("Zzz…");
611
+ await applyMood(current);
612
+ });
613
+ },
614
+ });
615
+ }
616
+ `,
617
+ test: () => `${sharedTestHeader}
618
+ const PERMISSIONS = ["pet:speak", "pet:interact", "pet:pin", "pet:reaction", "commands", "status", "schedule", "storage", "events", "audio", "notify"];
619
+ const h = createTestHarness(register, { permissions: PERMISSIONS, config: { decayMinutes: 30 } });
620
+ await h.start();
621
+ h.expectScheduled("decay");
622
+ h.expectStored("stats", (stats) => stats.hunger <= 100 && stats.hunger >= 0);
623
+ h.expectBubble({ pin: true });
624
+
625
+ await h.runCommand("feed");
626
+ h.expectSpoke(/nom/i);
627
+ h.expectStored("stats", (stats) => stats.hunger >= 80);
628
+
629
+ await h.emit("pet:clicked", { petId: "default" });
630
+ h.expectStored("stats", (stats) => stats.affection > 60);
631
+
632
+ // Needs decay over time and the pet complains when neglected.
633
+ await h.clock.advance("4h");
634
+ h.expectStored("stats", (stats) => stats.hunger < 100);
635
+ h.expectNoErrors();
636
+ console.log("tamagotchi template tests passed.");
637
+ `,
638
+ },
639
+ calendar: {
640
+ description: "Calendar companion: .ics import, countdown pin, event reminders.",
641
+ permissions: ["pet:speak", "pet:interact", "pet:pin", "pet:reaction", "commands", "status", "schedule", "storage", "files", "notify"],
642
+ configSchema: {
643
+ reminderMinutes: { type: "number", label: "Remind before (minutes)", default: 10, min: 1, max: 120 },
644
+ },
645
+ entry: () => `/// <reference types="@burmese/plugin-sdk" />
646
+
647
+ const parseIcs = (text) => {
648
+ const events = [];
649
+ for (const block of text.split("BEGIN:VEVENT").slice(1)) {
650
+ const summary = /SUMMARY:(.*)/.exec(block)?.[1]?.trim();
651
+ const start = /DTSTART(?:;[^:]*)?:(\\d{8}T\\d{6}Z?)/.exec(block)?.[1];
652
+ if (!summary || !start) continue;
653
+ const iso = start.replace(/^(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})(Z?)$/, "$1-$2-$3T$4:$5:$6$7");
654
+ const startsAt = Date.parse(iso);
655
+ if (Number.isFinite(startsAt)) events.push({ summary: summary.slice(0, 120), startsAt });
656
+ }
657
+ return events.sort((a, b) => a.startsAt - b.startsAt);
658
+ };
659
+
660
+ export function register(OpenPetsPlugin) {
661
+ OpenPetsPlugin.register({
662
+ async start(ctx) {
663
+ const config = await ctx.config.get();
664
+ const reminderMinutes = Math.max(1, Number(config.reminderMinutes ?? 10));
665
+ let pinned = null;
666
+
667
+ const armReminders = async () => {
668
+ const events = (await ctx.storage.get("events")) ?? [];
669
+ const upcoming = events.filter((event) => event.startsAt > Date.now());
670
+ await ctx.storage.set("events", upcoming);
671
+ const next = upcoming[0];
672
+ if (!next) { if (pinned) { await pinned.dismiss(); pinned = null; } return; }
673
+ const label = \`Next: \${next.summary}\`;
674
+ if (pinned) await pinned.update({ text: label });
675
+ else {
676
+ pinned = await ctx.pet.speak({ text: label, pin: true, icon: "timer" });
677
+ pinned.onDismiss(() => { pinned = null; });
678
+ }
679
+ const remindAt = new Date(next.startsAt - reminderMinutes * 60_000).toISOString();
680
+ await ctx.schedule.at("next-event-reminder", remindAt, async () => {
681
+ // Drop the reminded event first so re-arming never repeats it.
682
+ const remaining = ((await ctx.storage.get("events")) ?? []).filter((event) => !(event.summary === next.summary && event.startsAt === next.startsAt));
683
+ await ctx.storage.set("events", remaining);
684
+ await ctx.notify.notify({ title: "Upcoming event", body: next.summary });
685
+ await ctx.pet.speak({ text: \`\${next.summary} in \${reminderMinutes} min\`, icon: "bell", sticky: true, actions: [{ id: "ok", label: "OK", style: "primary" }] });
686
+ await ctx.pet.react("waiting");
687
+ await armReminders();
688
+ });
689
+ };
690
+
691
+ await ctx.commands.register({ id: "import-ics", title: "Import calendar (.ics)" }, async () => {
692
+ const files = await ctx.files.pick({ accept: [".ics"] });
693
+ if (files.length === 0) return;
694
+ const text = await files[0].readText();
695
+ const events = parseIcs(text).filter((event) => event.startsAt > Date.now()).slice(0, 50);
696
+ await ctx.storage.set("events", events);
697
+ await ctx.pet.speak(\`Imported \${events.length} upcoming events.\`);
698
+ await armReminders();
699
+ });
700
+
701
+ await ctx.commands.register({ id: "whats-next", title: "What's next?" }, async () => {
702
+ const events = (await ctx.storage.get("events")) ?? [];
703
+ const next = events.find((event) => event.startsAt > Date.now());
704
+ await ctx.pet.speak(next ? \`Next up: \${next.summary}\` : "Nothing on the calendar.");
705
+ });
706
+
707
+ await armReminders();
708
+ },
709
+ });
710
+ }
711
+ `,
712
+ test: () => `${sharedTestHeader}
713
+ const PERMISSIONS = ["pet:speak", "pet:interact", "pet:pin", "pet:reaction", "commands", "status", "schedule", "storage", "files", "notify"];
714
+ const h = createTestHarness(register, { permissions: PERMISSIONS, config: { reminderMinutes: 10 } });
715
+ const startsAt = new Date(h.clock.now() + 60 * 60_000);
716
+ const stamp = startsAt.toISOString().replace(/[-:]/g, "").replace(/\\.\\d{3}/, "");
717
+ h.files.provide([{ name: "work.ics", text: "BEGIN:VEVENT\\nSUMMARY:Standup\\nDTSTART:" + stamp + "\\nEND:VEVENT" }]);
718
+ await h.start();
719
+ await h.runCommand("import-ics");
720
+ h.expectSpoke(/imported 1 upcoming/i);
721
+ h.expectBubble({ pin: true });
722
+ h.expectScheduled("next-event-reminder");
723
+ await h.clock.advance("51m");
724
+ h.expectNotified(/standup/i);
725
+ h.expectNoErrors();
726
+ console.log("calendar template tests passed.");
727
+ `,
728
+ },
729
+ };
730
+ //# sourceMappingURL=plugin-templates.js.map