diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx
index beb99fce8..1f7ff0d81 100644
--- a/src/app/entity-navigation.test.tsx
+++ b/src/app/entity-navigation.test.tsx
@@ -683,7 +683,7 @@ it("lists active primary pages in their sidebar and header placements", async ()
);
// The Attention tab is on by default, and attention keeps running without it.
- // Bestie adds its own tab beside it.
+ // Bestie adds its own tabs beside it.
const providers = () =>
current.agents.attentionProviders().map(({ provider }) => provider);
const tabs = () =>
@@ -692,14 +692,24 @@ it("lists active primary pages in their sidebar and header placements", async ()
.map((tab) => tab.key)
.sort();
await waitFor(() =>
- expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]),
+ expect(tabs()).toEqual([
+ "buzz.bestie/bestie",
+ "buzz.bestie/former-bestie",
+ "buzz.watching/watching",
+ ]),
);
await act(() => current.plugins.change("disable", "buzz.watching"));
- await waitFor(() => expect(tabs()).toEqual(["buzz.bestie/bestie"]));
+ await waitFor(() =>
+ expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.bestie/former-bestie"]),
+ );
expect(providers()).toContain("attention/command");
await act(() => current.plugins.change("enable", "buzz.watching"));
await waitFor(() =>
- expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]),
+ expect(tabs()).toEqual([
+ "buzz.bestie/bestie",
+ "buzz.bestie/former-bestie",
+ "buzz.watching/watching",
+ ]),
);
await act(() => current.plugins.change("disable", "buzz.agents"));
diff --git a/src/bundled/agents/BestieChoice.tsx b/src/bundled/agents/BestieChoice.tsx
index 484e7bca4..0ccbe85c6 100644
--- a/src/bundled/agents/BestieChoice.tsx
+++ b/src/bundled/agents/BestieChoice.tsx
@@ -12,6 +12,14 @@ import { Button } from "../../shared/design-system/ui/Button";
type BestieAgent = AgentChoicesSnapshot["selectable"][number];
+const made = new Set<(pubkey: string) => void>();
+/** Hears each agent the viewer makes Bestie on this device, once it is
+ * saved. A choice synced from elsewhere isn't heard. */
+export function onBestieMade(listener: (pubkey: string) => void) {
+ made.add(listener);
+ return () => void made.delete(listener);
+}
+
/** Making an agent Bestie, wherever it is offered: `make` asks before
* replacing an explicitly saved Bestie, in the dialog `view` renders. */
export function useBestieChoice(session: RelaySession, scope: string) {
@@ -37,6 +45,7 @@ export function useBestieChoice(session: RelaySession, scope: string) {
setSavingBestie(true);
chooseDefaultAgent(session, scope, pubkey)
.then(() => {
+ for (const listener of made) listener(pubkey);
if (choice === latest.current) setReplacement(undefined);
})
.catch((failure: Error) => {
diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx
index 1d19a5c29..14359248a 100644
--- a/src/bundled/bestie/BestieTab.test.tsx
+++ b/src/bundled/bestie/BestieTab.test.tsx
@@ -6,7 +6,8 @@ import { afterEach, expect, it, vi } from "vitest";
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 { onBestieMade } from "../agents/BestieChoice";
+import { bestieTab, formerBestieTab } from "./BestieTab";
import { validateObject } from "../../features/agents/attention";
import { checkinHourly, memory, withFeature } from "./features";
@@ -246,6 +247,11 @@ it("keeps memory on through the owner's edits, and offers Restore for edited mar
rerender();
expect(screen.queryByRole("button", { name: "Restore" })).toBeNull();
expect(screen.getByText(/Fix its start and end lines there/)).toBeVisible();
+ // Check-ins wait on memory.
+ expect(screen.getByRole("switch", { name: "Every hour" })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
});
it("says why a save failed and lets the switch be tried again", async () => {
@@ -395,3 +401,78 @@ it("checks in every hour only while memory is on, with a timer in the owner's DM
expect(hourly()).toBeChecked();
expect(hourly()).not.toHaveAttribute("aria-disabled", "true");
});
+
+it("shows a Former Bestie tab while a former Bestie has a setting on, with a way back", async () => {
+ const user = userEvent.setup();
+ const { relay, mainAgent } = community(bea.pubkey);
+ const tab = formerBestieTab(relay, host().agents);
+ const remembering = {
+ ...ada,
+ config: { instructions: withFeature("Be brief.", memory, true) },
+ } as Agent;
+ const stop = tab.subscribe?.(() => {});
+ // Nobody is former before the account's choice is read.
+ expect(tab.when?.(remembering)).toBe(false);
+ await vi.waitFor(() => expect(tab.when?.(remembering)).toBe(true));
+ // Not on the Bestie, nor on an agent with nothing on.
+ expect(tab.when?.(bea)).toBe(false);
+ expect(tab.when?.(ada)).toBe(false);
+
+ const made = vi.fn();
+ const stopMade = onBestieMade(made);
+ const Tab = tab.component;
+ render();
+ expect(
+ screen.getByText("Ada is no longer your Bestie. These are still on."),
+ ).toBeVisible();
+ expect(screen.getByRole("switch", { name: "Remembers you" })).toBeChecked();
+ const hourly = screen.getByRole("switch", { name: "Every hour" });
+ expect(hourly).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByText("Only your Bestie checks in.")).toBeVisible();
+ expect(screen.getByText("Private channels Ada can read")).toBeVisible();
+
+ await user.click(
+ screen.getByRole("button", { name: "Make my Bestie again" }),
+ );
+ const confirmation = await screen.findByRole("alertdialog", {
+ name: "Make Ada your bestie?",
+ });
+ await user.click(
+ within(confirmation).getByRole("button", { name: "Switch to Ada" }),
+ );
+ expect(mainAgent.write).toHaveBeenCalledWith(ada.pubkey);
+ // This device made it Bestie, so the plugin hears of it.
+ await vi.waitFor(() => expect(made).toHaveBeenCalledWith(ada.pubkey));
+ await vi.waitFor(() => expect(tab.when?.(remembering)).toBe(false));
+ stopMade();
+ stop?.();
+});
+
+it("lets a former Bestie's leftover check-in be turned off", async () => {
+ const user = userEvent.setup();
+ const { relay } = community(bea.pubkey);
+ const { agents, save } = host();
+ const Tab = formerBestieTab(relay, agents).component;
+ const checking = {
+ ...ada,
+ config: {
+ instructions: withFeature(
+ withFeature("Be brief.", memory, true),
+ checkinHourly,
+ true,
+ ),
+ },
+ attention: {
+ "interest/bestie-hourly-check-in": {},
+ "watch/bestie-hourly-check-in": {},
+ },
+ } as unknown as Agent;
+ render();
+ const hourly = screen.getByRole("switch", { name: "Every hour" });
+ expect(hourly).toBeChecked();
+ expect(hourly).not.toHaveAttribute("aria-disabled", "true");
+ await user.click(hourly);
+ expect(save).toHaveBeenLastCalledWith(ada.key, {
+ config: { instructions: withFeature("Be brief.", memory, true) },
+ });
+});
diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx
index 3cd6f670e..72ee08593 100644
--- a/src/bundled/bestie/BestieTab.tsx
+++ b/src/bundled/bestie/BestieTab.tsx
@@ -1,14 +1,8 @@
import { useState } from "react";
-import type {
- Agent,
- AgentAddedTab,
- Agents,
-} from "../../features/agents/service";
-import { relayOrigin } from "../../features/communities/destination";
-import { relayPartition } from "../../features/relay/partition";
+import type { Agent, AgentAddedTab } from "../../features/agents/service";
import { useRelayConnection } from "../../features/relay/react";
-import type { RelayData, RelaySnapshot } from "../../features/relay/service";
-import { savedAgentStore } from "../../features/solo/default-agent";
+import type { RelayData } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
import { BestieIcon } from "../../shared/design-system/icons";
import { Button } from "../../shared/design-system/ui/Button";
import { InlineHeader } from "../../shared/design-system/ui/Header";
@@ -28,22 +22,25 @@ import {
FEATURES,
type Feature,
featureState,
- hasFeature,
restoredFeature,
- withFeature,
} from "./features";
-import {
- type AttentionValue,
- interestSlug,
- watchSlug,
-} from "../../features/agents/attention";
-import type { RelaySession } from "../../features/relay/session";
+import { formerWithSettings, subscribePrivateMembers } from "./former";
import { PrivateChannels } from "./PrivateChannels";
-
-type TabAgents = Pick;
+import {
+ cannotEdit,
+ type FeatureAgents,
+ featureOn,
+ instructionsOf,
+ restoreFeature,
+ saveFeature,
+} from "./save";
+import { inCommunity, savedBestie } from "./saved";
/** The Bestie tab, on the agent saved as Bestie in its community. */
-export function bestieTab(relay: RelayData, agents: TabAgents): AgentAddedTab {
+export function bestieTab(
+ relay: RelayData,
+ agents: FeatureAgents,
+): AgentAddedTab {
const bestie = savedBestie(relay);
return {
id: "bestie",
@@ -58,72 +55,52 @@ export function bestieTab(relay: RelayData, agents: TabAgents): AgentAddedTab {
};
}
-/** The saved Bestie in the connected community, across reconnections. */
-function savedBestie(relay: RelayData) {
- let current:
- | Readonly<{
- snapshot: RelaySnapshot;
- store: ReturnType;
- }>
- | undefined;
- const store = () => {
- const snapshot = relay.snapshot();
- if (
- current?.snapshot.session !== snapshot.session ||
- current.snapshot.scope !== snapshot.scope
- )
- current = {
- snapshot,
- store: savedAgentStore(snapshot.session, snapshot.scope ?? ""),
- };
- return current.store;
- };
+/** The Former Bestie tab, on an agent that was Bestie and still has
+ * something on. It goes once the agent has nothing left on, and shows only
+ * against a Bestie saved to the account and read, as check-ins stop. */
+export function formerBestieTab(
+ relay: RelayData,
+ agents: FeatureAgents,
+): AgentAddedTab {
+ const bestie = savedBestie(relay);
return {
- snapshot: () => store().snapshot(),
- subscribe(listener: () => void) {
- // The saved choice's sources also change for unrelated view state, so
- // only a different Bestie, or community, is passed on.
- let following = store();
- let last = following.snapshot();
- const changed = () => {
- const next = following.snapshot();
- if (next === last) return;
- last = next;
- listener();
- };
- let stop = following.subscribe(changed);
- const stopRelay = relay.subscribe(() => {
- if (store() === following) return;
- stop();
- following = store();
- last = following.snapshot();
- stop = following.subscribe(changed);
- listener();
- });
+ id: "former-bestie",
+ title: "Former Bestie",
+ when: (agent) => {
+ const current = bestie.snapshot();
+ const snapshot = relay.snapshot();
+ return (
+ !!current &&
+ !!snapshot.session.mainAgent &&
+ agent.pubkey !== current &&
+ inCommunity(agent, snapshot) &&
+ formerWithSettings(agent, snapshot)
+ );
+ },
+ subscribe(listener) {
+ const stopBestie = bestie.subscribe(listener);
+ const stopMembers = subscribePrivateMembers(relay, listener);
return () => {
- stopRelay();
- stop();
+ stopBestie();
+ stopMembers();
};
},
+ component: ({ agent }) => (
+
+ ),
};
}
-function inCommunity(agent: Agent, { scope }: RelaySnapshot) {
- try {
- return relayPartition(relayOrigin(agent.relay), agent.owner) === scope;
- } catch {
- return false;
- }
-}
-
function BestieTab({
relay,
agents,
agent,
+ former = false,
}: {
relay: RelayData;
- agents: TabAgents;
+ agents: FeatureAgents;
agent: Agent;
+ former?: boolean;
}) {
const { session, scope } = useRelayConnection(relay);
const bestie = useBestieChoice(session, scope ?? "");
@@ -132,122 +109,120 @@ function BestieTab({
const others = bestie.choices.selectable.filter(
(choice) => choice.pubkey !== agent.pubkey,
);
+ const name = agent.name || "This agent";
+ const switches = (features: readonly Feature[]) =>
+ features.map((feature) => (
+
+ ));
return (
// Its own size container: rows wrap their buttons to the panel's width,
// as each agent type's settings form does, and nothing widens the tab.
- }
- title="This is your Bestie"
- subtitle="It's pinned at the top of Buzz, with your DM as its home."
- trailing={
-
- Change}
- />
-
-
- Make your Bestie
- {others.map((choice) => (
-
- ))}
-
-
-
- }
- />
+ {former ? (
+ }
+ title={`${name} is no longer your Bestie. These are still on.`}
+ trailing={
+
+ }
+ />
+ ) : (
+ }
+ title="This is your Bestie"
+ subtitle="It's pinned at the top of Buzz, with your DM as its home."
+ trailing={
+
+ Change}
+ />
+
+
+ Make your Bestie
+ {others.map((choice) => (
+
+ ))}
+
+
+
+ }
+ />
+ )}
- {FEATURES.filter((feature) => !feature.timer).map((feature) => (
-
- ))}
+ {switches(FEATURES.filter((feature) => !feature.timer))}
- {FEATURES.filter((feature) => feature.timer).map((feature) => (
-
- ))}
+ {switches(FEATURES.filter((feature) => feature.timer))}
-
+
{bestie.view}
);
}
-/** 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. */
+/** A feature's switch. Off also turns off the features that require this
+ * one. On a former Bestie, check-ins can only be turned off. 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,
+ former,
saving,
setSaving,
}: {
- agents: TabAgents;
+ agents: FeatureAgents;
agent: Agent;
session: RelaySession;
feature: Feature;
+ former: boolean;
saving: boolean;
setSaving(saving: boolean): void;
}) {
const [error, setError] = useState();
- // A type without `adopt` has no instructions, as `updateKind` says.
- if (!agents.types().find(({ key }) => key === agent.type)?.adopt)
- return (
-
- );
- // Without a confirmed copy its instructions aren't shown, so a save here
- // would replace them.
- if (agent.configProblem)
- return (
-
- );
- const config = (agent.config ?? {}) as { instructions?: unknown };
- const instructions =
- typeof config.instructions === "string" ? config.instructions : "";
+ const problem = cannotEdit(agents, agent);
+ if (problem)
+ return ;
+ const instructions = instructionsOf(agent);
const required = FEATURES.find(({ id }) => id === feature.requires);
- const blocked = !!required && !featureOn(agent, instructions, required);
- const checked = featureOn(agent, instructions, feature);
+ const blocked = !!required && !featureOn(agent, required);
+ const checked = featureOn(agent, feature);
+ const formerCheckIn = former && !!feature.timer;
const run = async (task: () => Promise) => {
setSaving(true);
setError(undefined);
@@ -259,73 +234,18 @@ function FeatureSwitch({
setSaving(false);
}
};
- const saveInstructions = (next: string) =>
- agents.save(agent.key, {
- config: { ...config, instructions: next },
- ...(agent.configBase && { base: agent.configBase }),
- });
const save = (on: boolean) =>
run(async () => {
- // On never adds a second section beside one edited by hand.
- if (on && featureState(instructions, feature) === "edited")
- throw new Error(
- `${feature.title} was edited in Instructions; restore it first.`,
- );
- 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 }),
+ // The owner's 1:1 DM with the agent: opening it is idempotent.
+ const channel =
+ on && feature.timer
+ ? await session.directMessages.open(
+ [agent.pubkey],
+ AbortSignal.timeout(30_000),
+ )
+ : undefined;
+ await saveFeature(agents, agent, feature, on, {
+ ...(channel && { channel }),
});
});
const alert = error && (
@@ -334,22 +254,25 @@ function FeatureSwitch({
);
if (featureState(instructions, feature) === "edited") {
- const restored = restoredFeature(instructions, feature);
+ const restorable =
+ !formerCheckIn && restoredFeature(instructions, feature) !== undefined;
return (
<>
void run(() => saveInstructions(restored))}
+ onClick={() =>
+ void run(() => restoreFeature(agents, agent, feature))
+ }
>
Restore
@@ -365,17 +288,19 @@ function FeatureSwitch({
void save(on)}
/>
{alert}
diff --git a/src/bundled/bestie/PrivateChannels.test.tsx b/src/bundled/bestie/PrivateChannels.test.tsx
index 5e4f1b049..d5c74b9fe 100644
--- a/src/bundled/bestie/PrivateChannels.test.tsx
+++ b/src/bundled/bestie/PrivateChannels.test.tsx
@@ -62,7 +62,7 @@ function session(channels: ChannelSummary[], removal = "confirmed") {
const add = vi.fn(async (id: string) => {
if (id === "id-beta") throw new Error("Not confirmed. Try again.");
});
- const run = vi.fn(async () => {});
+ const run = vi.fn(async (_channel: string, _change: unknown) => {});
const list = { channels };
const value = {
viewer: YOU,
@@ -76,8 +76,8 @@ function session(channels: ChannelSummary[], removal = "confirmed") {
memberAdministration: {
refresh: vi.fn(async () => {}),
run,
- snapshot: vi.fn(() =>
- run.mock.calls.length
+ snapshot: vi.fn((id: string) =>
+ run.mock.calls.some(([removed]) => removed === id)
? {
status: "ready",
authority: { roles: {} },
@@ -172,3 +172,33 @@ it("says when a removal isn't confirmed, and offers none from a cached roster",
"#shared: The change may have taken effect. Refresh to check.",
);
});
+
+it("lists a former Bestie's channels by name, to remove from one or all after asking", async () => {
+ const user = userEvent.setup();
+ const { value, run } = session([
+ channel("alpha", [YOU, BESTIE], { lastActivityAt: 5 }),
+ channel("beta", [YOU, BESTIE], { lastActivityAt: 9 }),
+ channel("gamma", [YOU]),
+ ]);
+ render();
+ expect(screen.getByText("Private channels Ada can read")).toBeVisible();
+ // Winding down offers no adds.
+ expect(screen.queryByRole("button", { name: "Choose…" })).toBeNull();
+ expect(screen.queryByRole("switch")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Remove Ada from #beta" }),
+ ).toBeVisible();
+
+ await user.click(screen.getByRole("button", { name: "Remove from all" }));
+ const ask = await screen.findByRole("alertdialog", {
+ name: "Remove Ada from 2 private channels?",
+ });
+ expect(run).not.toHaveBeenCalled();
+ await user.click(
+ within(ask).getByRole("button", { name: "Remove from all" }),
+ );
+ await vi.waitFor(() =>
+ expect(run.mock.calls.map(([id]) => id)).toEqual(["id-beta", "id-alpha"]),
+ );
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx
index bb3510559..90b276c8a 100644
--- a/src/bundled/bestie/PrivateChannels.tsx
+++ b/src/bundled/bestie/PrivateChannels.tsx
@@ -65,13 +65,16 @@ async function removeMember(
const message = (error: unknown) =>
error instanceof Error ? error.message : String(error);
-/** The private channels Bestie reads because it is a member. */
+/** The private channels Bestie reads because it is a member. For a former
+ * Bestie, `former` names it, and the list is for taking it out. */
export function PrivateChannels({
session,
bestie,
+ former,
}: {
session: RelaySession;
bestie: string;
+ former?: string;
}) {
const list = useChannelList(session.channels);
const { joined, others } = privateChannels(
@@ -87,7 +90,7 @@ export function PrivateChannels({
Record>
>
>({});
- /** The channels Add to all asked about. */
+ /** The channels Add to all, or Remove from all, asked about. */
const [confirming, setConfirming] = useState();
/** Runs `change` on each channel in turn, as adding several agents does,
@@ -118,8 +121,10 @@ export function PrivateChannels({
};
const add = (channels: readonly ChannelSummary[]) =>
run(channels, true, (id) => session.memberAdditions.add(id, bestie));
- const remove = (channel: ChannelSummary) =>
- run([channel], false, (id) => removeMember(session, id, bestie));
+ const remove = (channels: readonly ChannelSummary[]) =>
+ run(channels, false, (id) => removeMember(session, id, bestie));
+ const name = former ?? "Bestie";
+ const removable = joined.filter((channel) => !channel.cached);
const adding = addable.some((channel) => busy.has(channel.id));
// A failure stops showing once the channel is as the change wanted anyway.
const inBestie = new Set(joined.map((channel) => channel.id));
@@ -130,8 +135,12 @@ export function PrivateChannels({
return (
<>
{joined.map((channel) => (
@@ -143,75 +152,96 @@ export function PrivateChannels({
variant="outline"
loading={busy.has(channel.id)}
disabled={channel.cached}
- aria-label={`Remove Bestie from #${channel.name}`}
- onClick={() => void remove(channel)}
+ aria-label={`Remove ${name} from #${channel.name}`}
+ onClick={() => void remove([channel])}
>
Remove
}
/>
))}
- {!joined.length && (
-
- )}
- {/* Each action has a row of its own, so its button can wrap under
- its title in a narrow panel instead of widening the tab. */}
- {addable.length > 0 && (
- <>
- (
-
-
- Choose…
-
- }
- />
-
- {addable.map((channel) => (
-
- ))}
-
-
- )}
- />
+ {!joined.length &&
+ (former ? (
+
+ ) : (
(
-
- )}
+ title="Not in any of your private channels yet"
+ subtitle="Add Bestie to the ones you want it to catch you up on."
/>
- >
+ ))}
+ {/* Each action has a row of its own, so its button can wrap under
+ its title in a narrow panel instead of widening the tab. */}
+ {former
+ ? removable.length > 0 && (
+ (
+
+ )}
+ />
+ )
+ : addable.length > 0 && (
+ <>
+ (
+
+
+ Choose…
+
+ }
+ />
+
+ {addable.map((channel) => (
+
+ ))}
+
+
+ )}
+ />
+ (
+
+ )}
+ />
+ >
+ )}
+ {!former && (
+
)}
-
{problems.length > 0 && (
@@ -224,8 +254,12 @@ export function PrivateChannels({
)}
{confirming && (
setConfirming(undefined)}
actions={
<>
@@ -234,10 +268,10 @@ export function PrivateChannels({
variant="prominent"
onClick={() => {
setConfirming(undefined);
- void add(confirming);
+ void (former ? remove : add)(confirming);
}}
>
- Add to all
+ {former ? "Remove from all" : "Add to all"}
>
}
diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md
index 61977b455..aca9cb2a4 100644
--- a/src/bundled/bestie/README.md
+++ b/src/bundled/bestie/README.md
@@ -34,9 +34,8 @@ Bestie, each with **Remove**. **Add to a channel** (**Choose…**) and **Add to
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. Adding Bestie to new channels you
-create is not set up yet.
+Public channels 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
@@ -50,6 +49,15 @@ 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.
+**Changing your Bestie.** Only your Bestie checks in: whenever the saved Bestie
+is known, on this device or synced from another, any other agent here has its
+check-ins removed. Its memory section and channels stay. An agent this device
+makes Bestie starts with Remembers you on; a choice synced from elsewhere
+changes nothing, so memory turned off stays off. A former Bestie keeps a
+**Former Bestie** tab while it has a Bestie section, or, if this device saw it
+as Bestie, while it is in one of your private channels. The tab has the same
+switches, **Remove from all** for its channels, and **Make my Bestie again**.
+
---
## Changing Bestie's instructions
diff --git a/src/bundled/bestie/features.test.ts b/src/bundled/bestie/features.test.ts
index 55263db3e..0a7135dbe 100644
--- a/src/bundled/bestie/features.test.ts
+++ b/src/bundled/bestie/features.test.ts
@@ -1,6 +1,6 @@
import { expect, it } from "vitest";
import {
- type Feature,
+ checkinHourly,
featureState,
hasFeature,
memory,
@@ -8,8 +8,6 @@ import {
withFeature,
} from "./features";
-// Another feature, whose markers are not memory's.
-const another: Feature = { ...memory, id: "other", text: "other" };
const OPEN = "";
const CLOSE = "";
@@ -95,7 +93,9 @@ it("shows a section whose markers were edited, and adds no second one", () => {
}
expect(featureState("Be brief.", memory)).toBe("off");
// Another feature's markers are not this one's.
- expect(featureState(withFeature("", another, true), memory)).toBe("off");
+ expect(featureState(withFeature("", checkinHourly, true), memory)).toBe(
+ "off",
+ );
});
it("restores an edited section only where its markers show it was", () => {
@@ -126,7 +126,7 @@ it("restores an edited section only where its markers show it was", () => {
memory,
),
).toBeUndefined();
- const other = withFeature("", another, true);
+ const other = withFeature("", checkinHourly, true);
expect(
restoredFeature(
`${OPEN.replace(" v1", "")}\nrules\n\n${other}\n${CLOSE.replace(" -->", "-->")}`,
diff --git a/src/bundled/bestie/features.ts b/src/bundled/bestie/features.ts
index 1f3c4361e..7581752b2 100644
--- a/src/bundled/bestie/features.ts
+++ b/src/bundled/bestie/features.ts
@@ -35,7 +35,7 @@ Your owner turned on "Remembers you": keep memory about them, so your help gets
Your memory has these slugs:
- core: who you are to this owner and your durable operating rules. Link the slugs below from it, as [[mem/user]].
- mem/user: the owner's facts, preferences, timezone, notification preferences, and an "Open loops" section of things they asked you to follow up on.
-- mem/notes/YYYY-MM-DD: sparse dated notes of significant events, each with its source event id. Append only.
+- mem/notes/YYYY-MM-DD: sparse dated notes of significant events, each with its source event id, dated by this device's local date. Append only.
- mem/memory: curated longer-term knowledge. Replace superseded claims; never duplicate.
Rules:
diff --git a/src/bundled/bestie/former.test.ts b/src/bundled/bestie/former.test.ts
new file mode 100644
index 000000000..d74d07d74
--- /dev/null
+++ b/src/bundled/bestie/former.test.ts
@@ -0,0 +1,269 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from "vitest";
+import type { Agent, Agents } from "../../features/agents/service";
+import type { ChannelSummary } from "../../features/relay/contracts";
+import type { RelayData, RelaySnapshot } from "../../features/relay/service";
+import { checkinHourly, memory, withFeature } from "./features";
+import {
+ formerWithSettings,
+ rememberNewBestie,
+ stopFormerCheckIns,
+} from "./former";
+
+afterEach(() => localStorage.clear());
+
+const OWNER = "f".repeat(64);
+const SCOPE = `https://relay.example:${OWNER}`;
+const DM = "0f0f0f0f-0000-4000-8000-000000000001";
+const TIMER = {
+ "interest/bestie-hourly-check-in": { type: "interest" },
+ "watch/bestie-hourly-check-in": { type: "timer", channel: DM },
+};
+const checkingIn = withFeature(
+ withFeature("Be brief.\n", memory, true),
+ checkinHourly,
+ true,
+);
+const agent = (digit: string, instructions = "", attention = {}): Agent =>
+ ({
+ key: digit.repeat(64),
+ pubkey: digit.repeat(64),
+ name: digit,
+ type: "test/echo",
+ owner: OWNER,
+ relay: "wss://relay.example",
+ place: "here",
+ attention,
+ config: { instructions },
+ }) as unknown as Agent;
+const ada = agent("a", checkingIn, TIMER);
+const bea = agent("b");
+
+/** A community whose account read of the saved Bestie waits until `settle`. */
+function community(channels: readonly ChannelSummary[] = [], account = true) {
+ let settle: (pubkey: string | undefined) => void = () => {};
+ const read = new Promise((resolve) => {
+ settle = resolve;
+ });
+ const session = {
+ viewer: OWNER,
+ ...(account && {
+ mainAgent: { read: () => read, write: vi.fn(async () => {}) },
+ }),
+ channels: {
+ list: () => ({ channels }),
+ subscribeList: () => () => {},
+ ensureList: () => {},
+ },
+ };
+ const snapshot = { status: "ready", scope: SCOPE, session };
+ const relay = {
+ snapshot: () => snapshot,
+ subscribe: () => () => {},
+ } as unknown as RelayData;
+ return {
+ relay,
+ snapshot: snapshot as unknown as RelaySnapshot,
+ settle: async (pubkey: string | undefined) => {
+ settle(pubkey);
+ await read;
+ await Promise.resolve();
+ },
+ };
+}
+
+function host(list: Agent[], running: Agent[] = []) {
+ let agents = list;
+ const listeners = new Set<() => void>();
+ const save = vi.fn(async (key, change) => {
+ agents = agents.map((each) =>
+ each.key !== key
+ ? each
+ : ({
+ ...each,
+ ...(change.config !== undefined && { config: change.config }),
+ ...(change.attention && {
+ attention: Object.fromEntries(
+ Object.entries({
+ ...each.attention,
+ ...change.attention,
+ }).filter(([, value]) => value),
+ ),
+ }),
+ } as Agent),
+ );
+ for (const listener of listeners) listener();
+ });
+ return {
+ save,
+ /** The agents' listeners are told, with nothing changed. */
+ notify: () => {
+ for (const listener of listeners) listener();
+ },
+ agents: {
+ save,
+ types: () => [{ key: "test/echo", adopt: () => ({}) }],
+ snapshot: () => ({ agents, running: [...agents, ...running] }),
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => void listeners.delete(listener);
+ },
+ } as unknown as Pick,
+ };
+}
+
+it("stops a former Bestie's check-ins once the Bestie is known, and leaves its memory", async () => {
+ const { relay, settle } = community();
+ const { agents, save, notify } = host([ada, bea]);
+ const stop = stopFormerCheckIns(relay, agents);
+ // While the saved Bestie is being read, nobody is former.
+ await Promise.resolve();
+ expect(save).not.toHaveBeenCalled();
+ await settle(bea.pubkey);
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2));
+ expect(save.mock.calls).toEqual([
+ [
+ ada.key,
+ {
+ attention: {
+ "interest/bestie-hourly-check-in": null,
+ "watch/bestie-hourly-check-in": null,
+ },
+ },
+ ],
+ [
+ ada.key,
+ { config: { instructions: withFeature("Be brief.\n", memory, true) } },
+ ],
+ ]);
+ // Once stopped there is nothing more to do.
+ save.mockClear();
+ notify();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(save).not.toHaveBeenCalled();
+ stop();
+});
+
+it("does nothing while the saved Bestie is unknown, or to the Bestie itself", async () => {
+ const unknown = community();
+ const first = host([ada]);
+ const stopUnknown = stopFormerCheckIns(unknown.relay, first.agents);
+ // An account with no saved Bestie, or a read that failed, isn't "none".
+ await unknown.settle(undefined);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(first.save).not.toHaveBeenCalled();
+ stopUnknown();
+
+ const known = community();
+ const second = host([ada]);
+ const stopKnown = stopFormerCheckIns(known.relay, second.agents);
+ await known.settle(ada.pubkey);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(second.save).not.toHaveBeenCalled();
+ stopKnown();
+});
+
+it("stops a timer left without its section, and doesn't retry a failed save until something changes", async () => {
+ const { relay, settle } = community();
+ const half = agent("c", "Be brief.", TIMER);
+ const { agents, save, notify } = host([half]);
+ save.mockRejectedValueOnce(new Error("offline"));
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const stop = stopFormerCheckIns(relay, agents);
+ await settle(bea.pubkey);
+ await vi.waitFor(() => expect(warn).toHaveBeenCalled());
+ expect(save).toHaveBeenCalledTimes(1);
+ // The same state seen again isn't saved again.
+ notify();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(save).toHaveBeenCalledTimes(1);
+ stop();
+ warn.mockRestore();
+});
+
+it("turns memory on for a local agent made Bestie here, unless it has it", async () => {
+ const { relay } = community();
+ const { agents, save } = host([ada, bea]);
+ await rememberNewBestie(relay, agents, ada.pubkey);
+ expect(save).not.toHaveBeenCalled();
+ await rememberNewBestie(relay, agents, bea.pubkey);
+ expect(save).toHaveBeenCalledWith(bea.key, {
+ config: { instructions: withFeature("", memory, true) },
+ });
+ // A remote agent, or one in another community, is left alone.
+ save.mockClear();
+ await rememberNewBestie(relay, agents, "9".repeat(64));
+ expect(save).not.toHaveBeenCalled();
+ // Nor is one whose section was edited by hand: no second section.
+ const edited = host([
+ {
+ ...bea,
+ config: {
+ instructions: withFeature("", memory, true).replace(
+ "",
+ "",
+ ),
+ },
+ } as Agent,
+ ]);
+ await rememberNewBestie(relay, edited.agents, bea.pubkey);
+ expect(edited.save).not.toHaveBeenCalled();
+});
+
+it("counts a former Bestie as on while it has a section, or a private channel once seen as Bestie", async () => {
+ const channel = {
+ id: "c1",
+ name: "plans",
+ channelType: "stream",
+ private: true,
+ members: [OWNER, bea.pubkey],
+ } as unknown as ChannelSummary;
+ const { relay, snapshot, settle } = community([channel]);
+ const { agents } = host([ada, bea]);
+ expect(formerWithSettings(ada, snapshot)).toBe(true);
+ // Any agent in a private channel isn't a former Bestie...
+ expect(formerWithSettings(bea, snapshot)).toBe(false);
+ // ...but one this device saw as Bestie is, once another one is.
+ const stop = stopFormerCheckIns(relay, agents);
+ await settle(bea.pubkey);
+ expect(formerWithSettings(bea, snapshot)).toBe(true);
+ expect(
+ formerWithSettings({ ...bea, pubkey: "d".repeat(64) } as Agent, snapshot),
+ ).toBe(false);
+ stop();
+});
+
+it("leaves a Bestie kept only on this device alone, as an automatic pick looks the same", async () => {
+ const { relay } = community([], false);
+ localStorage.setItem(
+ `buzz-view.v1:${JSON.stringify([SCOPE, "solo:default-agent"])}`,
+ JSON.stringify(bea.pubkey),
+ );
+ const { agents, save } = host([ada, bea]);
+ const stop = stopFormerCheckIns(relay, agents);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(save).not.toHaveBeenCalled();
+ stop();
+});
+
+it("stops only the timer of an agent also in another community, whose instructions it shares", async () => {
+ const { relay, settle } = community();
+ const elsewhere = { ...ada, relay: "wss://other.example" } as Agent;
+ const { agents, save } = host([ada, bea], [elsewhere]);
+ const stop = stopFormerCheckIns(relay, agents);
+ await settle(bea.pubkey);
+ await vi.waitFor(() => expect(save).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(save.mock.calls).toEqual([
+ [
+ ada.key,
+ {
+ attention: {
+ "interest/bestie-hourly-check-in": null,
+ "watch/bestie-hourly-check-in": null,
+ },
+ },
+ ],
+ ]);
+ stop();
+});
diff --git a/src/bundled/bestie/former.ts b/src/bundled/bestie/former.ts
new file mode 100644
index 000000000..50237cf9d
--- /dev/null
+++ b/src/bundled/bestie/former.ts
@@ -0,0 +1,195 @@
+import type { Agent, Agents } from "../../features/agents/service";
+import type { RelayData, RelaySnapshot } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
+import { FEATURES, featureState, memory } from "./features";
+import { privateChannels } from "./PrivateChannels";
+import {
+ cannotEdit,
+ type FeatureAgents,
+ featurePresent,
+ instructionsOf,
+ saveFeature,
+} from "./save";
+import { inCommunity, savedBestie } from "./saved";
+import { readView, writeView } from "../../shared/view-state";
+
+type ReconcileAgents = FeatureAgents & Pick;
+
+const FORMER = "bestie:former";
+/** The agents this device has seen as the community's Bestie, newest last. */
+function seenAsBestie(scope: string) {
+ const value = readView(scope, FORMER, []);
+ return Array.isArray(value)
+ ? value.filter((pubkey): pubkey is string => typeof pubkey === "string")
+ : [];
+}
+function rememberBestie(scope: string, pubkey: string) {
+ const seen = seenAsBestie(scope);
+ if (!seen.includes(pubkey))
+ writeView(scope, FORMER, [...seen, pubkey].slice(-20));
+}
+
+/** Whether `agent`, which isn't Bestie, was one and still has something on
+ * that the Former Bestie tab can turn off. A Bestie section in its
+ * instructions shows it was Bestie, on any device, and is on. Membership of
+ * one of the viewer's private channels counts only for an agent this device
+ * saw as Bestie: nothing records which channels Bestie's switches added. */
+export function formerWithSettings(
+ agent: Agent,
+ { session, scope }: RelaySnapshot,
+) {
+ const instructions = instructionsOf(agent);
+ if (FEATURES.some((feature) => featureState(instructions, feature) !== "off"))
+ return true;
+ return (
+ !!scope &&
+ seenAsBestie(scope).includes(agent.pubkey) &&
+ privateChannels(
+ session.channels.list().channels,
+ session.viewer ?? "",
+ agent.pubkey,
+ ).joined.length > 0
+ );
+}
+
+/** Calls `listener` when who is in the viewer's private channels changes,
+ * across reconnections, but not for each new message in them. */
+export function subscribePrivateMembers(
+ relay: RelayData,
+ listener: () => void,
+) {
+ const members = (session: RelaySession) =>
+ privateChannels(session.channels.list().channels, session.viewer ?? "", "")
+ .others.map(({ id, members = [] }) => `${id}:${[...members].sort()}`)
+ .sort()
+ .join(" ");
+ let session = relay.snapshot().session;
+ let last = members(session);
+ const changed = () => {
+ const next = members(session);
+ if (next === last) return;
+ last = next;
+ listener();
+ };
+ const follow = () => {
+ session.channels.ensureList();
+ return session.channels.subscribeList(changed);
+ };
+ let stop = follow();
+ const stopRelay = relay.subscribe(() => {
+ const next = relay.snapshot().session;
+ if (next === session) return;
+ stop();
+ session = next;
+ stop = follow();
+ changed();
+ });
+ return () => {
+ stopRelay();
+ stop();
+ };
+}
+
+/** Stops the check-ins of each agent here that isn't the community's Bestie,
+ * whenever the saved Bestie or the agents change, wherever the change was
+ * made. It acts only on a Bestie saved to the account and read: while the
+ * choice is loading or unknown, or kept only on this device, where an
+ * automatic pick looks the same, nobody counts as former. An agent also in
+ * another community, where it may be Bestie, loses only its timer here, as
+ * its instructions are shared. Memory and channels stay as they are. */
+export function stopFormerCheckIns(relay: RelayData, agents: ReconcileAgents) {
+ const bestie = savedBestie(relay);
+ const checkIns = FEATURES.filter(({ timer }) => timer);
+ /** Agents being saved, and what each was like when last tried, so a state
+ * is tried once per connection, and a save that left it as it was doesn't
+ * loop. */
+ const saving = new Set();
+ const tried = new Map();
+ let session = relay.snapshot().session;
+ const reconcile = () => {
+ const relaySnapshot = relay.snapshot();
+ if (relaySnapshot.session !== session) {
+ session = relaySnapshot.session;
+ tried.clear();
+ }
+ const current = session.mainAgent ? bestie.snapshot() : undefined;
+ if (!current) return;
+ if (relaySnapshot.scope) rememberBestie(relaySnapshot.scope, current);
+ const { agents: here, running = [] } = agents.snapshot();
+ for (const agent of here) {
+ const on = checkIns.filter((feature) => featurePresent(agent, feature));
+ const seen = JSON.stringify([instructionsOf(agent), agent.attention]);
+ if (
+ !on.length ||
+ agent.pubkey === current ||
+ agent.place !== "here" ||
+ saving.has(agent.key) ||
+ tried.get(agent.key) === seen ||
+ cannotEdit(agents, agent) ||
+ !inCommunity(agent, relaySnapshot)
+ )
+ continue;
+ const timerOnly = running.some(
+ (other) =>
+ other.pubkey === agent.pubkey && !inCommunity(other, relaySnapshot),
+ );
+ saving.add(agent.key);
+ tried.set(agent.key, seen);
+ // Not while the agents are telling their listeners of a change.
+ void Promise.resolve()
+ .then(async () => {
+ for (const feature of on) {
+ // Each save starts from the agent as the last one left it, and
+ // stops if it was made Bestie again meanwhile.
+ const fresh = agents
+ .snapshot()
+ .agents.find((each) => each.key === agent.key);
+ if (!fresh || bestie.snapshot() === agent.pubkey) return;
+ await saveFeature(agents, fresh, feature, false, { timerOnly });
+ }
+ })
+ .catch((error: unknown) =>
+ console.warn("A former Bestie's check-ins didn't stop", error),
+ )
+ .finally(() => {
+ saving.delete(agent.key);
+ // What changed while it saved.
+ reconcile();
+ });
+ }
+ };
+ const stopBestie = bestie.subscribe(reconcile);
+ const stopAgents = agents.subscribe(reconcile);
+ const stopRelay = relay.subscribe(reconcile);
+ reconcile();
+ return () => {
+ stopBestie();
+ stopAgents();
+ stopRelay();
+ };
+}
+
+/** Turns Remembers you on for a local agent this device just made Bestie,
+ * unless its type can't take it or it already has it. */
+export function rememberNewBestie(
+ relay: RelayData,
+ agents: ReconcileAgents,
+ pubkey: string,
+) {
+ const agent = agents
+ .snapshot()
+ .agents.find(
+ (candidate) =>
+ candidate.pubkey === pubkey &&
+ candidate.place === "here" &&
+ inCommunity(candidate, relay.snapshot()),
+ );
+ if (
+ !agent ||
+ cannotEdit(agents, agent) ||
+ // Never a second section, even beside one edited by hand.
+ featureState(instructionsOf(agent), memory) !== "off"
+ )
+ return Promise.resolve();
+ return saveFeature(agents, agent, memory, true);
+}
diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx
index 5583497eb..7b2d838d5 100644
--- a/src/bundled/bestie/index.tsx
+++ b/src/bundled/bestie/index.tsx
@@ -29,7 +29,9 @@ import {
type RemoteBestie,
} from "./setup";
import type { RemoteAgentModel } from "../builderlab/agents/client";
-import { bestieTab } from "./BestieTab";
+import { onBestieMade } from "../agents/BestieChoice";
+import { bestieTab, formerBestieTab } from "./BestieTab";
+import { rememberNewBestie, stopFormerCheckIns } from "./former";
export const inject = [
"pages",
@@ -68,6 +70,18 @@ export const apply: PluginModule["apply"] = (ctx) => {
});
ctx.effect(() => () => listeners.clear());
ctx.agents.registerTab(bestieTab(ctx.relay, ctx.agents));
+ ctx.agents.registerTab(formerBestieTab(ctx.relay, ctx.agents));
+ // Whoever is Bestie, on any device, only it checks in.
+ ctx.effect(() => stopFormerCheckIns(ctx.relay, ctx.agents));
+ // A Bestie made on this device starts out remembering its owner. One made
+ // elsewhere keeps what it has, so memory turned off stays off.
+ ctx.effect(() =>
+ onBestieMade((pubkey) => {
+ rememberNewBestie(ctx.relay, ctx.agents, pubkey).catch((error) =>
+ console.warn("Bestie's memory wasn't turned on", error),
+ );
+ }),
+ );
ctx.pages.register({
id: "bestie",
title: "Bestie",
diff --git a/src/bundled/bestie/save.test.ts b/src/bundled/bestie/save.test.ts
new file mode 100644
index 000000000..b41c4ca4a
--- /dev/null
+++ b/src/bundled/bestie/save.test.ts
@@ -0,0 +1,24 @@
+import { expect, it, vi } from "vitest";
+import type { Agent, Agents } from "../../features/agents/service";
+import { checkinHourly, memory, withFeature } from "./features";
+import { saveFeature } from "./save";
+
+it("refuses to turn on a section edited by hand, saving nothing", async () => {
+ const save = vi.fn(async () => {});
+ const agents = { save, types: () => [] };
+ const instructions = withFeature(
+ withFeature("Be brief.", memory, true),
+ checkinHourly,
+ true,
+ ).replace("", "");
+ const agent = {
+ key: "a".repeat(64),
+ attention: {},
+ config: { instructions },
+ } as unknown as Agent;
+ await expect(
+ saveFeature(agents, agent, checkinHourly, true, { channel: "dm" }),
+ ).rejects.toThrow("restore it first");
+ // Its timer would otherwise be saved first, leaving half of it on.
+ expect(save).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/bestie/save.ts b/src/bundled/bestie/save.ts
new file mode 100644
index 000000000..fe08369f4
--- /dev/null
+++ b/src/bundled/bestie/save.ts
@@ -0,0 +1,140 @@
+import {
+ type AttentionValue,
+ interestSlug,
+ watchSlug,
+} from "../../features/agents/attention";
+import type { Agent, Agents } from "../../features/agents/service";
+import {
+ FEATURES,
+ type Feature,
+ featureState,
+ hasFeature,
+ restoredFeature,
+ withFeature,
+} from "./features";
+
+export type FeatureAgents = Pick;
+
+/** The agent's instructions, as its synced editor shows them. */
+export function instructionsOf(agent: Agent) {
+ const { instructions } = (agent.config ?? {}) as { instructions?: unknown };
+ return typeof instructions === "string" ? instructions : "";
+}
+
+/** Why the agent's instructions can't take a feature here, if they can't. */
+export function cannotEdit(agents: FeatureAgents, agent: Agent) {
+ // A type without `adopt` has no instructions, as `updateKind` says.
+ if (!agents.types().find(({ key }) => key === agent.type)?.adopt)
+ return "This agent's type has no instructions to add this to.";
+ // Without a confirmed copy its instructions aren't shown, so a save here
+ // would replace them.
+ return agent.configProblem;
+}
+
+/** 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. */
+export function featureOn(agent: Agent, feature: Feature) {
+ return (
+ hasFeature(instructionsOf(agent), feature) &&
+ (!feature.timer || !!agent.attention[watchSlug(feature.timer.id)])
+ );
+}
+
+/** Whether any of the feature's parts is there: its section, whole or
+ * edited, or its timer. */
+export function featurePresent(agent: Agent, feature: Feature) {
+ return (
+ featureState(instructionsOf(agent), feature) !== "off" ||
+ (!!feature.timer && !!agent.attention[watchSlug(feature.timer.id)])
+ );
+}
+
+/** Turns `feature` on or off as its switch does, revising the synced
+ * instructions as their editor would. Off also turns off the features that
+ * require it. A timer is delivered to `channel`, the owner's DM with the
+ * agent. With `timerOnly`, the instructions, which each of the agent's
+ * communities shares, are left as they are. Attention is saved first: if the
+ * instructions then fail to save, the half left reads as off, and turning it
+ * either way finishes it. On refuses a section edited by hand: Restore puts
+ * it back first. */
+export async function saveFeature(
+ agents: FeatureAgents,
+ agent: Agent,
+ feature: Feature,
+ on: boolean,
+ {
+ channel,
+ timerOnly = false,
+ }: { channel?: string; timerOnly?: boolean } = {},
+) {
+ if (on && featureState(instructionsOf(agent), feature) === "edited")
+ throw new Error(
+ `${feature.title} was edited in Instructions; restore it first.`,
+ );
+ 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) {
+ if (!channel) throw new Error("A check-in needs your DM with the agent.");
+ 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,
+ };
+ }
+ 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 }),
+ });
+ if (timerOnly) return;
+ const instructions = instructionsOf(agent);
+ const next = on
+ ? withFeature(instructions, feature, true)
+ : off.reduce((text, item) => withFeature(text, item, false), instructions);
+ if (next !== instructions) await saveInstructions(agents, agent, next);
+}
+
+/** Puts back the feature's section edited by hand, as the editor would save
+ * it. */
+export async function restoreFeature(
+ agents: FeatureAgents,
+ agent: Agent,
+ feature: Feature,
+) {
+ const next = restoredFeature(instructionsOf(agent), feature);
+ if (next === undefined)
+ throw new Error(`Fix ${feature.title}'s markers in Instructions.`);
+ await saveInstructions(agents, agent, next);
+}
+
+/** Saves `instructions` as the next revision of the copy `agent` shows. */
+function saveInstructions(
+ agents: FeatureAgents,
+ agent: Agent,
+ instructions: string,
+) {
+ return agents.save(agent.key, {
+ config: { ...(agent.config as object), instructions },
+ ...(agent.configBase && { base: agent.configBase }),
+ });
+}
diff --git a/src/bundled/bestie/saved.ts b/src/bundled/bestie/saved.ts
new file mode 100644
index 000000000..6c4249447
--- /dev/null
+++ b/src/bundled/bestie/saved.ts
@@ -0,0 +1,63 @@
+import type { Agent } from "../../features/agents/service";
+import { relayOrigin } from "../../features/communities/destination";
+import { relayPartition } from "../../features/relay/partition";
+import type { RelayData, RelaySnapshot } from "../../features/relay/service";
+import { savedAgentStore } from "../../features/solo/default-agent";
+
+/** The saved Bestie in the connected community, across reconnections. */
+export function savedBestie(relay: RelayData) {
+ let current:
+ | Readonly<{
+ snapshot: RelaySnapshot;
+ store: ReturnType;
+ }>
+ | undefined;
+ const store = () => {
+ const snapshot = relay.snapshot();
+ if (
+ current?.snapshot.session !== snapshot.session ||
+ current.snapshot.scope !== snapshot.scope
+ )
+ current = {
+ snapshot,
+ store: savedAgentStore(snapshot.session, snapshot.scope ?? ""),
+ };
+ return current.store;
+ };
+ return {
+ snapshot: () => store().snapshot(),
+ subscribe(listener: () => void) {
+ // The saved choice's sources also change for unrelated view state, so
+ // only a different Bestie, or community, is passed on.
+ let following = store();
+ let last = following.snapshot();
+ const changed = () => {
+ const next = following.snapshot();
+ if (next === last) return;
+ last = next;
+ listener();
+ };
+ let stop = following.subscribe(changed);
+ const stopRelay = relay.subscribe(() => {
+ if (store() === following) return;
+ stop();
+ following = store();
+ last = following.snapshot();
+ stop = following.subscribe(changed);
+ listener();
+ });
+ return () => {
+ stopRelay();
+ stop();
+ };
+ },
+ };
+}
+
+export function inCommunity(agent: Agent, { scope }: RelaySnapshot) {
+ try {
+ return relayPartition(relayOrigin(agent.relay), agent.owner) === scope;
+ } catch {
+ return false;
+ }
+}
diff --git a/tests/browser/bestie-tab.spec.mjs b/tests/browser/bestie-tab.spec.mjs
new file mode 100644
index 000000000..06a605d6d
--- /dev/null
+++ b/tests/browser/bestie-tab.spec.mjs
@@ -0,0 +1,52 @@
+import { test, expect } from "./source-fixture.mjs";
+
+// Real layout is outside jsdom: the tab at the Agents side panel's default and
+// narrowest widths, with large text, never runs off the panel.
+// With a section edited by hand, its row shows Restore instead of a switch.
+for (const [variant, query] of [
+ ["bestie", ""],
+ ["former", "?former"],
+ ["edited", "?edited"],
+]) {
+ test(`The ${variant} Bestie tab fits the side panel at every width and text size`, async ({
+ page,
+ }) => {
+ await page.goto(`/tests/fixtures/bestie-tab.html${query}`);
+ if (variant === "edited")
+ await expect(page.getByRole("button", { name: "Restore" })).toBeVisible();
+ const panel = page.getByRole("complementary", { name: "Agent panel" });
+ await expect(
+ page.getByText("Private channels", { exact: false }).first(),
+ ).toBeVisible();
+ for (const width of ["28rem", "20rem"]) {
+ for (const fontSize of ["16px", "20px"]) {
+ await page.evaluate(
+ ([width, fontSize]) => {
+ document.documentElement.style.setProperty(
+ "--secondary-panel-width",
+ width,
+ );
+ document.documentElement.style.fontSize = fontSize;
+ },
+ [width, fontSize],
+ );
+ if (process.env.BESTIE_SHOTS)
+ await panel.screenshot({
+ path: `${process.env.BESTIE_SHOTS}/${variant}-${width}-${fontSize}.png`,
+ });
+ expect(
+ await panel.evaluate((el) => el.scrollWidth <= el.clientWidth),
+ `${width} at ${fontSize}`,
+ ).toBe(true);
+ for (const button of await panel.getByRole("button").all()) {
+ const box = await button.boundingBox();
+ const bounds = await panel.boundingBox();
+ expect(
+ box && bounds && box.x + box.width <= bounds.x + bounds.width + 0.5,
+ `${await button.textContent()} at ${width} ${fontSize}`,
+ ).toBe(true);
+ }
+ }
+ }
+ });
+}
diff --git a/tests/fixtures/bestie-tab.html b/tests/fixtures/bestie-tab.html
new file mode 100644
index 000000000..e3ed30ce9
--- /dev/null
+++ b/tests/fixtures/bestie-tab.html
@@ -0,0 +1,2 @@
+
+Bestie tab fixture
diff --git a/tests/fixtures/bestie-tab.tsx b/tests/fixtures/bestie-tab.tsx
new file mode 100644
index 000000000..c552f66e5
--- /dev/null
+++ b/tests/fixtures/bestie-tab.tsx
@@ -0,0 +1,131 @@
+// The production Bestie tabs in the Agents side panel's layout, with only the
+// host data boundary replaced. `?former` shows the Former Bestie tab, and
+// `?edited` a memory section whose closing marker was edited by hand.
+import { createRoot } from "react-dom/client";
+import styles from "../../src/bundled/agents/AgentsPage.module.css";
+import { bestieTab, formerBestieTab } from "../../src/bundled/bestie/BestieTab";
+import {
+ checkinHourly,
+ memory,
+ withFeature,
+} from "../../src/bundled/bestie/features";
+import type { Agent } from "../../src/features/agents/service";
+import type { RelayData } from "../../src/features/relay/service";
+import "../../src/shared/styles/globals.css";
+
+const OWNER = "f".repeat(64);
+const ADA = "a".repeat(64);
+const BEA = "b".repeat(64);
+const query = new URLSearchParams(location.search);
+const former = query.has("former");
+const edited = query.has("edited");
+const channel = (name: string, members: string[]) => ({
+ id: `id-${name}`,
+ name,
+ channelType: "stream",
+ private: true,
+ members,
+});
+const list = {
+ channels: [
+ channel("design-reviews-and-weekly-planning-notes", [OWNER, ADA]),
+ channel("family", [OWNER, ADA]),
+ channel("leadership-offsite", [OWNER]),
+ channel("hiring", [OWNER]),
+ ],
+};
+const choice = (pubkey: string, name: string) => ({
+ pubkey,
+ name,
+ managed: true,
+});
+const choices = {
+ status: "ready",
+ complete: true,
+ pending: false,
+ identities: [choice(ADA, "Ada"), choice(BEA, "Bea")],
+ selectable: [choice(ADA, "Ada"), choice(BEA, "Bea")],
+ archives: { status: "unavailable", archived: [] },
+};
+const connection = {
+ status: "ready",
+ scope: `https://relay.example:${OWNER}`,
+ session: {
+ viewer: OWNER,
+ outbox: { supports: () => true },
+ directMessages: { open: async () => "dm" },
+ channels: {
+ list: () => list,
+ subscribeList: () => () => {},
+ ensureList: () => {},
+ },
+ memberAdditions: { add: async () => {} },
+ memberAdministration: {
+ refresh: async () => {},
+ run: async () => {},
+ snapshot: () => ({ status: "ready", authority: { roles: {} } }),
+ },
+ mainAgent: {
+ read: async () => (former ? BEA : ADA),
+ write: async () => {},
+ },
+ agentChoices: {
+ snapshot: () => choices,
+ subscribe: () => () => {},
+ retain: () => () => {},
+ },
+ },
+};
+const relay = {
+ snapshot: () => connection,
+ subscribe: () => () => {},
+} as unknown as RelayData;
+const agents = {
+ save: async () => {},
+ types: () => [{ key: "claude-code", adopt: () => ({}) }],
+};
+const agent = {
+ key: ADA,
+ pubkey: ADA,
+ name: "Ada",
+ type: "claude-code",
+ owner: OWNER,
+ relay: "wss://relay.example",
+ place: "here",
+ attentionEnabled: true,
+ attention: {
+ "interest/bestie-hourly-check-in": { value: { type: "interest" } },
+ "watch/bestie-hourly-check-in": { value: { type: "timer" } },
+ },
+ config: {
+ instructions: withFeature(
+ withFeature("Be brief.", memory, true),
+ checkinHourly,
+ true,
+ ).replace(
+ "",
+ edited ? " Also answer in French." : "$&",
+ ),
+ },
+} as unknown as Agent;
+const tab = (former ? formerBestieTab : bestieTab)(relay, agents);
+const Tab = tab.component;
+const root = document.getElementById("root");
+if (!root) throw new Error("Missing fixture root");
+createRoot(root).render(
+