@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 +15 -8
- package/dist/cli.js +56 -19
- package/dist/face.d.ts +78 -0
- package/dist/face.js +148 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/turn.d.ts +10 -1
- package/dist/turn.js +29 -2
- package/package.json +1 -1
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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 ??
|
|
241
|
+
const every = Number(process.env.BUZZ_PROFILE_SYNC_MS ?? 15 * 60 * 1000);
|
|
208
242
|
const timers = [];
|
|
209
|
-
|
|
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 = () =>
|
|
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
|
|
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
|
|
388
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|