@kybernesis/buzz 0.10.0 → 0.10.2

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
@@ -53,6 +53,16 @@ BUZZ_RELAY="wss://one.communities.buzz.xyz,wss://two.communities.buzz.xyz"
53
53
  Conversations never mix: a channel belongs to exactly one community, and
54
54
  sessions are keyed by channel.
55
55
 
56
+ ## Who it says it is
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.
65
+
56
66
  ## Configuration
57
67
 
58
68
  | Variable | Meaning |
@@ -63,6 +73,8 @@ sessions are keyed by channel.
63
73
  | `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
64
74
  | `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. |
65
75
  | `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) |
77
+ | `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
66
78
  | `KYBERNESIS_ISSUER` | The control plane |
67
79
  | `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
68
80
 
@@ -0,0 +1,54 @@
1
+ import type { AgentKey } from "./keys.js";
2
+ import * as profile from "./profile.js";
3
+ import type { Profile } from "./profile.js";
4
+ /**
5
+ * An agent's `.agent` name is the source of truth for how it presents itself.
6
+ *
7
+ * @remarks
8
+ * The name's public profile document carries the name, description, picture and NIP-05
9
+ * handle. The bridge keeps each community's copy of the profile in step with it, so a change
10
+ * made on the name — a new picture, a new description, or the name's address moving — reaches
11
+ * every community without anyone running a command on the agent's host. A one-off copy is
12
+ * exactly what went stale when the addresses moved: the profile kept pointing at a picture URL
13
+ * that no longer existed.
14
+ */
15
+ /** Where names serve their public documents: `<name>.agentid.dev`. */
16
+ export declare const DEFAULT_MIRROR_SUFFIX = ".agentid.dev";
17
+ export type NameProfile = {
18
+ name: string;
19
+ description: string;
20
+ picture: string | null;
21
+ nip05: string | null;
22
+ };
23
+ /** `did:web:kyber.agent`, `kyber.agent` or `kyber` → `kyber`. Anything else is not a `.agent` name. */
24
+ export declare function nameFromDid(did: string): string | null;
25
+ /** The name's public profile document. `AGENTID_MIRROR_SUFFIX` overrides the host, for staging. */
26
+ export declare function profileDocumentUrl(name: string, suffix?: string): string;
27
+ export declare function fetchNameProfile(name: string, fetchImpl?: typeof fetch): Promise<NameProfile>;
28
+ /** The profile fields the name decides. */
29
+ export declare function profileFromName(name: string, doc: NameProfile): Profile;
30
+ /** Whether a community's copy already says everything the name says. */
31
+ export declare function matchesName(current: Profile | null, wanted: Profile): boolean;
32
+ export type SyncOutcome = {
33
+ relay: string;
34
+ outcome: "unchanged" | "updated" | "not-member" | "failed";
35
+ detail?: string;
36
+ };
37
+ /**
38
+ * Bring this agent's profile in every community in line with its name.
39
+ *
40
+ * @remarks
41
+ * Only communities whose copy differs are written, so running this on every start and on a
42
+ * timer costs one read per community when nothing has changed. Fields the name does not
43
+ * decide are left as they are. Throws only when the name's document cannot be read; a
44
+ * community that refuses or is unreachable is reported, not fatal, because membership is per
45
+ * community and being in one and not another is the ordinary case.
46
+ */
47
+ export declare function syncProfileFromName(options: {
48
+ name: string;
49
+ relays: readonly string[];
50
+ key: AgentKey;
51
+ fetch?: typeof fetch;
52
+ read?: typeof profile.read;
53
+ write?: typeof profile.write;
54
+ }): Promise<SyncOutcome[]>;
@@ -0,0 +1,81 @@
1
+ import * as profile from "./profile.js";
2
+ /**
3
+ * An agent's `.agent` name is the source of truth for how it presents itself.
4
+ *
5
+ * @remarks
6
+ * The name's public profile document carries the name, description, picture and NIP-05
7
+ * handle. The bridge keeps each community's copy of the profile in step with it, so a change
8
+ * made on the name — a new picture, a new description, or the name's address moving — reaches
9
+ * every community without anyone running a command on the agent's host. A one-off copy is
10
+ * exactly what went stale when the addresses moved: the profile kept pointing at a picture URL
11
+ * that no longer existed.
12
+ */
13
+ /** Where names serve their public documents: `<name>.agentid.dev`. */
14
+ export const DEFAULT_MIRROR_SUFFIX = ".agentid.dev";
15
+ /** `did:web:kyber.agent`, `kyber.agent` or `kyber` → `kyber`. Anything else is not a `.agent` name. */
16
+ export function nameFromDid(did) {
17
+ const match = /^(?:did:web:)?([a-z0-9-]+)(?:\.agent)?$/i.exec(did.trim());
18
+ return match?.[1] ? match[1].toLowerCase() : null;
19
+ }
20
+ /** The name's public profile document. `AGENTID_MIRROR_SUFFIX` overrides the host, for staging. */
21
+ export function profileDocumentUrl(name, suffix = process.env.AGENTID_MIRROR_SUFFIX ?? DEFAULT_MIRROR_SUFFIX) {
22
+ return `https://${name}${suffix.startsWith(".") ? suffix : `.${suffix}`}/.well-known/agent-profile.json`;
23
+ }
24
+ export async function fetchNameProfile(name, fetchImpl = fetch) {
25
+ const url = profileDocumentUrl(name);
26
+ const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
27
+ if (!res.ok)
28
+ throw new Error(`${name}.agent has no profile document yet (${res.status} from ${url})`);
29
+ const doc = (await res.json());
30
+ return { name: doc.name ?? name, description: doc.description ?? "", picture: doc.picture ?? null, nip05: doc.nip05 ?? null };
31
+ }
32
+ /** The profile fields the name decides. */
33
+ export function profileFromName(name, doc) {
34
+ return {
35
+ name,
36
+ display_name: doc.name,
37
+ ...(doc.description ? { about: doc.description } : {}),
38
+ ...(doc.picture ? { picture: doc.picture } : {}),
39
+ ...(doc.nip05 ? { nip05: doc.nip05 } : {}),
40
+ bot: true,
41
+ };
42
+ }
43
+ /** Whether a community's copy already says everything the name says. */
44
+ export function matchesName(current, wanted) {
45
+ if (!current)
46
+ return false;
47
+ return Object.keys(wanted).every((field) => current[field] === wanted[field]);
48
+ }
49
+ /**
50
+ * Bring this agent's profile in every community in line with its name.
51
+ *
52
+ * @remarks
53
+ * Only communities whose copy differs are written, so running this on every start and on a
54
+ * timer costs one read per community when nothing has changed. Fields the name does not
55
+ * decide are left as they are. Throws only when the name's document cannot be read; a
56
+ * community that refuses or is unreachable is reported, not fatal, because membership is per
57
+ * community and being in one and not another is the ordinary case.
58
+ */
59
+ export async function syncProfileFromName(options) {
60
+ const doc = await fetchNameProfile(options.name, options.fetch);
61
+ const wanted = profileFromName(options.name, doc);
62
+ const read = options.read ?? profile.read;
63
+ const write = options.write ?? profile.write;
64
+ const outcomes = [];
65
+ for (const relay of options.relays) {
66
+ try {
67
+ const current = await read(relay, options.key);
68
+ if (matchesName(current, wanted)) {
69
+ outcomes.push({ relay, outcome: "unchanged" });
70
+ continue;
71
+ }
72
+ await write(relay, options.key, { ...(current ?? {}), ...wanted });
73
+ outcomes.push({ relay, outcome: "updated" });
74
+ }
75
+ catch (error) {
76
+ const why = error.message;
77
+ outcomes.push({ relay, outcome: why.includes("not a relay member") ? "not-member" : "failed", detail: why });
78
+ }
79
+ }
80
+ return outcomes;
81
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { buzzBridge } from "./bridge.js";
6
6
  import { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
7
7
  import * as profile from "./profile.js";
8
+ import { nameFromDid, syncProfileFromName } from "./agentid.js";
8
9
  /**
9
10
  * Setup and operation for an agent that lives in a workspace.
10
11
  *
@@ -44,8 +45,10 @@ function flag(name) {
44
45
  const at = process.argv.indexOf(`--${name}`);
45
46
  return at === -1 ? undefined : process.argv[at + 1];
46
47
  }
47
- /** The `.agent` identity this directory's agent was connected to, if any. */
48
- function identityDidFromFile() {
48
+ /** The `.agent` identity this agent was connected to, if any: `ARP_AGENT_DID`, else the directory's identity file. */
49
+ function identityDid() {
50
+ if (process.env.ARP_AGENT_DID?.startsWith("did:web:"))
51
+ return process.env.ARP_AGENT_DID;
49
52
  const file = process.env.ARP_IDENTITY_FILE ?? join(process.cwd(), ".eve", "arp-identity.json");
50
53
  if (!existsSync(file))
51
54
  return null;
@@ -57,18 +60,6 @@ function identityDidFromFile() {
57
60
  return null;
58
61
  }
59
62
  }
60
- /** The name's public profile document, served from the name's own address. */
61
- async function fetchAgentIdProfile(sld) {
62
- const suffix = process.env.AGENTID_MIRROR_SUFFIX ?? ".agent.arp.run";
63
- const url = `https://${sld}${suffix.startsWith(".") ? suffix : `.${suffix}`}/.well-known/agent-profile.json`;
64
- const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
65
- if (!res.ok) {
66
- console.error(`\n ${sld}.agent has no profile document yet (${res.status} from ${url}).\n`);
67
- process.exit(1);
68
- }
69
- const doc = (await res.json());
70
- return { name: doc.name ?? sld, description: doc.description ?? "", picture: doc.picture ?? null, nip05: doc.nip05 ?? null };
71
- }
72
63
  /**
73
64
  * Say who this agent is, in every community it belongs to.
74
65
  *
@@ -94,34 +85,36 @@ async function setProfile() {
94
85
  // the NIP-05 handle that nostr clients verify against the name itself. The
95
86
  // Buzz key never leaves this host; ARP Cloud only publishes the document.
96
87
  if (process.argv.includes("--from-agentid")) {
97
- const did = flag("from-agentid") && !flag("from-agentid").startsWith("--") ? flag("from-agentid") : identityDidFromFile();
88
+ const did = flag("from-agentid") && !flag("from-agentid").startsWith("--") ? flag("from-agentid") : identityDid();
98
89
  if (!did) {
99
90
  console.error("\n Which name? kybernesis-buzz profile --from-agentid <name>.agent\n (or run it from the agent's directory, where .eve/arp-identity.json says who it is)\n");
100
91
  process.exit(1);
101
92
  }
102
- const sld = did.replace(/^did:web:/, "").replace(/\.agent$/, "").toLowerCase();
103
- const doc = await fetchAgentIdProfile(sld);
104
- const wanted = {
105
- name: sld,
106
- display_name: doc.name,
107
- ...(doc.description ? { about: doc.description } : {}),
108
- ...(doc.picture ? { picture: doc.picture } : {}),
109
- ...(doc.nip05 ? { nip05: doc.nip05 } : {}),
110
- bot: true,
111
- };
112
- let published = 0;
113
- for (const url of urls) {
114
- try {
115
- await profile.write(url, key, wanted);
116
- console.log(` ✓ ${url}`);
117
- published += 1;
118
- }
119
- catch (error) {
120
- const why = error.message;
121
- console.log(why.includes("not a relay member") ? ` · ${url} — not a member yet, so nothing to be known as there` : ` ✕ ${url} — ${why}`);
122
- }
93
+ const name = nameFromDid(did);
94
+ if (!name) {
95
+ console.error(`\n ${did} is not a .agent name.\n`);
96
+ process.exit(1);
123
97
  }
124
- console.log(published > 0 ? `\n ${doc.name} (${sld}.agent) is ${key.npub.slice(0, 20)}… — profile taken from the name.\n` : "\n Nothing published — invite this key to a community first.\n");
98
+ let outcomes;
99
+ try {
100
+ outcomes = await syncProfileFromName({ name, relays: urls, key });
101
+ }
102
+ catch (error) {
103
+ console.error(`\n ${error.message}\n`);
104
+ process.exit(1);
105
+ }
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
+ }
116
+ const reached = outcomes.filter((o) => o.outcome === "updated" || o.outcome === "unchanged").length;
117
+ 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");
125
118
  return;
126
119
  }
127
120
  const name = flag("name");
@@ -206,8 +199,34 @@ function run() {
206
199
  ? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
207
200
  : {}),
208
201
  });
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.
205
+ const did = identityDid();
206
+ const name = did ? nameFromDid(did) : null;
207
+ const every = Number(process.env.BUZZ_PROFILE_SYNC_MS ?? 6 * 60 * 60 * 1000);
208
+ const timers = [];
209
+ if (name && every > 0) {
210
+ const log = (message) => console.log(new Date().toISOString().slice(11, 19), message);
211
+ const sync = () => syncProfileFromName({ name, relays: relayList(), key: loadKey(KEY_FILE) })
212
+ .then((outcomes) => {
213
+ for (const { relay, outcome, detail } of outcomes) {
214
+ if (outcome === "updated")
215
+ log(`profile: ${relay} updated from ${name}.agent`);
216
+ if (outcome === "failed")
217
+ log(`profile: ${relay} not updated — ${detail}`);
218
+ }
219
+ })
220
+ .catch((error) => log(`profile: ${error.message}`));
221
+ // A moment after start, so the bridge's own connections come first.
222
+ timers.push(setTimeout(sync, 5_000), setInterval(sync, every));
223
+ for (const timer of timers)
224
+ timer.unref();
225
+ }
209
226
  for (const signal of ["SIGINT", "SIGTERM"]) {
210
227
  process.on(signal, () => {
228
+ for (const timer of timers)
229
+ clearTimeout(timer);
211
230
  bridge.stop();
212
231
  setTimeout(() => process.exit(0), 400);
213
232
  });
@@ -365,6 +384,8 @@ else {
365
384
  BUZZ_SESSIONS_FILE channel-to-session continuity store
366
385
  BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
367
386
  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)
368
389
  KYBERNESIS_ISSUER the control plane
369
390
  KYBERNESIS_AGENT_CREDENTIAL this agent's credential
370
391
  `);
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.0",
3
+ "version": "0.10.2",
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",