@kybernesis/buzz 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,13 +55,20 @@ sessions are keyed by channel.
55
55
 
56
56
  ## Who it says it is
57
57
 
58
- An agent connected to a `.agent` name takes its profile from the name: display name,
59
- description, picture and NIP-05 handle. The bridge reads the name's public profile document
60
- (`https://<name>.agentid.dev/.well-known/agent-profile.json`) when it starts and every six
61
- hours, and writes the profile only in communities whose copy differs. A new picture, a new
62
- description, or the name's address moving reaches every community without a command on the
63
- host. The name comes from `ARP_AGENT_DID`, or from `.eve/arp-identity.json` in the agent's
64
- directory. `kybernesis-buzz profile --from-agentid` does the same once, on demand.
58
+ An agent shows the name and picture its owner set in the app or KYBER Studio, and where the
59
+ owner has not chosen, what its `.agent` name says (display name, description, picture and
60
+ NIP-05 handle). The bridge reads the owner's choice from the control plane
61
+ (`GET /api/agent/profile`, with the agent's credential) and the name's public profile document
62
+ (`https://<name>.agentid.dev/.well-known/agent-profile.json`) when it starts and every fifteen
63
+ minutes, and writes the profile only in communities whose copy differs. The control plane holds
64
+ the picture as a small image, so it is uploaded to a community's own media store once per change
65
+ and that address is used everywhere. A new picture or name reaches every community without a
66
+ command on the host. The `.agent` name comes from `ARP_AGENT_DID`, or from
67
+ `.eve/arp-identity.json` in the agent's directory.
68
+
69
+ `kybernesis-buzz profile --sync` does the same once, on demand: the owner's Sync profile button
70
+ runs it through `@kybernesis/manage` (`buzz.profile`). `kybernesis-buzz profile --from-agentid`
71
+ still takes the profile from the name alone.
65
72
 
66
73
  ## Configuration
67
74
 
@@ -73,7 +80,7 @@ directory. `kybernesis-buzz profile --from-agentid` does the same once, on deman
73
80
  | `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
74
81
  | `BUZZ_AGENT_SILENCE_TIMEOUT_MS` | Maximum silence before the agent acknowledges a request: unread drain, send and create acknowledgement (default `300000`, five minutes). This is where a genuine stall shows: no run is ever created. |
75
82
  | `BUZZ_AGENT_WORK_TIMEOUT_MS` | Ceiling on silence once the response stream is open (default `3600000`, sixty minutes). The stream is quiet while the agent works, a long tool call or a delegated subagent produces nothing until it returns, so this is a ceiling on work, not a stall detector. |
76
- | `BUZZ_PROFILE_SYNC_MS` | How often the profile is re-checked against the agent's `.agent` name (default `21600000`, six hours; `0` turns it off) |
83
+ | `BUZZ_PROFILE_SYNC_MS` | How often the profile is re-checked against the name and picture its owner set and its `.agent` name (default `900000`, fifteen minutes; `0` turns it off) |
77
84
  | `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
78
85
  | `KYBERNESIS_ISSUER` | The control plane |
79
86
  | `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { buzzBridge } from "./bridge.js";
6
6
  import { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
7
7
  import * as profile from "./profile.js";
8
8
  import { nameFromDid, syncProfileFromName } from "./agentid.js";
9
+ import { resolveFace, syncProfile } from "./face.js";
9
10
  /**
10
11
  * Setup and operation for an agent that lives in a workspace.
11
12
  *
@@ -60,6 +61,27 @@ function identityDid() {
60
61
  return null;
61
62
  }
62
63
  }
64
+ /** One line per community, in the shape the Channels page reads back. */
65
+ function printOutcomes(outcomes) {
66
+ for (const { relay, outcome, detail } of outcomes) {
67
+ if (outcome === "updated")
68
+ console.log(` ✓ ${relay}${detail ? ` — ${detail}` : ""}`);
69
+ else if (outcome === "unchanged")
70
+ console.log(` ✓ ${relay} — already up to date`);
71
+ else if (outcome === "not-member")
72
+ console.log(` · ${relay} — not a member yet, so nothing to be known as there`);
73
+ else
74
+ console.log(` ✕ ${relay} — ${detail}`);
75
+ }
76
+ }
77
+ /** Where the face comes from: the control plane when this host has a credential, and the `.agent` name. */
78
+ function faceSources(name) {
79
+ return {
80
+ name,
81
+ issuer: process.env.KYBERNESIS_ISSUER ?? "https://agent.kybernesis.ai",
82
+ ...(process.env.KYBERNESIS_AGENT_CREDENTIAL ? { credential: process.env.KYBERNESIS_AGENT_CREDENTIAL } : {}),
83
+ };
84
+ }
63
85
  /**
64
86
  * Say who this agent is, in every community it belongs to.
65
87
  *
@@ -79,6 +101,25 @@ async function setProfile() {
79
101
  console.log();
80
102
  return;
81
103
  }
104
+ // The face its owner chose (the control plane), over its `.agent` name: what the bridge
105
+ // keeps in step on its own, run now. The Sync profile button in the app runs this.
106
+ if (process.argv.includes("--sync")) {
107
+ const did = identityDid();
108
+ const name = did ? nameFromDid(did) : null;
109
+ let outcomes;
110
+ try {
111
+ const face = await resolveFace(faceSources(name));
112
+ outcomes = await syncProfile({ face, relays: urls, key });
113
+ }
114
+ catch (error) {
115
+ console.error(`\n ${error.message}\n`);
116
+ process.exit(1);
117
+ }
118
+ printOutcomes(outcomes);
119
+ const reached = outcomes.filter((o) => o.outcome === "updated" || o.outcome === "unchanged").length;
120
+ console.log(reached > 0 ? `\n ${key.npub.slice(0, 20)}… now looks the way its owner set it.\n` : "\n Nothing published — invite this key to a community first.\n");
121
+ return;
122
+ }
82
123
  // AgentID: the agent's `.agent` name is the source of truth for how it
83
124
  // presents itself. `--from-agentid` reads the name's public profile document
84
125
  // and publishes it as this key's kind-0 — name, description, picture and
@@ -103,16 +144,7 @@ async function setProfile() {
103
144
  console.error(`\n ${error.message}\n`);
104
145
  process.exit(1);
105
146
  }
106
- for (const { relay, outcome, detail } of outcomes) {
107
- if (outcome === "updated")
108
- console.log(` ✓ ${relay}`);
109
- else if (outcome === "unchanged")
110
- console.log(` ✓ ${relay} — already matches the name`);
111
- else if (outcome === "not-member")
112
- console.log(` · ${relay} — not a member yet, so nothing to be known as there`);
113
- else
114
- console.log(` ✕ ${relay} — ${detail}`);
115
- }
147
+ printOutcomes(outcomes);
116
148
  const reached = outcomes.filter((o) => o.outcome === "updated" || o.outcome === "unchanged").length;
117
149
  console.log(reached > 0 ? `\n ${name}.agent is ${key.npub.slice(0, 20)}… — profile taken from the name.\n` : "\n Nothing published — invite this key to a community first.\n");
118
150
  return;
@@ -120,6 +152,7 @@ async function setProfile() {
120
152
  const name = flag("name");
121
153
  if (!name) {
122
154
  console.error("usage: kybernesis-buzz profile --name <name> [--about …] [--picture <file|url>]");
155
+ console.error(" or: kybernesis-buzz profile --sync");
123
156
  console.error(" or: kybernesis-buzz profile --from-agentid [<name>.agent]");
124
157
  console.error(" or: kybernesis-buzz profile --copy-from <wss://…>");
125
158
  process.exit(1);
@@ -199,20 +232,23 @@ function run() {
199
232
  ? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
200
233
  : {}),
201
234
  });
202
- // How the agent presents itself follows its `.agent` name: checked on start and then every
203
- // BUZZ_PROFILE_SYNC_MS (default six hours, 0 turns it off), and written only where a
204
- // community's copy differs. A new picture or a moved address needs no command on this host.
235
+ // How the agent presents itself follows the face its owner chose (name and picture, from the
236
+ // control plane), with its `.agent` name filling the gaps: checked on start and then every
237
+ // BUZZ_PROFILE_SYNC_MS (default fifteen minutes, 0 turns it off), and written only where a
238
+ // community's copy differs. A new picture or name needs no command on this host.
205
239
  const did = identityDid();
206
240
  const name = did ? nameFromDid(did) : null;
207
- const every = Number(process.env.BUZZ_PROFILE_SYNC_MS ?? 6 * 60 * 60 * 1000);
241
+ const every = Number(process.env.BUZZ_PROFILE_SYNC_MS ?? 15 * 60 * 1000);
208
242
  const timers = [];
209
- if (name && every > 0) {
243
+ const sources = faceSources(name);
244
+ if ((name || sources.credential) && every > 0) {
210
245
  const log = (message) => console.log(new Date().toISOString().slice(11, 19), message);
211
- const sync = () => syncProfileFromName({ name, relays: relayList(), key: loadKey(KEY_FILE) })
246
+ const sync = () => resolveFace(sources)
247
+ .then((face) => syncProfile({ face, relays: relayList(), key: loadKey(KEY_FILE) }))
212
248
  .then((outcomes) => {
213
249
  for (const { relay, outcome, detail } of outcomes) {
214
250
  if (outcome === "updated")
215
- log(`profile: ${relay} updated from ${name}.agent`);
251
+ log(`profile: ${relay} updated${detail ? ` (${detail})` : ""}`);
216
252
  if (outcome === "failed")
217
253
  log(`profile: ${relay} not updated — ${detail}`);
218
254
  }
@@ -384,8 +420,9 @@ else {
384
420
  BUZZ_SESSIONS_FILE channel-to-session continuity store
385
421
  BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
386
422
  BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
387
- BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the .agent name
388
- (default 21600000ms, six hours; 0 turns it off)
423
+ BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the name and
424
+ picture its owner set (default 900000ms, fifteen
425
+ minutes; 0 turns it off)
389
426
  KYBERNESIS_ISSUER the control plane
390
427
  KYBERNESIS_AGENT_CREDENTIAL this agent's credential
391
428
  `);
