diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx index 7f4a155e5..1d19a5c29 100644 --- a/src/bundled/bestie/BestieTab.test.tsx +++ b/src/bundled/bestie/BestieTab.test.tsx @@ -3,11 +3,12 @@ import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import type { Agent, Agents } from "../../features/agents/service"; +import type { Agent, AgentChange, Agents } from "../../features/agents/service"; import type { RelayData } from "../../features/relay/service"; import { writeView } from "../../shared/view-state"; import { bestieTab } from "./BestieTab"; -import { memory, withFeature } from "./features"; +import { validateObject } from "../../features/agents/attention"; +import { checkinHourly, memory, withFeature } from "./features"; afterEach(() => { cleanup(); @@ -17,6 +18,7 @@ afterEach(() => { const OWNER = "f".repeat(64); const SCOPE = `https://relay.example:${OWNER}`; const NO_CHANNELS = { channels: [] }; +const DM = "0f0f0f0f-0000-4000-8000-000000000001"; const member = (name: string, digit: string): Agent => ({ key: digit.repeat(64), @@ -26,7 +28,8 @@ const member = (name: string, digit: string): Agent => owner: OWNER, relay: "wss://relay.example", place: "here", - }) as Agent; + attention: {}, + }) as unknown as Agent; const ada = member("Ada", "a"); const bea = member("Bea", "b"); @@ -66,6 +69,7 @@ function community(saved: string | undefined, agents = [ada, bea]) { scope: SCOPE, session: { viewer: OWNER, + directMessages: { open: vi.fn(async () => DM) }, channels: { list: () => NO_CHANNELS, subscribeList: () => () => {}, @@ -280,8 +284,114 @@ it("offers no switch while the agent's instructions can't be read here", () => { />, ); expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull(); + // Each setting's row says why, check-ins too. expect( - screen.getByText("This agent's instructions could not be read"), - ).toBeVisible(); + screen.getAllByText("This agent's instructions could not be read"), + ).toHaveLength(2); expect(save).not.toHaveBeenCalled(); }); + +it("checks in every hour only while memory is on, with a timer in the owner's DM", async () => { + const user = userEvent.setup(); + const { relay } = community(ada.pubkey); + const { agents, save } = host(); + const Tab = bestieTab(relay, agents).component; + const base = { instructions: "head-1" }; + const remembering = withFeature("Be brief.", memory, true); + const off = { + ...ada, + config: { instructions: "Be brief." }, + configBase: base, + }; + const { rerender } = render(); + const hourly = () => screen.getByRole("switch", { name: "Every hour" }); + // Memory is off, so check-ins can't be turned on. + expect(hourly()).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Turn on Remembers you first.")).toBeVisible(); + + const on = { ...off, config: { instructions: remembering } } as Agent; + rerender(); + expect(hourly()).not.toBeChecked(); + await user.click(hourly()); + // The timer first, then the instructions that say what it is for. + expect(save).toHaveBeenCalledTimes(2); + const [[, timers], [, instructions]] = save.mock.calls as [ + [string, AgentChange], + [string, AgentChange], + ]; + expect(instructions).toEqual({ + config: { instructions: withFeature(remembering, checkinHourly, true) }, + base, + }); + // Turning it on lets the agent's Attention run. + expect(timers.attentionEnabled).toBe(true); + expect(timers.attention?.["watch/bestie-hourly-check-in"]).toMatchObject({ + type: "timer", + interest_id: "bestie-hourly-check-in", + interval_secs: 3600, + max_occurrences: null, + expires_at: null, + channel: DM, + enabled: true, + }); + // The objects are ones Attention accepts. + for (const [slug, value] of Object.entries(timers.attention ?? {})) + expect(validateObject(slug, value)).toBeUndefined(); + + // On means the section and the timer are both there. + const checking = { + ...on, + config: { instructions: withFeature(remembering, checkinHourly, true) }, + attention: { + "interest/bestie-hourly-check-in": {}, + "watch/bestie-hourly-check-in": {}, + }, + } as unknown as Agent; + rerender(); + expect(hourly()).toBeChecked(); + // With Attention not running here, as while Watching is off, it says so. + expect(screen.getByText(/^Paused: watches aren't running/)).toBeVisible(); + rerender(); + expect(screen.queryByText(/^Paused/)).toBeNull(); + rerender(); + expect(hourly()).not.toBeChecked(); + rerender(); + + const removed = { + attention: { + "interest/bestie-hourly-check-in": null, + "watch/bestie-hourly-check-in": null, + }, + }; + // Memory off takes check-ins with it, and leaves Attention's switch alone. + save.mockClear(); + await user.click(screen.getByRole("switch", { name: "Remembers you" })); + expect(save.mock.calls).toEqual([ + [ada.key, removed], + [ada.key, { config: { instructions: "Be brief." }, base }], + ]); + + // Check-ins off removes only their section and timer. + save.mockClear(); + await user.click(hourly()); + expect(save.mock.calls).toEqual([ + [ada.key, removed], + [ada.key, { config: { instructions: remembering.trimEnd() }, base }], + ]); + + // Left on after memory went some other way, it can still be turned off. + rerender( + , + ); + expect(hourly()).toBeChecked(); + expect(hourly()).not.toHaveAttribute("aria-disabled", "true"); +}); diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx index 1bfdce44f..3cd6f670e 100644 --- a/src/bundled/bestie/BestieTab.tsx +++ b/src/bundled/bestie/BestieTab.tsx @@ -32,6 +32,12 @@ import { restoredFeature, withFeature, } from "./features"; +import { + type AttentionValue, + interestSlug, + watchSlug, +} from "../../features/agents/attention"; +import type { RelaySession } from "../../features/relay/session"; import { PrivateChannels } from "./PrivateChannels"; type TabAgents = Pick; @@ -121,6 +127,8 @@ function BestieTab({ }) { const { session, scope } = useRelayConnection(relay); const bestie = useBestieChoice(session, scope ?? ""); + // One save at a time: each switch saves from the instructions it shows. + const [saving, setSaving] = useState(false); const others = bestie.choices.selectable.filter( (choice) => choice.pubkey !== agent.pubkey, ); @@ -158,18 +166,31 @@ function BestieTab({ - {FEATURES.map((feature) => ( + {FEATURES.filter((feature) => !feature.timer).map((feature) => ( + ))} + + + + {FEATURES.filter((feature) => feature.timer).map((feature) => ( + ))} - {bestie.view} @@ -177,20 +198,35 @@ function BestieTab({ ); } -/** A feature's switch: on while the agent's instructions hold its section. - * Saving revises the synced instructions the tab shows, as their editor does. - * A section edited by hand where it starts or ends shows that instead, with - * Restore when what is left of its markers shows where it was. */ +/** Whether `agent` has `feature` on: its section, and its timer if it has + * one. Half of one, such as a timer deleted in Watching, reads as off. */ +function featureOn(agent: Agent, instructions: string, feature: Feature) { + return ( + hasFeature(instructions, feature) && + (!feature.timer || !!agent.attention[watchSlug(feature.timer.id)]) + ); +} + +/** A feature's switch. Saving revises the synced instructions the tab shows, + * as their editor does, with the feature's timer in the same change. Off also + * turns off the features that require this one. A section edited by hand + * where it starts or ends shows that instead, with Restore when what is left + * of its markers shows where it was. */ function FeatureSwitch({ agents, agent, + session, feature, + saving, + setSaving, }: { agents: TabAgents; agent: Agent; + session: RelaySession; feature: Feature; + saving: boolean; + setSaving(saving: boolean): void; }) { - const [saving, setSaving] = useState(false); const [error, setError] = useState(); // A type without `adopt` has no instructions, as `updateKind` says. if (!agents.types().find(({ key }) => key === agent.type)?.adopt) @@ -209,6 +245,9 @@ function FeatureSwitch({ const config = (agent.config ?? {}) as { instructions?: unknown }; const instructions = typeof config.instructions === "string" ? config.instructions : ""; + const required = FEATURES.find(({ id }) => id === feature.requires); + const blocked = !!required && !featureOn(agent, instructions, required); + const checked = featureOn(agent, instructions, feature); const run = async (task: () => Promise) => { setSaving(true); setError(undefined); @@ -232,7 +271,62 @@ function FeatureSwitch({ throw new Error( `${feature.title} was edited in Instructions; restore it first.`, ); - await saveInstructions(withFeature(instructions, feature, on)); + const off = on + ? [] + : [ + feature, + ...FEATURES.filter(({ requires }) => requires === feature.id), + ]; + const attention: Record = {}; + 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();