Repository navigation
feat(bestie): hourly check-ins from your Bestie #2037
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: rileycrane/bestie-l4-private-channels
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Agents, "save" | "types">; | ||
|
|
@@ -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,39 +166,67 @@ function BestieTab({ | |
| </SettingsGroup> | ||
| <InlineHeader title="Settings" /> | ||
| <SettingsGroup> | ||
| {FEATURES.map((feature) => ( | ||
| {FEATURES.filter((feature) => !feature.timer).map((feature) => ( | ||
| <FeatureSwitch | ||
| key={feature.id} | ||
| agents={agents} | ||
| agent={agent} | ||
| session={session} | ||
| feature={feature} | ||
| saving={saving} | ||
| setSaving={setSaving} | ||
| /> | ||
| ))} | ||
| </SettingsGroup> | ||
| <InlineHeader title="Check-ins" /> | ||
| <SettingsGroup> | ||
| {FEATURES.filter((feature) => feature.timer).map((feature) => ( | ||
| <FeatureSwitch | ||
| key={feature.id} | ||
| agents={agents} | ||
| agent={agent} | ||
| session={session} | ||
| feature={feature} | ||
| saving={saving} | ||
| setSaving={setSaving} | ||
| /> | ||
| ))} | ||
| <PreferenceRow | ||
| title="Check-ins" | ||
| subtitle="Every hour. Not set up yet." | ||
| /> | ||
| </SettingsGroup> | ||
| <PrivateChannels session={session} bestie={agent.pubkey} /> | ||
| {bestie.view} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** 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<string>(); | ||
| // 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<void>) => { | ||
| 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<string, AttentionValue | null> = {}; | ||
| 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 && ( | ||
| <p role="alert" className="buzz-field-error"> | ||
|
|
@@ -270,9 +364,18 @@ function FeatureSwitch({ | |
| <> | ||
| <SwitchPreferenceRow | ||
| label={feature.title} | ||
| description={feature.description} | ||
| checked={hasFeature(instructions, feature)} | ||
| disabled={saving} | ||
| description={ | ||
| blocked && required && !checked | ||
| ? `Turn on ${required.title} first.` | ||
| : // Attention reads off while no plugin shows it, as when the | ||
| // Watching plugin is off, or while the agent's switch is off. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 P2 — Reflect a timer paused individually in Attention. After a successful setup, pause |
||
| checked && feature.timer && !agent.attentionEnabled | ||
| ? "Paused: watches aren't running on this device. Turn on the Watching plugin and its Run watches switch." | ||
| : feature.description | ||
| } | ||
| checked={checked} | ||
| // A check-in left on without memory can still be turned off. | ||
| disabled={saving || (blocked && !checked)} | ||
| onCheckedChange={(on) => void save(on)} | ||
| /> | ||
| {alert} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 P2 — Do not leave an enabled timer after the instruction save fails. This first save commits the hourly timer and enables the agent’s Attention before the following config save. A rejected/conflicting instruction revision leaves those effects in place, but
featureOnreports unchecked because the Hourly upkeep section is absent. The scheduler (runner.ts:2012–2074) admits the saved timer independently of that section, so, with Watching on and destination membership confirmed, the failed enable can keep consuming tokens hourly. Clicking the unchecked switch retries On; it does not remove the live timer. Preserve the intended timer-first ordering by keeping staged work disabled until instructions are confirmed, or compensate the owned timer on failure and expose explicit partial-state cleanup. Do not enable unrelated watches until the operation succeeds. Cover instruction rejection and both retry/cancel recovery; reporting unchecked alone is not a safe recovery state.