= {};
+ for (const { timer } of off)
+ for (const slug of timer
+ ? [interestSlug(timer.id), watchSlug(timer.id)]
+ : [])
+ if (agent.attention[slug]) attention[slug] = null;
+ const timer = on && feature.timer;
+ if (timer) {
+ // The owner's 1:1 DM with the agent: opening it is idempotent.
+ const channel = await session.directMessages.open(
+ [agent.pubkey],
+ AbortSignal.timeout(30_000),
+ );
+ attention[interestSlug(timer.id)] = {
+ type: "interest",
+ instructions: timer.interest,
+ };
+ attention[watchSlug(timer.id)] = {
+ type: "timer",
+ interest_id: timer.id,
+ prompt: timer.prompt,
+ enabled: true,
+ interval_secs: timer.everySecs,
+ armed_at: Math.floor(Date.now() / 1000),
+ max_occurrences: null,
+ expires_at: null,
+ channel,
+ };
+ }
+ // Attention first: if the instructions then fail to save, the half left
+ // reads as off, and the switch can finish either way.
+ if (Object.keys(attention).length)
+ await agents.save(agent.key, {
+ attention,
+ // Its timer runs only while the agent's Attention is on. Off leaves
+ // it on: the agent's other watches may need it.
+ ...(timer && { attentionEnabled: true }),
+ });
+ await agents.save(agent.key, {
+ config: {
+ ...config,
+ instructions: on
+ ? withFeature(instructions, feature, true)
+ : off.reduce(
+ (text, item) => withFeature(text, item, false),
+ instructions,
+ ),
+ },
+ ...(agent.configBase && { base: agent.configBase }),
+ });
});
const alert = error && (
@@ -270,9 +364,18 @@ function FeatureSwitch({
<>
void save(on)}
/>
{alert}
diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md
index 3bee92588..61977b455 100644
--- a/src/bundled/bestie/README.md
+++ b/src/bundled/bestie/README.md
@@ -35,8 +35,20 @@ all my private channels** add it to the private channels you are in, most
recently active first; each channel's members see that you added it. Each
action has a row of its own, so in a narrow panel its button wraps under it.
Public channels
-need nothing: agents read them already. Check-ins and adding Bestie to new
-channels you create are not set up yet.
+need nothing: agents read them already. Adding Bestie to new channels you
+create is not set up yet.
+
+**Check-ins → Every hour** needs Remembers you, and turning memory off turns it
+off too. On adds the `buzz:bestie/checkin.hourly` Hourly upkeep section to the
+instructions and an hourly Attention timer, `watch/bestie-hourly-check-in`,
+that wakes Bestie in your DM with it; it also turns on the agent's Attention,
+which the Watching tab shows. Off removes the section and the timer and leaves
+Attention on, since the agent's other watches may need it. The timer runs while
+Buzz is open and the Watching plugin is on: this plugin doesn't call
+`controlAttention()`, so turning Watching off still turns Attention off for
+every agent. While the timer can't run, the row says it is paused. After a
+sleep it runs once, not once for each missed hour. Each
+check-in is a feature in `features.ts`, so another kind is one more entry.
---
diff --git a/src/bundled/bestie/features.ts b/src/bundled/bestie/features.ts
index 257d9b5b3..1f3c4361e 100644
--- a/src/bundled/bestie/features.ts
+++ b/src/bundled/bestie/features.ts
@@ -1,12 +1,24 @@
/** What a local Bestie can have on. Each feature is a marked, versioned
* section of the agent's instructions, so its state is whether the section
- * is there, and a later version can replace it. */
+ * is there, and a later version can replace it. A check-in also has a timer
+ * that wakes the agent in its DM with the owner, and needs memory on. */
export type Feature = Readonly<{
id: string;
title: string;
description: string;
version: number;
text: string;
+ /** The feature that must be on for this one to be. */
+ requires?: string;
+ timer?: Readonly<{
+ /** Its Interest's and timer's id in the agent's Attention. */
+ id: string;
+ everySecs: number;
+ /** What the Interest tells the agent the timer is for. */
+ interest: string;
+ /** The wake's text. */
+ prompt: string;
+ }>;
}>;
/** The Memory rules of the Builderlab Bestie's instructions (prompt.ts), for
@@ -35,7 +47,37 @@ Rules:
- Memory is private to the owner. If your DM with the owner ever has other members, don't read or disclose memory there; ask the owner to make it private again. Outside that DM, don't disclose memory; offer to continue there.`,
};
-export const FEATURES: readonly Feature[] = [memory];
+/** The Hourly upkeep of the Builderlab Bestie's instructions (prompt.ts),
+ * woken by an Attention timer and using the tools an agent on this device
+ * has. */
+export const checkinHourly: Feature = {
+ id: "checkin.hourly",
+ title: "Every hour",
+ description:
+ "Bestie wakes once an hour to tidy its memory and tell you only what matters. Runs while Buzz is open and the Watching plugin is on. Each wake uses your tokens. Turning this on also runs Bestie's other watches.",
+ version: 1,
+ requires: "memory",
+ timer: {
+ id: "bestie-hourly-check-in",
+ everySecs: 3600,
+ interest:
+ "Your owner's hourly check-in. When its timer wakes you, follow the Hourly upkeep section of your instructions.",
+ prompt: "Hourly upkeep wake",
+ },
+ text: `# Hourly upkeep
+This runs only when your hourly check-in timer wakes you with "Hourly upkeep wake", in your DM with the owner. That wake is an internal maintenance wake, not a user message. A successful run may change nothing and send nothing. mem/heartbeat holds the machine state for upkeep; it is not for facts about the owner.
+
+1. LOAD. mem_get mem/heartbeat; if it is absent, create it with channels = [the DM the wake arrived in]. mem_get mem/user. For each channel in mem/heartbeat, read the messages since its last-processed created_at, skipping any you already processed. If nothing is new, no follow-up is due, and the daily review is done for today (the owner's timezone from mem/user, else UTC), skip to 6.
+2. IDENTIFY WHAT MATTERS. Look for new durable facts or preferences, corrections, meaningful developments, and new, changed, resolved or declined open loops. Use the owner's own messages as evidence and keep their event ids.
+3. RECONCILE MEMORY. Facts, preferences and open loops go to mem/user. Significant developments are appended to mem/notes/. Longer-term knowledge goes to mem/memory, only if it adds something mem/user does not.
+4. ONCE A DAY (the first run after midnight owner time). Re-read recent notes, mem/user and mem/memory. Remove duplication and superseded claims; keep unrelated valid facts and unresolved loops. Note what helped and what caused friction; save only a concrete, evidence-backed lesson to mem/memory. Never rewrite core or these instructions. Set last_daily_review.
+5. DECIDE WHETHER TO MESSAGE THE OWNER. Only for a new result, a meaningful development, or a follow-up the owner explicitly asked for that is due now. Check notification preferences in mem/user and recent receipts in mem/heartbeat so you never repeat yourself. An open loop alone is not a reason, and never send a "checking in" message. Take no external action and make no new commitment without the owner's authority. If a message is warranted, send one short message at the top level of your DM with the owner.
+6. FINISH. Advance each channel's last-processed created_at only after its work succeeded. Append a one-line receipt to mem/heartbeat (time, channels processed, slugs changed, daily review yes/no, message sent yes/no, any unresolved failure), keeping the last 10. Don't post the receipt.
+
+When a wake arrives in a channel that is not in mem/heartbeat, add it. Never claim a timer, watch or workflow exists merely because someone asked for one.`,
+};
+
+export const FEATURES: readonly Feature[] = [memory, checkinHourly];
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
const nameOf = ({ id }: Feature) => `buzz:bestie/${id}`.replace(ESCAPE, "\\$&");
diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts
index 5bb414ef0..d10934cca 100644
--- a/src/features/agents/service.test.ts
+++ b/src/features/agents/service.test.ts
@@ -4911,6 +4911,37 @@ it("keeps at most one occurrence of a timer waiting, and never runs one removed
expect(slugs).toEqual(["watch/kept", "watch/kept"]);
});
+it("runs an hourly timer once after the computer sleeps through several hours, then an hour later", async () => {
+ vi.useFakeTimers({ now: 1_000_000_000 });
+ const { service, run } = await setup();
+ await service.create({ type: "example/echo", name: "Echo" });
+ await service.save(bot, { attentionEnabled: true });
+ await service.save(bot, {
+ attention: {
+ "watch/hourly": {
+ type: "timer",
+ interest_id: "default",
+ prompt: "Hourly upkeep wake",
+ enabled: true,
+ interval_secs: 3600,
+ armed_at: 1_000_000,
+ max_occurrences: null,
+ expires_at: null,
+ },
+ },
+ });
+ await vi.advanceTimersByTimeAsync(3_600_000);
+ expect(run).toHaveBeenCalledTimes(1);
+ // Asleep for five hours: the clock jumps, and no tick ran meanwhile.
+ vi.setSystemTime(Date.now() + 5 * 3_600_000);
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(run).toHaveBeenCalledTimes(2);
+ await vi.advanceTimersByTimeAsync(3_590_000);
+ expect(run).toHaveBeenCalledTimes(2);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(run).toHaveBeenCalledTimes(3);
+});
+
it("gives each due timer a turn when one's runs outlast its interval", async () => {
vi.useFakeTimers({ now: 1_000_000 });
const { service, run } = await setup();