package/dist/face.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { AgentKey } from "./keys.js";
2
+ import * as profile from "./profile.js";
3
+ import type { Profile } from "./profile.js";
4
+ import { type SyncOutcome } from "./agentid.js";
5
+ /**
6
+ * The agent's face in every community: the name and picture its owner chose.
7
+ *
8
+ * @remarks
9
+ * The owner sets them in the app or KYBER Studio, and the control plane holds them. Before
10
+ * this, a community only ever saw what was published when the agent joined (its bare
11
+ * registered name) or what its `.agent` name said, so a new picture or name chosen by the
12
+ * owner never reached Buzz, and a name sync put the old one back. Now the owner's choice wins
13
+ * field by field and the `.agent` name fills the gaps, the same order every client draws.
14
+ *
15
+ * The control plane keeps the picture as a small data URL; a profile can only carry an
16
+ * address. So the picture is uploaded to a community's own media store, addressed by the hash
17
+ * of its bytes, and a community whose copy already points at that hash is left alone.
18
+ */
19
+ export type Face = {
20
+ /** The handle: lowercase, no spaces. */
21
+ handle: string;
22
+ /** Null when nobody has chosen one: the community keeps what it has. */
23
+ displayName: string | null;
24
+ /** A data URL, an https address, or null to leave the picture as it is. */
25
+ picture: string | null;
26
+ about: string | null;
27
+ nip05: string | null;
28
+ };
29
+ type ControlPlaneFace = {
30
+ agent: string;
31
+ displayName: string | null;
32
+ picture: string | null;
33
+ about: string | null;
34
+ nip05: string | null;
35
+ };
36
+ /** GET /api/agent/profile: the calling agent's own face, as its owner set it. */
37
+ export declare function fetchControlPlaneFace(options: {
38
+ issuer: string;
39
+ credential: string;
40
+ fetch?: typeof fetch;
41
+ }): Promise<ControlPlaneFace>;
42
+ /**
43
+ * What the agent should look like: the owner's choices over its `.agent` name.
44
+ *
45
+ * @remarks
46
+ * Either source may be missing (an older control plane, an agent with no name); only both
47
+ * missing is an error. The `.agent` name is read even when the control plane answers, because
48
+ * the control plane only folds it in once the name lists the agent back as verified, and an
49
+ * agent that has always taken its picture from its name should keep doing so.
50
+ */
51
+ export declare function resolveFace(options: {
52
+ /** The `.agent` name, when this agent has one. */
53
+ name: string | null;
54
+ issuer?: string;
55
+ credential?: string;
56
+ fetch?: typeof fetch;
57
+ }): Promise<Face>;
58
+ /** The profile fields the face decides, given where its picture now lives. */
59
+ export declare function profileFromFace(face: Face, pictureUrl: string | null): Profile;
60
+ /**
61
+ * Bring this agent's profile in every community in line with its face.
62
+ *
63
+ * @remarks
64
+ * Each community is read first; only one whose copy differs is written, so running this on a
65
+ * timer costs one read per community when nothing has changed. A picture is uploaded at most
66
+ * once per change, to the first community that takes it, and that address is used everywhere,
67
+ * the way `kybernesis-buzz profile --picture` always has. Fields the face does not decide are
68
+ * kept. A community that refuses or cannot be reached is reported, not fatal.
69
+ */
70
+ export declare function syncProfile(options: {
71
+ face: Face;
72
+ relays: readonly string[];
73
+ key: AgentKey;
74
+ read?: typeof profile.read;
75
+ write?: typeof profile.write;
76
+ upload?: typeof profile.uploadImage;
77
+ }): Promise<SyncOutcome[]>;
78
+ export {};
package/dist/face.js ADDED
@@ -0,0 +1,148 @@
1
+ import * as profile from "./profile.js";
2
+ import { fetchNameProfile } from "./agentid.js";
3
+ /** GET /api/agent/profile: the calling agent's own face, as its owner set it. */
4
+ export async function fetchControlPlaneFace(options) {
5
+ const url = `${options.issuer.replace(/\/+$/, "")}/api/agent/profile`;
6
+ const res = await (options.fetch ?? fetch)(url, {
7
+ headers: { authorization: `Bearer ${options.credential}`, accept: "application/json" },
8
+ signal: AbortSignal.timeout(10_000),
9
+ });
10
+ if (!res.ok)
11
+ throw new Error(`the control plane did not give this agent's profile (${res.status} from ${url})`);
12
+ return (await res.json());
13
+ }
14
+ function handleOf(name) {
15
+ return name.trim().toLowerCase().replace(/\s+/g, "-");
16
+ }
17
+ /**
18
+ * What the agent should look like: the owner's choices over its `.agent` name.
19
+ *
20
+ * @remarks
21
+ * Either source may be missing (an older control plane, an agent with no name); only both
22
+ * missing is an error. The `.agent` name is read even when the control plane answers, because
23
+ * the control plane only folds it in once the name lists the agent back as verified, and an
24
+ * agent that has always taken its picture from its name should keep doing so.
25
+ */
26
+ export async function resolveFace(options) {
27
+ let owner = null;
28
+ let ownerError = null;
29
+ if (options.issuer && options.credential) {
30
+ try {
31
+ owner = await fetchControlPlaneFace({ issuer: options.issuer, credential: options.credential, fetch: options.fetch });
32
+ }
33
+ catch (error) {
34
+ ownerError = error;
35
+ }
36
+ }
37
+ let named = null;
38
+ let nameError = null;
39
+ if (options.name) {
40
+ try {
41
+ named = await fetchNameProfile(options.name, options.fetch);
42
+ }
43
+ catch (error) {
44
+ nameError = error;
45
+ }
46
+ }
47
+ if (!owner && !named) {
48
+ throw ownerError ?? nameError ?? new Error("Nothing says who this agent is: no control plane credential and no .agent name.");
49
+ }
50
+ return {
51
+ handle: options.name ?? handleOf(owner.agent),
52
+ displayName: owner?.displayName ?? named?.name ?? null,
53
+ picture: owner?.picture ?? named?.picture ?? null,
54
+ about: owner?.about ?? (named?.description || null),
55
+ nip05: owner?.nip05 ?? named?.nip05 ?? null,
56
+ };
57
+ }
58
+ /** A picture held as a data URL, decoded and stripped the way an upload will be. */
59
+ async function pictureBytes(dataUrl) {
60
+ const match = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(dataUrl);
61
+ if (!match)
62
+ return null;
63
+ const mediaType = match[1].toLowerCase();
64
+ const bytes = new Uint8Array(Buffer.from(match[2], "base64"));
65
+ const { createHash } = await import("node:crypto");
66
+ const sha256 = createHash("sha256").update(profile.stripMetadata(bytes, mediaType)).digest("hex");
67
+ return { bytes, mediaType, sha256 };
68
+ }
69
+ /** The profile fields the face decides, given where its picture now lives. */
70
+ export function profileFromFace(face, pictureUrl) {
71
+ return {
72
+ name: face.handle,
73
+ ...(face.displayName ? { display_name: face.displayName } : {}),
74
+ ...(face.about ? { about: face.about } : {}),
75
+ ...(pictureUrl ? { picture: pictureUrl } : {}),
76
+ ...(face.nip05 ? { nip05: face.nip05 } : {}),
77
+ bot: true,
78
+ };
79
+ }
80
+ /**
81
+ * Bring this agent's profile in every community in line with its face.
82
+ *
83
+ * @remarks
84
+ * Each community is read first; only one whose copy differs is written, so running this on a
85
+ * timer costs one read per community when nothing has changed. A picture is uploaded at most
86
+ * once per change, to the first community that takes it, and that address is used everywhere,
87
+ * the way `kybernesis-buzz profile --picture` always has. Fields the face does not decide are
88
+ * kept. A community that refuses or cannot be reached is reported, not fatal.
89
+ */
90
+ export async function syncProfile(options) {
91
+ const read = options.read ?? profile.read;
92
+ const write = options.write ?? profile.write;
93
+ const upload = options.upload ?? profile.uploadImage;
94
+ const { face, key } = options;
95
+ const current = new Map();
96
+ const outcomes = new Map();
97
+ for (const relay of options.relays) {
98
+ try {
99
+ current.set(relay, await read(relay, key));
100
+ }
101
+ catch (error) {
102
+ const why = error.message;
103
+ outcomes.set(relay, { relay, outcome: why.includes("not a relay member") ? "not-member" : "failed", detail: why });
104
+ }
105
+ }
106
+ // Where the picture lives. Null leaves each community's picture as it is.
107
+ let pictureUrl = null;
108
+ let pictureProblem = null;
109
+ if (face.picture && /^https:\/\//.test(face.picture)) {
110
+ pictureUrl = face.picture;
111
+ }
112
+ else if (face.picture) {
113
+ const picture = await pictureBytes(face.picture);
114
+ if (!picture) {
115
+ pictureProblem = "the picture is not an image this can send";
116
+ }
117
+ else {
118
+ pictureUrl = [...current.values()].map((p) => p?.picture).find((url) => url?.includes(picture.sha256)) ?? null;
119
+ for (const relay of current.keys()) {
120
+ if (pictureUrl)
121
+ break;
122
+ try {
123
+ pictureUrl = await upload(relay, key, picture.bytes, picture.mediaType);
124
+ }
125
+ catch (error) {
126
+ pictureProblem = error.message;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ const wanted = profileFromFace(face, pictureUrl);
132
+ for (const [relay, existing] of current) {
133
+ const matches = existing && Object.keys(wanted).every((field) => existing[field] === wanted[field]);
134
+ if (matches) {
135
+ outcomes.set(relay, { relay, outcome: "unchanged" });
136
+ continue;
137
+ }
138
+ try {
139
+ await write(relay, key, { ...(existing ?? {}), ...wanted });
140
+ outcomes.set(relay, { relay, outcome: "updated", ...(pictureProblem ? { detail: `picture not sent: ${pictureProblem}` } : {}) });
141
+ }
142
+ catch (error) {
143
+ const why = error.message;
144
+ outcomes.set(relay, { relay, outcome: why.includes("not a relay member") ? "not-member" : "failed", detail: why });
145
+ }
146
+ }
147
+ return options.relays.map((relay) => outcomes.get(relay));
148
+ }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export { BuzzRelay, type NostrEvent, type PresenceStatus, type RelayOptions, KIN
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode, type AgentKey } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
5
  export type { Profile } from "./profile.js";
6
+ export { fetchControlPlaneFace, profileFromFace, resolveFace, syncProfile, type Face } from "./face.js";
6
7
  export { readSurfaceManifest, surfaceWriter, surfacesDir, type SurfaceManifest } from "./surface.js";
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ export { buzzBridge } from "./bridge.js";
2
2
  export { BuzzRelay, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
+ export { fetchControlPlaneFace, profileFromFace, resolveFace, syncProfile } from "./face.js";
5
6
  export { readSurfaceManifest, surfaceWriter, surfacesDir } from "./surface.js";
package/dist/turn.d.ts CHANGED
@@ -82,8 +82,17 @@ export declare function silentTurnReply(failures: readonly TurnFailure[]): strin
82
82
  export declare function describeSilentTurn(failures: readonly TurnFailure[]): string;
83
83
  /** Convert one inbound Buzz message into an eve-compatible turn. */
84
84
  export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
85
+ /**
86
+ * How long reading the backlog of a conversation on record may take before the
87
+ * conversation is treated as gone. An idle session's unread events arrive in
88
+ * well under a second; a session whose durable run died under a runtime
89
+ * upgrade never finishes. On 2026-10-05 a Buzz channel's stored run had failed
90
+ * replay after the eve 0.68 cutover, and every message in that channel sat at
91
+ * "typing" with nothing reaching the agent, for as long as anyone waited.
92
+ */
93
+ export declare const DEFAULT_UNREAD_DRAIN_TIMEOUT_MS = 20000;
85
94
  /** Continue a channel's existing conversation, replacing it only when it is explicitly stale. */
86
- export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number, agentWorkTimeoutMs?: number): Promise<TurnOutcome>;
95
+ export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number, agentWorkTimeoutMs?: number, unreadDrainTimeoutMs?: number): Promise<TurnOutcome>;
87
96
  /** What the room is told when eve refuses the turn itself. */
88
97
  /**
89
98
  * The two answers from eve that mean the stored session cannot carry another
package/dist/turn.js CHANGED
@@ -313,12 +313,23 @@ async function sendExisting(responsePromise, streamIndex, timeouts, continueStre
313
313
  watchdog.dispose();
314
314
  }
315
315
  }
316
+ /**
317
+ * How long reading the backlog of a conversation on record may take before the
318
+ * conversation is treated as gone. An idle session's unread events arrive in
319
+ * well under a second; a session whose durable run died under a runtime
320
+ * upgrade never finishes. On 2026-10-05 a Buzz channel's stored run had failed
321
+ * replay after the eve 0.68 cutover, and every message in that channel sat at
322
+ * "typing" with nothing reaching the agent, for as long as anyone waited.
323
+ */
324
+ export const DEFAULT_UNREAD_DRAIN_TIMEOUT_MS = 20_000;
316
325
  /** Continue a channel's existing conversation, replacing it only when it is explicitly stale. */
317
- export async function answerTurn(client, sessions, channel, message, community, log = () => { }, agentSilenceTimeoutMs = DEFAULT_AGENT_SILENCE_TIMEOUT_MS, agentWorkTimeoutMs = DEFAULT_AGENT_WORK_TIMEOUT_MS) {
326
+ export async function answerTurn(client, sessions, channel, message, community, log = () => { }, agentSilenceTimeoutMs = DEFAULT_AGENT_SILENCE_TIMEOUT_MS, agentWorkTimeoutMs = DEFAULT_AGENT_WORK_TIMEOUT_MS, unreadDrainTimeoutMs = DEFAULT_UNREAD_DRAIN_TIMEOUT_MS) {
318
327
  const intervalMs = {
319
328
  silenceMs: validateAgentSilenceTimeoutMs(agentSilenceTimeoutMs),
320
329
  workMs: validateAgentSilenceTimeoutMs(agentWorkTimeoutMs),
321
330
  };
331
+ // Never longer than the silence bound, so a caller that tightened silence tightened this too.
332
+ const drainMs = Math.min(validateAgentSilenceTimeoutMs(unreadDrainTimeoutMs), intervalMs.silenceMs);
322
333
  const existing = sessions.get(community, channel);
323
334
  if (existing) {
324
335
  try {
@@ -330,8 +341,24 @@ export async function answerTurn(client, sessions, channel, message, community,
330
341
  * `send()` opens its response stream at the position the handle already
331
342
  * holds and stops at the FIRST turn boundary it meets. Draining first
332
343
  * repairs any cursor that was left behind by a prior disconnected reader.
344
+ * Bounded on its own, tightly: this read is of a backlog, not of work.
345
+ * A backlog that does not finish is not proof of anything by itself —
346
+ * the run behind it may be dead (eve 0.68 cutover, 2026-10-05: a stored
347
+ * run that failed replay streamed nothing, and the channel sat at
348
+ * "typing" for good) or the transport may be having a moment. So the
349
+ * stall is logged and the send goes ahead: the agent's answer to the
350
+ * send decides. 404 / "no longer active" replaces the session below; a
351
+ * real reply continues it; a transport failure preserves it.
333
352
  */
334
- const unread = await drainUnread((signal) => session.stream({ follow: false, startIndex: existing.streamIndex, signal }), intervalMs);
353
+ let unread = 0;
354
+ try {
355
+ unread = await drainUnread((signal) => session.stream({ follow: false, startIndex: existing.streamIndex, signal }), { silenceMs: drainMs, workMs: drainMs });
356
+ }
357
+ catch (error) {
358
+ if (!(error instanceof AgentSilenceTimeoutError))
359
+ throw error;
360
+ log(`backlog of ${channel.slice(0, 8)} (${existing.id}) did not finish reading in ${drainMs}ms; asking the agent directly`);
361
+ }
335
362
  if (unread > 0) {
336
363
  log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
337
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",