@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 +12 -0
- package/dist/agentid.d.ts +54 -0
- package/dist/agentid.js +81 -0
- package/dist/cli.js +58 -37
- package/package.json +1 -1
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[]>;
|
package/dist/agentid.js
ADDED
|
@@ -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
|
|
48
|
-
function
|
|
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") :
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|