@kybernesis/buzz 0.10.0 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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",