@kybernesis/buzz 0.12.1 → 0.13.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 +28 -2
- package/dist/bridge.d.ts +6 -0
- package/dist/bridge.js +76 -4
- package/dist/cli.js +12 -8
- package/dist/face.d.ts +16 -4
- package/dist/face.js +71 -25
- package/dist/hitl.d.ts +3 -1
- package/dist/hitl.js +7 -3
- package/dist/media.d.ts +2 -0
- package/dist/media.js +17 -3
- package/dist/profile.js +3 -1
- package/dist/relay.d.ts +46 -1
- package/dist/relay.js +171 -2
- package/dist/turn.d.ts +3 -0
- package/dist/turn.js +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,6 +27,16 @@ of the account it names, so it is delivered to a direct conversation and never p
|
|
|
27
27
|
Administrators can also link identities directly in the control plane, which is the path for
|
|
28
28
|
bulk provisioning.
|
|
29
29
|
|
|
30
|
+
## When it answers
|
|
31
|
+
|
|
32
|
+
In a room, when someone @mentions it, or replies to a question it asked. In a one-to-one direct
|
|
33
|
+
message, every message: a DM is addressed to the other member, mention or not, as with Buzz's
|
|
34
|
+
own agents. A group DM still needs the mention. The bridge learns which DMs it is in from the
|
|
35
|
+
relay's signed channel rosters, every thirty seconds, and reads a newly found DM back from the
|
|
36
|
+
last check so nothing sent in between is missed. Messages written by another agent (they carry
|
|
37
|
+
an owner attestation) are answered only when they mention it, so two agents in a DM never
|
|
38
|
+
answer each other forever.
|
|
39
|
+
|
|
30
40
|
## What the host holds
|
|
31
41
|
|
|
32
42
|
Only the agent's own key and the agent's own credential — neither of which can act as any
|
|
@@ -61,8 +71,9 @@ NIP-05 handle). The bridge reads the owner's choice from the control plane
|
|
|
61
71
|
(`GET /api/agent/profile`, with the agent's credential) and the name's public profile document
|
|
62
72
|
(`https://<name>.agentid.dev/.well-known/agent-profile.json`) when it starts and every fifteen
|
|
63
73
|
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
|
|
65
|
-
and
|
|
74
|
+
the picture as a small image, so it is uploaded to each community's own media store once per
|
|
75
|
+
change, and each community's profile points at its own copy: the Buzz app loads a community's
|
|
76
|
+
media only through that community, so a picture held by another one shows as no picture. A new picture or name reaches every community without a
|
|
66
77
|
command on the host. The `.agent` name comes from `ARP_AGENT_DID`, or from
|
|
67
78
|
`.eve/arp-identity.json` in the agent's directory.
|
|
68
79
|
|
|
@@ -70,6 +81,20 @@ command on the host. The `.agent` name comes from `ARP_AGENT_DID`, or from
|
|
|
70
81
|
runs it through `@kybernesis/manage` (`buzz.profile`). `kybernesis-buzz profile --from-agentid`
|
|
71
82
|
still takes the profile from the name alone.
|
|
72
83
|
|
|
84
|
+
## Cards
|
|
85
|
+
|
|
86
|
+
When a tool hands back a card (a chart, a table, a checklist, an artifact), the bridge posts it
|
|
87
|
+
after the reply as its own threaded message: the card's text for people to read, and the card
|
|
88
|
+
itself in an HTML comment (`<!-- kyb:card v1 {...} -->`) that Buzz hides. The reply stays an
|
|
89
|
+
ordinary message Buzz draws itself. The Kybernesis cards plugin for the Buzz desktop app
|
|
90
|
+
(`plugin/`) draws those messages as the card; without it people read the text.
|
|
91
|
+
|
|
92
|
+
An artifact posted into a channel is shared with the channel: the bridge asks the agent for a
|
|
93
|
+
share link as the person who asked (`POST /eve/v1/kyb/artifacts/:id/share`, `@kybernesis/manage`
|
|
94
|
+
0.28), and the card carries it. The plugin draws the page inside the card on the macOS desktop
|
|
95
|
+
app and opens it beside the conversation; everywhere else the link is in the text. The person
|
|
96
|
+
can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
|
|
97
|
+
|
|
73
98
|
## Configuration
|
|
74
99
|
|
|
75
100
|
| Variable | Meaning |
|
|
@@ -80,6 +105,7 @@ still takes the profile from the name alone.
|
|
|
80
105
|
| `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
|
|
81
106
|
| `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. |
|
|
82
107
|
| `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. |
|
|
108
|
+
| `BUZZ_CARDS` | `off` stops posting cards after replies (default on) |
|
|
83
109
|
| `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) |
|
|
84
110
|
| `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
|
|
85
111
|
| `KYBERNESIS_ISSUER` | The control plane |
|
package/dist/bridge.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export type BuzzBridgeOptions = {
|
|
|
23
23
|
agentSilenceTimeoutMs?: number;
|
|
24
24
|
/** Ceiling on a turn once acknowledged; the agent is working while its stream is quiet. Default 60 minutes. */
|
|
25
25
|
agentWorkTimeoutMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Post the cards a turn's tools handed back, each as its own message after
|
|
28
|
+
* the reply: the card's text, with the card hidden in a comment for clients
|
|
29
|
+
* that draw it. Default true; false posts the reply alone, as before 0.13.
|
|
30
|
+
*/
|
|
31
|
+
cards?: boolean;
|
|
26
32
|
onLog?: (message: string) => void;
|
|
27
33
|
};
|
|
28
34
|
export declare function buzzBridge(options: BuzzBridgeOptions): {
|
package/dist/bridge.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cardMessage, cardText } from "@kybernesis/cards";
|
|
1
2
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
3
|
import { fetchMedia, parseMedia } from "./media.js";
|
|
3
4
|
import { speakerCredentials } from "./credentials.js";
|
|
@@ -34,6 +35,66 @@ export function buzzBridge(options) {
|
|
|
34
35
|
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
35
36
|
const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
|
|
36
37
|
const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
|
|
38
|
+
/**
|
|
39
|
+
* An artifact card, with a link everyone in the channel can open.
|
|
40
|
+
*
|
|
41
|
+
* Posting an artifact into a channel shares it with the channel: that is
|
|
42
|
+
* what the person asked for by asking here. The link is made by the agent's
|
|
43
|
+
* own share route, as the person who asked (the artifact's owner), so it is
|
|
44
|
+
* their act and they can withdraw it from their artifacts. If the link
|
|
45
|
+
* cannot be made, the card goes out as it was and says where to open it.
|
|
46
|
+
*/
|
|
47
|
+
const sharedInChannel = async (card, speaker) => {
|
|
48
|
+
if (card.card !== "artifact" || !card.data || typeof card.data !== "object")
|
|
49
|
+
return card;
|
|
50
|
+
const data = card.data;
|
|
51
|
+
if (typeof data.artifactId !== "string" || typeof data.url === "string")
|
|
52
|
+
return card;
|
|
53
|
+
try {
|
|
54
|
+
const credentials = credentialsFor(speaker);
|
|
55
|
+
const response = await fetch(`${options.agentUrl.replace(/\/$/, "")}/eve/v1/kyb/artifacts/${encodeURIComponent(data.artifactId)}/share`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
...(await credentials.headers()),
|
|
59
|
+
authorization: `Bearer ${await credentials.bearer()}`,
|
|
60
|
+
"content-type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(typeof data.version === "number" ? { version: data.version } : {}),
|
|
63
|
+
signal: AbortSignal.timeout(15_000),
|
|
64
|
+
});
|
|
65
|
+
const body = (await response.json().catch(() => null));
|
|
66
|
+
if (!response.ok || !body?.ok || typeof body.url !== "string") {
|
|
67
|
+
log(`could not share artifact ${data.artifactId.slice(0, 8)} (${response.status} ${String(body?.error ?? "")})`);
|
|
68
|
+
return card;
|
|
69
|
+
}
|
|
70
|
+
// The words people without the plugin read now carry the link.
|
|
71
|
+
const shared = { ...card, data: { ...data, url: body.url }, text: undefined };
|
|
72
|
+
return { ...shared, text: cardText(shared) };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
log(`could not share artifact ${data.artifactId.slice(0, 8)} (${error.message})`);
|
|
76
|
+
return card;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* A turn's cards, one message each, threaded where the reply went.
|
|
81
|
+
*
|
|
82
|
+
* The reply stays an ordinary message the client draws itself, links and
|
|
83
|
+
* mentions included; a card-aware client swaps only these follow-ups for the
|
|
84
|
+
* card. They do not mention the asker again: the reply already did.
|
|
85
|
+
*/
|
|
86
|
+
const postCards = async (relay, channel, cards, speaker, replyTo) => {
|
|
87
|
+
if (options.cards === false || cards.length === 0)
|
|
88
|
+
return;
|
|
89
|
+
let posted = 0;
|
|
90
|
+
for (const card of await Promise.all(cards.map((c) => sharedInChannel(c, speaker)))) {
|
|
91
|
+
const content = cardMessage(card);
|
|
92
|
+
if (content && relay.reply(channel, content, replyTo, { mention: false }))
|
|
93
|
+
posted += 1;
|
|
94
|
+
}
|
|
95
|
+
if (posted)
|
|
96
|
+
log(`posted ${posted} card(s) in ${channel.slice(0, 8)}`);
|
|
97
|
+
};
|
|
37
98
|
/**
|
|
38
99
|
* One conversation per channel — the SESSION ID, not a handle to it.
|
|
39
100
|
*
|
|
@@ -249,12 +310,14 @@ export function buzzBridge(options) {
|
|
|
249
310
|
}
|
|
250
311
|
log(`posted another input request in ${channel.slice(0, 8)}`);
|
|
251
312
|
}),
|
|
252
|
-
onMessage: (message) => serialize(community, channel, async () => {
|
|
313
|
+
onMessage: (message, cards) => serialize(community, channel, async () => {
|
|
253
314
|
if (followers.get(conversation) !== controller)
|
|
254
315
|
return;
|
|
255
|
-
if (!relay.reply(channel, message))
|
|
316
|
+
if (message.trim() && !relay.reply(channel, message))
|
|
256
317
|
throw new Error("Buzz relay is not connected");
|
|
257
|
-
|
|
318
|
+
if (message.trim())
|
|
319
|
+
log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
|
|
320
|
+
await postCards(relay, channel, cards, stored.speakerPublicKey);
|
|
258
321
|
}),
|
|
259
322
|
onLog: log,
|
|
260
323
|
}, controller.signal).catch((error) => {
|
|
@@ -318,7 +381,11 @@ export function buzzBridge(options) {
|
|
|
318
381
|
const repliedTo = event.tags.find((t) => t[0] === "e")?.[1];
|
|
319
382
|
const open = channel ? sessions.get(from, channel) : undefined;
|
|
320
383
|
const answersPrompt = Boolean(repliedTo && open?.pendingInputRequests?.length && open.promptEventIds?.includes(repliedTo));
|
|
321
|
-
|
|
384
|
+
// In a one-to-one DM every message is to the agent, mention or not, as with Buzz's own
|
|
385
|
+
// agents. Not one written by another agent (it carries its owner's `auth` attestation):
|
|
386
|
+
// two agents in a DM would otherwise answer each other forever.
|
|
387
|
+
const direct = Boolean(channel && relay.isDirect(channel) && !event.tags.some((t) => t[0] === "auth"));
|
|
388
|
+
if (!channel || !(addressed || answersPrompt || direct))
|
|
322
389
|
return;
|
|
323
390
|
const text = String(event.content ?? "").trim();
|
|
324
391
|
const attachments = parseMedia(event);
|
|
@@ -418,6 +485,10 @@ export function buzzBridge(options) {
|
|
|
418
485
|
startFollower(from, channel);
|
|
419
486
|
return;
|
|
420
487
|
}
|
|
488
|
+
if (!result.message && result.cards.length) {
|
|
489
|
+
await postCards(relay, channel, result.cards, event.pubkey, event);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
421
492
|
if (!result.message) {
|
|
422
493
|
// Say what the stream said: a failed tool by name and error, a
|
|
423
494
|
// failed model call by code, or, only when there is truly nothing,
|
|
@@ -428,6 +499,7 @@ export function buzzBridge(options) {
|
|
|
428
499
|
}
|
|
429
500
|
relay.reply(channel, result.message, event);
|
|
430
501
|
log(`replied (${result.message.length} chars)`);
|
|
502
|
+
await postCards(relay, channel, result.cards, event.pubkey, event);
|
|
431
503
|
});
|
|
432
504
|
}
|
|
433
505
|
catch (error) {
|
package/dist/cli.js
CHANGED
|
@@ -157,9 +157,11 @@ async function setProfile() {
|
|
|
157
157
|
console.error(" or: kybernesis-buzz profile --copy-from <wss://…>");
|
|
158
158
|
process.exit(1);
|
|
159
159
|
}
|
|
160
|
-
// A local file is uploaded to
|
|
161
|
-
// as given.
|
|
160
|
+
// A local file is uploaded to each community and becomes that community's URL; a URL is
|
|
161
|
+
// used as given. Each community needs its own copy: the Buzz app loads a community's media
|
|
162
|
+
// only through that community, so another community's address shows no picture at all.
|
|
162
163
|
let picture = flag("picture");
|
|
164
|
+
const pictures = new Map();
|
|
163
165
|
if (picture && !/^https?:\/\//.test(picture)) {
|
|
164
166
|
const path = picture.startsWith("~/")
|
|
165
167
|
? join(homedir(), picture.slice(2))
|
|
@@ -169,22 +171,20 @@ async function setProfile() {
|
|
|
169
171
|
process.exit(1);
|
|
170
172
|
}
|
|
171
173
|
const bytes = readFileSync(path);
|
|
172
|
-
let uploaded;
|
|
173
174
|
for (const url of urls) {
|
|
174
175
|
try {
|
|
175
|
-
|
|
176
|
+
pictures.set(url, await profile.uploadImage(url, key, bytes, profile.mediaTypeOf(path)));
|
|
176
177
|
console.log(` uploaded ${path.split("/").pop()} to ${url}`);
|
|
177
|
-
break;
|
|
178
178
|
}
|
|
179
179
|
catch (error) {
|
|
180
180
|
console.log(` · ${url} — ${error.message}`);
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
if (
|
|
183
|
+
if (pictures.size === 0) {
|
|
184
184
|
console.error("\n Could not upload the image to any community.\n");
|
|
185
185
|
process.exit(1);
|
|
186
186
|
}
|
|
187
|
-
picture =
|
|
187
|
+
picture = undefined;
|
|
188
188
|
}
|
|
189
189
|
const wanted = {
|
|
190
190
|
name: name.toLowerCase().replace(/\s+/g, "-"),
|
|
@@ -200,7 +200,9 @@ async function setProfile() {
|
|
|
200
200
|
let published = 0;
|
|
201
201
|
for (const url of urls) {
|
|
202
202
|
try {
|
|
203
|
-
|
|
203
|
+
// A community that refused the upload still gets a picture, if not one it can show.
|
|
204
|
+
const own = pictures.get(url) ?? pictures.values().next().value;
|
|
205
|
+
await profile.write(url, key, own ? { ...wanted, picture: own } : wanted);
|
|
204
206
|
console.log(` ✓ ${url}`);
|
|
205
207
|
published += 1;
|
|
206
208
|
}
|
|
@@ -228,6 +230,7 @@ function run() {
|
|
|
228
230
|
...(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS
|
|
229
231
|
? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
|
|
230
232
|
: {}),
|
|
233
|
+
...(process.env.BUZZ_CARDS === "off" ? { cards: false } : {}),
|
|
231
234
|
...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
|
|
232
235
|
? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
|
|
233
236
|
: {}),
|
|
@@ -420,6 +423,7 @@ else {
|
|
|
420
423
|
BUZZ_SESSIONS_FILE channel-to-session continuity store
|
|
421
424
|
BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
|
|
422
425
|
BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
|
|
426
|
+
BUZZ_CARDS=off post replies without the cards tools handed back
|
|
423
427
|
BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the name and
|
|
424
428
|
picture its owner set (default 900000ms, fifteen
|
|
425
429
|
minutes; 0 turns it off)
|
package/dist/face.d.ts
CHANGED
|
@@ -57,15 +57,22 @@ export declare function resolveFace(options: {
|
|
|
57
57
|
}): Promise<Face>;
|
|
58
58
|
/** The profile fields the face decides, given where its picture now lives. */
|
|
59
59
|
export declare function profileFromFace(face: Face, pictureUrl: string | null): Profile;
|
|
60
|
+
/** The hash a community media URL addresses (`…/media/<sha256>[.ext]`), or null for any other URL. */
|
|
61
|
+
export declare function relayMediaHash(url: string): string | null;
|
|
60
62
|
/**
|
|
61
63
|
* Bring this agent's profile in every community in line with its face.
|
|
62
64
|
*
|
|
63
65
|
* @remarks
|
|
64
66
|
* 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.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* timer costs one read per community when nothing has changed. Fields the face does not decide
|
|
68
|
+
* are kept. A community that refuses or cannot be reached is reported, not fatal.
|
|
69
|
+
*
|
|
70
|
+
* Every community gets its OWN copy of the picture. A community's media store refuses
|
|
71
|
+
* anonymous reads, so the Buzz app loads a picture through the session of the community it is
|
|
72
|
+
* showing, and only when the address is that community's. An address on another community is
|
|
73
|
+
* fetched anonymously and refused, and the agent shows no face there: that is how an agent in
|
|
74
|
+
* two communities lost its picture in one of them when the app changed. So the picture is
|
|
75
|
+
* uploaded to each community that does not already hold it, and each profile points at its own.
|
|
69
76
|
*/
|
|
70
77
|
export declare function syncProfile(options: {
|
|
71
78
|
face: Face;
|
|
@@ -74,5 +81,10 @@ export declare function syncProfile(options: {
|
|
|
74
81
|
read?: typeof profile.read;
|
|
75
82
|
write?: typeof profile.write;
|
|
76
83
|
upload?: typeof profile.uploadImage;
|
|
84
|
+
/** Reads a picture held in one community's media store, to copy it to another. */
|
|
85
|
+
fetchBlob?: (url: string, sha256: string) => Promise<{
|
|
86
|
+
bytes: Uint8Array;
|
|
87
|
+
mediaType: string;
|
|
88
|
+
}>;
|
|
77
89
|
}): Promise<SyncOutcome[]>;
|
|
78
90
|
export {};
|
package/dist/face.js
CHANGED
|
@@ -77,21 +77,48 @@ export function profileFromFace(face, pictureUrl) {
|
|
|
77
77
|
bot: true,
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/** A relay's own HTTP origin, where its media store answers. */
|
|
81
|
+
function originOf(relay) {
|
|
82
|
+
return relay.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/+$/, "");
|
|
83
|
+
}
|
|
84
|
+
/** The hash a community media URL addresses (`…/media/<sha256>[.ext]`), or null for any other URL. */
|
|
85
|
+
export function relayMediaHash(url) {
|
|
86
|
+
try {
|
|
87
|
+
return /^\/media\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/.exec(new URL(url).pathname)?.[1] ?? null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Whether a URL is this community's own copy of the blob with this hash. */
|
|
94
|
+
function heldBy(relay, url, sha256) {
|
|
95
|
+
return Boolean(url?.startsWith(`${originOf(relay)}/media/`) && relayMediaHash(url) === sha256);
|
|
96
|
+
}
|
|
80
97
|
/**
|
|
81
98
|
* Bring this agent's profile in every community in line with its face.
|
|
82
99
|
*
|
|
83
100
|
* @remarks
|
|
84
101
|
* 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.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
102
|
+
* timer costs one read per community when nothing has changed. Fields the face does not decide
|
|
103
|
+
* are kept. A community that refuses or cannot be reached is reported, not fatal.
|
|
104
|
+
*
|
|
105
|
+
* Every community gets its OWN copy of the picture. A community's media store refuses
|
|
106
|
+
* anonymous reads, so the Buzz app loads a picture through the session of the community it is
|
|
107
|
+
* showing, and only when the address is that community's. An address on another community is
|
|
108
|
+
* fetched anonymously and refused, and the agent shows no face there: that is how an agent in
|
|
109
|
+
* two communities lost its picture in one of them when the app changed. So the picture is
|
|
110
|
+
* uploaded to each community that does not already hold it, and each profile points at its own.
|
|
89
111
|
*/
|
|
90
112
|
export async function syncProfile(options) {
|
|
91
113
|
const read = options.read ?? profile.read;
|
|
92
114
|
const write = options.write ?? profile.write;
|
|
93
115
|
const upload = options.upload ?? profile.uploadImage;
|
|
94
116
|
const { face, key } = options;
|
|
117
|
+
const fetchBlob = options.fetchBlob ??
|
|
118
|
+
(async (url, sha256) => {
|
|
119
|
+
const { fetchMedia } = await import("./media.js");
|
|
120
|
+
return fetchMedia(key, { url, sha256 });
|
|
121
|
+
});
|
|
95
122
|
const current = new Map();
|
|
96
123
|
const outcomes = new Map();
|
|
97
124
|
for (const relay of options.relays) {
|
|
@@ -103,33 +130,52 @@ export async function syncProfile(options) {
|
|
|
103
130
|
outcomes.set(relay, { relay, outcome: why.includes("not a relay member") ? "not-member" : "failed", detail: why });
|
|
104
131
|
}
|
|
105
132
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
133
|
+
// What the picture is. An ordinary https address is used as it is, everywhere. A data URL,
|
|
134
|
+
// or a picture already in some community's media store, is copied into each community.
|
|
135
|
+
let external = null;
|
|
136
|
+
let sha256 = null;
|
|
137
|
+
let source = null;
|
|
138
|
+
let bytes = null;
|
|
108
139
|
let pictureProblem = null;
|
|
109
|
-
if (face.picture
|
|
110
|
-
pictureUrl = face.picture;
|
|
111
|
-
}
|
|
112
|
-
else if (face.picture) {
|
|
140
|
+
if (face.picture?.startsWith("data:")) {
|
|
113
141
|
const picture = await pictureBytes(face.picture);
|
|
114
|
-
if (
|
|
115
|
-
|
|
142
|
+
if (picture) {
|
|
143
|
+
sha256 = picture.sha256;
|
|
144
|
+
bytes = picture;
|
|
116
145
|
}
|
|
117
146
|
else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
pictureProblem = "the picture is not an image this can send";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (face.picture && /^https:\/\//.test(face.picture)) {
|
|
151
|
+
sha256 = relayMediaHash(face.picture);
|
|
152
|
+
if (sha256)
|
|
153
|
+
source = face.picture;
|
|
154
|
+
else
|
|
155
|
+
external = face.picture;
|
|
156
|
+
}
|
|
157
|
+
/** Where this community's copy of the picture lives; null leaves its picture as it is. */
|
|
158
|
+
async function pictureFor(relay) {
|
|
159
|
+
if (external)
|
|
160
|
+
return { url: external, problem: null };
|
|
161
|
+
if (!sha256)
|
|
162
|
+
return { url: null, problem: pictureProblem };
|
|
163
|
+
const existing = current.get(relay)?.picture;
|
|
164
|
+
if (heldBy(relay, existing, sha256))
|
|
165
|
+
return { url: existing, problem: null };
|
|
166
|
+
if (source && heldBy(relay, source, sha256))
|
|
167
|
+
return { url: source, problem: null };
|
|
168
|
+
try {
|
|
169
|
+
bytes ??= await fetchBlob(source, sha256);
|
|
170
|
+
return { url: await upload(relay, key, bytes.bytes, bytes.mediaType), problem: null };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return { url: null, problem: error.message };
|
|
129
174
|
}
|
|
130
175
|
}
|
|
131
|
-
const wanted = profileFromFace(face, pictureUrl);
|
|
132
176
|
for (const [relay, existing] of current) {
|
|
177
|
+
const picture = await pictureFor(relay);
|
|
178
|
+
const wanted = profileFromFace(face, picture.url);
|
|
133
179
|
const matches = existing && Object.keys(wanted).every((field) => existing[field] === wanted[field]);
|
|
134
180
|
if (matches) {
|
|
135
181
|
outcomes.set(relay, { relay, outcome: "unchanged" });
|
|
@@ -137,7 +183,7 @@ export async function syncProfile(options) {
|
|
|
137
183
|
}
|
|
138
184
|
try {
|
|
139
185
|
await write(relay, key, { ...(existing ?? {}), ...wanted });
|
|
140
|
-
outcomes.set(relay, { relay, outcome: "updated", ...(
|
|
186
|
+
outcomes.set(relay, { relay, outcome: "updated", ...(picture.problem ? { detail: `picture not sent: ${picture.problem}` } : {}) });
|
|
141
187
|
}
|
|
142
188
|
catch (error) {
|
|
143
189
|
const why = error.message;
|
package/dist/hitl.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
|
|
2
|
+
import { type Card } from "@kybernesis/cards";
|
|
2
3
|
import type { StoredSession } from "./sessions.js";
|
|
3
4
|
/**
|
|
4
5
|
* Render pending Eve input in a text-only Buzz channel.
|
|
@@ -39,7 +40,8 @@ export interface PendingFollowerCallbacks {
|
|
|
39
40
|
/** Persist the latest cursor and supervision state before publishing outward. */
|
|
40
41
|
onState(state: Omit<StoredSession, "updated">): void | Promise<void>;
|
|
41
42
|
onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
|
|
42
|
-
|
|
43
|
+
/** The resumed turn's words, and the cards its tools handed back. */
|
|
44
|
+
onMessage(message: string, cards: readonly Card[]): void | Promise<void>;
|
|
43
45
|
onLog?: (message: string) => void;
|
|
44
46
|
}
|
|
45
47
|
type MutableStoredSession = Omit<StoredSession, "updated">;
|
package/dist/hitl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveTextToResponses, } from "eve/client";
|
|
2
|
-
import { promptText } from "@kybernesis/cards";
|
|
2
|
+
import { cardsOf, newStepState, promptText, readStep } from "@kybernesis/cards";
|
|
3
3
|
/**
|
|
4
4
|
* Render pending Eve input in a text-only Buzz channel.
|
|
5
5
|
*
|
|
@@ -105,6 +105,7 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
105
105
|
let state = { ...initial };
|
|
106
106
|
let streamIndex = initial.streamIndex;
|
|
107
107
|
let completedMessage = "";
|
|
108
|
+
let steps = newStepState();
|
|
108
109
|
const persist = async () => {
|
|
109
110
|
state = { ...state, streamIndex };
|
|
110
111
|
await callbacks.onState(state);
|
|
@@ -113,8 +114,9 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
113
114
|
// Do not persist past message.completed/turn.completed until publication
|
|
114
115
|
// succeeds. If this callback throws or the process dies, recovery replays
|
|
115
116
|
// the unpublished output from the last safe cursor.
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
const cards = cardsOf(steps.steps.values());
|
|
118
|
+
if (completedMessage.trim() || cards.length)
|
|
119
|
+
await callbacks.onMessage(completedMessage, cards);
|
|
118
120
|
state = withoutSupervision(state);
|
|
119
121
|
await persist();
|
|
120
122
|
};
|
|
@@ -130,9 +132,11 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
130
132
|
// closes; count yielded durable events so crash recovery can persist while
|
|
131
133
|
// the follower is still open.
|
|
132
134
|
streamIndex += 1;
|
|
135
|
+
readStep(steps, streamEvent.type, streamEvent.data);
|
|
133
136
|
switch (streamEvent.type) {
|
|
134
137
|
case "input.requested":
|
|
135
138
|
completedMessage = "";
|
|
139
|
+
steps = newStepState();
|
|
136
140
|
state = withPending(state, streamEvent.data.requests);
|
|
137
141
|
// Keep the durable cursor before the prompt until Buzz accepts it for
|
|
138
142
|
// relay delivery; a reconnect can then replay and render the request.
|
package/dist/media.d.ts
CHANGED
|
@@ -44,5 +44,7 @@ export declare function parseMedia(event: NostrEvent): MediaRef[];
|
|
|
44
44
|
* us one, so the authorisation is scoped to reading that specific blob.
|
|
45
45
|
*/
|
|
46
46
|
export declare function fetchMedia(key: AgentKey, ref: MediaRef, fetchImpl?: typeof fetch): Promise<FetchedMedia>;
|
|
47
|
+
/** How long a Blossom proof may live under NIP-FI's strict rules. */
|
|
48
|
+
export declare const BLOSSOM_PROOF_SECONDS = 60;
|
|
47
49
|
/** Whether the model can be shown this directly, or only told about it. */
|
|
48
50
|
export declare function isImage(mediaType: string): boolean;
|
package/dist/media.js
CHANGED
|
@@ -47,14 +47,17 @@ export function parseMedia(event) {
|
|
|
47
47
|
* us one, so the authorisation is scoped to reading that specific blob.
|
|
48
48
|
*/
|
|
49
49
|
export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
50
|
-
|
|
50
|
+
// NIP-FI's strict Blossom rules, which a community may switch on: a proof lives at most a
|
|
51
|
+
// minute and names the server it is for. Older servers accept the same proof.
|
|
52
|
+
const issued = Math.floor(Date.now() / 1000);
|
|
51
53
|
const auth = finalizeEvent({
|
|
52
54
|
kind: 24242,
|
|
53
|
-
created_at:
|
|
55
|
+
created_at: issued,
|
|
54
56
|
tags: [
|
|
55
57
|
["t", "get"],
|
|
56
58
|
...(ref.sha256 ? [["x", ref.sha256]] : []),
|
|
57
|
-
["expiration", String(
|
|
59
|
+
["expiration", String(issued + BLOSSOM_PROOF_SECONDS)],
|
|
60
|
+
...serverTag(ref.url),
|
|
58
61
|
],
|
|
59
62
|
content: "Fetch buzz-media",
|
|
60
63
|
}, key.secretKey);
|
|
@@ -79,6 +82,17 @@ export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
|
79
82
|
}
|
|
80
83
|
return { url: ref.url, mediaType, bytes };
|
|
81
84
|
}
|
|
85
|
+
/** How long a Blossom proof may live under NIP-FI's strict rules. */
|
|
86
|
+
export const BLOSSOM_PROOF_SECONDS = 60;
|
|
87
|
+
/** The `server` tag a strict Blossom server requires: the host the proof is for. */
|
|
88
|
+
function serverTag(url) {
|
|
89
|
+
try {
|
|
90
|
+
return [["server", new URL(url).host]];
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
82
96
|
/** Whether the model can be shown this directly, or only told about it. */
|
|
83
97
|
export function isImage(mediaType) {
|
|
84
98
|
return mediaType.startsWith("image/");
|
package/dist/profile.js
CHANGED
|
@@ -153,7 +153,9 @@ export async function uploadImage(relay, key, bytes, mediaType) {
|
|
|
153
153
|
// Blossom server checks that for itself.
|
|
154
154
|
const body = stripMetadata(bytes, mediaType);
|
|
155
155
|
const sha256 = createHash("sha256").update(body).digest("hex");
|
|
156
|
-
|
|
156
|
+
// At most a minute: NIP-FI's strict Blossom rules, which a community may switch on, refuse
|
|
157
|
+
// a longer-lived proof. The `server` tag below is the other thing they require.
|
|
158
|
+
const expires = now() + 60;
|
|
157
159
|
const auth = finalizeEvent({
|
|
158
160
|
kind: 24242,
|
|
159
161
|
created_at: now(),
|
package/dist/relay.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export declare const KIND_PRESENCE = 20001;
|
|
|
25
25
|
export declare const KIND_TYPING = 20002;
|
|
26
26
|
/** Open (or find) a direct conversation with a set of people. */
|
|
27
27
|
export declare const KIND_DM_OPEN = 41010;
|
|
28
|
+
/** Relay-signed channel metadata (NIP-29); its first `t` tag is the channel type. */
|
|
29
|
+
export declare const KIND_CHANNEL_METADATA = 39000;
|
|
30
|
+
/** Relay-signed channel roster (NIP-29): one `p` tag per member. */
|
|
31
|
+
export declare const KIND_CHANNEL_MEMBERS = 39002;
|
|
28
32
|
/**
|
|
29
33
|
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
30
34
|
* anything slower than that has to keep saying so.
|
|
@@ -33,6 +37,8 @@ export declare const TYPING_INTERVAL_MS = 3000;
|
|
|
33
37
|
export declare const PRESENCE_INTERVAL_MS = 60000;
|
|
34
38
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
35
39
|
export declare const MAX_SEEN_EVENT_IDS = 10000;
|
|
40
|
+
/** How often to re-read which direct conversations this agent is in. */
|
|
41
|
+
export declare const DIRECT_REFRESH_MS = 30000;
|
|
36
42
|
export type NostrEvent = {
|
|
37
43
|
id: string;
|
|
38
44
|
pubkey: string;
|
|
@@ -66,6 +72,26 @@ export declare class BuzzRelay {
|
|
|
66
72
|
private cursor;
|
|
67
73
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
68
74
|
private readonly replyWatch;
|
|
75
|
+
/**
|
|
76
|
+
* One-to-one direct conversations this agent is in, by channel id.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* A direct message used to reach the bridge because the client tagged every other
|
|
80
|
+
* participant, so the `#p` poll found it like a mention. The current Buzz app tags only
|
|
81
|
+
* explicit mentions, so a person typing to the agent in a DM was never seen: the agent
|
|
82
|
+
* answered in a DM only when named, which is not how anyone talks to one person. Buzz's own
|
|
83
|
+
* agents decide by the channel instead (a DM of exactly two members is addressed to the
|
|
84
|
+
* other one), and so does this: the relay's signed rosters say which DMs the agent is in,
|
|
85
|
+
* and those channels are polled whole.
|
|
86
|
+
*/
|
|
87
|
+
private direct;
|
|
88
|
+
/** The relay's own signing key (NIP-11 `self`), which every roster must carry. */
|
|
89
|
+
private relaySelf;
|
|
90
|
+
private discoveryTimer;
|
|
91
|
+
/** One discovery at a time: rosters first, then the metadata for those channels. */
|
|
92
|
+
private discovery;
|
|
93
|
+
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
94
|
+
private discoveredAt;
|
|
69
95
|
private readonly url;
|
|
70
96
|
private readonly rest;
|
|
71
97
|
private readonly key;
|
|
@@ -78,6 +104,8 @@ export declare class BuzzRelay {
|
|
|
78
104
|
watchReplies(ids: Iterable<string>): void;
|
|
79
105
|
unwatchReplies(ids: Iterable<string>): void;
|
|
80
106
|
connect(): void;
|
|
107
|
+
/** Whether this channel is a one-to-one DM with this agent: every message in it is addressed. */
|
|
108
|
+
isDirect(channel: string): boolean;
|
|
81
109
|
close(): void;
|
|
82
110
|
private reconnect;
|
|
83
111
|
private receive;
|
|
@@ -92,6 +120,20 @@ export declare class BuzzRelay {
|
|
|
92
120
|
* ignores every message addressed to it.
|
|
93
121
|
*/
|
|
94
122
|
poll(): void;
|
|
123
|
+
/**
|
|
124
|
+
* Find the one-to-one DMs this agent is in: its rosters, then which of those channels are DMs.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* Rosters and metadata are relay-signed; anything else claiming to be one is ignored, so a
|
|
128
|
+
* member cannot publish a roster that makes the agent answer every message in a room.
|
|
129
|
+
*/
|
|
130
|
+
private discoverDirect;
|
|
131
|
+
/** NIP-11 `self`: null when the relay names none, undefined when it could not be asked. */
|
|
132
|
+
private relayKey;
|
|
133
|
+
private discovered;
|
|
134
|
+
private discoveryDone;
|
|
135
|
+
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
136
|
+
private adopt;
|
|
95
137
|
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
96
138
|
publish(event: {
|
|
97
139
|
kind: number;
|
|
@@ -102,7 +144,10 @@ export declare class BuzzRelay {
|
|
|
102
144
|
private keepPresent;
|
|
103
145
|
/** Say "typing" in a channel until the returned function is called. */
|
|
104
146
|
typingIn(channel: string): () => void;
|
|
105
|
-
|
|
147
|
+
/** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
|
|
148
|
+
reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
|
|
149
|
+
mention?: boolean;
|
|
150
|
+
}): string | null;
|
|
106
151
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
107
152
|
private httpAuth;
|
|
108
153
|
private submit;
|
package/dist/relay.js
CHANGED
|
@@ -26,6 +26,10 @@ export const KIND_PRESENCE = 20001;
|
|
|
26
26
|
export const KIND_TYPING = 20002;
|
|
27
27
|
/** Open (or find) a direct conversation with a set of people. */
|
|
28
28
|
export const KIND_DM_OPEN = 41010;
|
|
29
|
+
/** Relay-signed channel metadata (NIP-29); its first `t` tag is the channel type. */
|
|
30
|
+
export const KIND_CHANNEL_METADATA = 39000;
|
|
31
|
+
/** Relay-signed channel roster (NIP-29): one `p` tag per member. */
|
|
32
|
+
export const KIND_CHANNEL_MEMBERS = 39002;
|
|
29
33
|
/**
|
|
30
34
|
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
31
35
|
* anything slower than that has to keep saying so.
|
|
@@ -34,6 +38,11 @@ export const TYPING_INTERVAL_MS = 3_000;
|
|
|
34
38
|
export const PRESENCE_INTERVAL_MS = 60_000;
|
|
35
39
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
36
40
|
export const MAX_SEEN_EVENT_IDS = 10_000;
|
|
41
|
+
/** How often to re-read which direct conversations this agent is in. */
|
|
42
|
+
export const DIRECT_REFRESH_MS = 30_000;
|
|
43
|
+
/** Ids per `#d`/`#h` filter, and filters per request: the relay caps a request at ten. */
|
|
44
|
+
const IDS_PER_FILTER = 200;
|
|
45
|
+
const MAX_FILTERS = 8;
|
|
37
46
|
const now = () => Math.floor(Date.now() / 1000);
|
|
38
47
|
/**
|
|
39
48
|
* The payload out of a command acknowledgement.
|
|
@@ -62,6 +71,26 @@ export class BuzzRelay {
|
|
|
62
71
|
cursor = now();
|
|
63
72
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
64
73
|
replyWatch = new Set();
|
|
74
|
+
/**
|
|
75
|
+
* One-to-one direct conversations this agent is in, by channel id.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* A direct message used to reach the bridge because the client tagged every other
|
|
79
|
+
* participant, so the `#p` poll found it like a mention. The current Buzz app tags only
|
|
80
|
+
* explicit mentions, so a person typing to the agent in a DM was never seen: the agent
|
|
81
|
+
* answered in a DM only when named, which is not how anyone talks to one person. Buzz's own
|
|
82
|
+
* agents decide by the channel instead (a DM of exactly two members is addressed to the
|
|
83
|
+
* other one), and so does this: the relay's signed rosters say which DMs the agent is in,
|
|
84
|
+
* and those channels are polled whole.
|
|
85
|
+
*/
|
|
86
|
+
direct = new Set();
|
|
87
|
+
/** The relay's own signing key (NIP-11 `self`), which every roster must carry. */
|
|
88
|
+
relaySelf = undefined;
|
|
89
|
+
discoveryTimer = null;
|
|
90
|
+
/** One discovery at a time: rosters first, then the metadata for those channels. */
|
|
91
|
+
discovery = null;
|
|
92
|
+
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
93
|
+
discoveredAt = null;
|
|
65
94
|
url;
|
|
66
95
|
rest;
|
|
67
96
|
key;
|
|
@@ -106,6 +135,10 @@ export class BuzzRelay {
|
|
|
106
135
|
}
|
|
107
136
|
});
|
|
108
137
|
}
|
|
138
|
+
/** Whether this channel is a one-to-one DM with this agent: every message in it is addressed. */
|
|
139
|
+
isDirect(channel) {
|
|
140
|
+
return this.direct.has(channel);
|
|
141
|
+
}
|
|
109
142
|
close() {
|
|
110
143
|
this.closed = true;
|
|
111
144
|
if (this.presenceTimer)
|
|
@@ -114,9 +147,12 @@ export class BuzzRelay {
|
|
|
114
147
|
clearTimeout(this.pollTimer);
|
|
115
148
|
if (this.reconnectTimer)
|
|
116
149
|
clearTimeout(this.reconnectTimer);
|
|
150
|
+
if (this.discoveryTimer)
|
|
151
|
+
clearTimeout(this.discoveryTimer);
|
|
117
152
|
this.presenceTimer = null;
|
|
118
153
|
this.pollTimer = null;
|
|
119
154
|
this.reconnectTimer = null;
|
|
155
|
+
this.discoveryTimer = null;
|
|
120
156
|
try {
|
|
121
157
|
this.socket?.close();
|
|
122
158
|
}
|
|
@@ -131,8 +167,12 @@ export class BuzzRelay {
|
|
|
131
167
|
clearInterval(this.presenceTimer);
|
|
132
168
|
if (this.pollTimer)
|
|
133
169
|
clearTimeout(this.pollTimer);
|
|
170
|
+
if (this.discoveryTimer)
|
|
171
|
+
clearTimeout(this.discoveryTimer);
|
|
134
172
|
this.presenceTimer = null;
|
|
135
173
|
this.pollTimer = null;
|
|
174
|
+
this.discoveryTimer = null;
|
|
175
|
+
this.discovery = null;
|
|
136
176
|
if (this.reconnectTimer)
|
|
137
177
|
return;
|
|
138
178
|
// Backs off rather than hammering. A membership refusal is the common case
|
|
@@ -190,6 +230,7 @@ export class BuzzRelay {
|
|
|
190
230
|
this.refusal = null;
|
|
191
231
|
this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
|
|
192
232
|
this.keepPresent();
|
|
233
|
+
this.discoverDirect();
|
|
193
234
|
this.poll();
|
|
194
235
|
return;
|
|
195
236
|
}
|
|
@@ -197,10 +238,20 @@ export class BuzzRelay {
|
|
|
197
238
|
// One poll's worth delivered. Closing it matters: the relay caps how many subscriptions one
|
|
198
239
|
// connection may hold, and a bridge that never closes them stops receiving within minutes.
|
|
199
240
|
this.send(["CLOSE", String(frame[1])]);
|
|
241
|
+
this.discoveryDone(String(frame[1]));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (type === "CLOSED") {
|
|
245
|
+
// A refused discovery must not wedge the next one.
|
|
246
|
+
this.discoveryDone(String(frame[1]), true);
|
|
200
247
|
return;
|
|
201
248
|
}
|
|
202
249
|
if (type !== "EVENT")
|
|
203
250
|
return;
|
|
251
|
+
if (this.discovery && (frame[1] === this.discovery.rosterSub || frame[1] === this.discovery.metadataSub)) {
|
|
252
|
+
this.discovered(frame[2]);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
204
255
|
const event = frame[2];
|
|
205
256
|
if (!event || event.pubkey === this.key.publicKey)
|
|
206
257
|
return;
|
|
@@ -243,9 +294,116 @@ export class BuzzRelay {
|
|
|
243
294
|
if (this.replyWatch.size > 0) {
|
|
244
295
|
filters.push({ kinds: [KIND_MESSAGE], "#e": [...this.replyWatch], since: this.cursor });
|
|
245
296
|
}
|
|
297
|
+
for (const ids of chunks([...this.direct], MAX_FILTERS - filters.length)) {
|
|
298
|
+
filters.push({ kinds: [KIND_MESSAGE], "#h": ids, since: this.cursor });
|
|
299
|
+
}
|
|
246
300
|
this.send(["REQ", `m${Date.now()}`, ...filters]);
|
|
247
301
|
this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
|
|
248
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Find the one-to-one DMs this agent is in: its rosters, then which of those channels are DMs.
|
|
305
|
+
*
|
|
306
|
+
* @remarks
|
|
307
|
+
* Rosters and metadata are relay-signed; anything else claiming to be one is ignored, so a
|
|
308
|
+
* member cannot publish a roster that makes the agent answer every message in a room.
|
|
309
|
+
*/
|
|
310
|
+
discoverDirect() {
|
|
311
|
+
if (this.closed)
|
|
312
|
+
return;
|
|
313
|
+
if (this.discoveryTimer)
|
|
314
|
+
clearTimeout(this.discoveryTimer);
|
|
315
|
+
this.discoveryTimer = setTimeout(() => this.discoverDirect(), DIRECT_REFRESH_MS);
|
|
316
|
+
if (this.discovery && now() - this.discovery.startedAt < 60)
|
|
317
|
+
return;
|
|
318
|
+
void this.relayKey().then((self) => {
|
|
319
|
+
if (this.closed || self === undefined)
|
|
320
|
+
return;
|
|
321
|
+
const rosterSub = `r${Date.now()}`;
|
|
322
|
+
this.discovery = { rosterSub, metadataSub: null, rosters: new Map(), dms: new Set(), startedAt: now() };
|
|
323
|
+
this.send(["REQ", rosterSub, { kinds: [KIND_CHANNEL_MEMBERS], "#p": [this.key.publicKey], limit: 1000 }]);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** NIP-11 `self`: null when the relay names none, undefined when it could not be asked. */
|
|
327
|
+
async relayKey() {
|
|
328
|
+
if (this.relaySelf !== undefined)
|
|
329
|
+
return this.relaySelf;
|
|
330
|
+
try {
|
|
331
|
+
const response = await fetch(this.rest, {
|
|
332
|
+
headers: { accept: "application/nostr+json" },
|
|
333
|
+
signal: AbortSignal.timeout(10_000),
|
|
334
|
+
});
|
|
335
|
+
const info = (await response.json());
|
|
336
|
+
const self = [info.self, info.pubkey].find((value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value));
|
|
337
|
+
this.relaySelf = self ?? null;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.log(`could not read the relay's information document (${error.message}); direct messages wait for the next try`);
|
|
341
|
+
}
|
|
342
|
+
return this.relaySelf;
|
|
343
|
+
}
|
|
344
|
+
discovered(event) {
|
|
345
|
+
const discovery = this.discovery;
|
|
346
|
+
if (!discovery || !event)
|
|
347
|
+
return;
|
|
348
|
+
if (this.relaySelf && event.pubkey !== this.relaySelf)
|
|
349
|
+
return;
|
|
350
|
+
const id = event.tags.find((t) => t[0] === "d")?.[1];
|
|
351
|
+
if (!id)
|
|
352
|
+
return;
|
|
353
|
+
if (event.kind === KIND_CHANNEL_MEMBERS) {
|
|
354
|
+
discovery.rosters.set(id, new Set(event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1])));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// NIP-29 as Buzz writes it: the FIRST `t` is the channel's type; later ones are labels.
|
|
358
|
+
const type = event.tags.find((t) => t[0] === "t")?.[1];
|
|
359
|
+
const members = discovery.rosters.get(id);
|
|
360
|
+
if (event.kind === KIND_CHANNEL_METADATA && type === "dm" && members?.size === 2 && members.has(this.key.publicKey)) {
|
|
361
|
+
discovery.dms.add(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
discoveryDone(sub, refused = false) {
|
|
365
|
+
const discovery = this.discovery;
|
|
366
|
+
if (!discovery)
|
|
367
|
+
return;
|
|
368
|
+
if (refused) {
|
|
369
|
+
// Only a subscription still in flight can be refused. This relay answers every CLOSE we
|
|
370
|
+
// send with a CLOSED of its own, so the roster's echo arrives after the metadata request
|
|
371
|
+
// has gone out; treating it as a refusal dropped every discovery.
|
|
372
|
+
const inFlight = discovery.metadataSub === null ? discovery.rosterSub : discovery.metadataSub;
|
|
373
|
+
if (sub === inFlight)
|
|
374
|
+
this.discovery = null;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (sub === discovery.rosterSub) {
|
|
378
|
+
const ids = [...discovery.rosters.keys()];
|
|
379
|
+
if (ids.length === 0)
|
|
380
|
+
return this.adopt(new Set());
|
|
381
|
+
discovery.metadataSub = `g${Date.now()}`;
|
|
382
|
+
this.send([
|
|
383
|
+
"REQ",
|
|
384
|
+
discovery.metadataSub,
|
|
385
|
+
...chunks(ids, MAX_FILTERS).map((chunk) => ({ kinds: [KIND_CHANNEL_METADATA], "#d": chunk })),
|
|
386
|
+
]);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (sub === discovery.metadataSub)
|
|
390
|
+
this.adopt(discovery.dms);
|
|
391
|
+
}
|
|
392
|
+
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
393
|
+
adopt(found) {
|
|
394
|
+
// Overlap the last discovery by a few seconds; the first one starts where polling did.
|
|
395
|
+
const since = this.discoveredAt === null ? this.cursor : this.discoveredAt - 5;
|
|
396
|
+
const fresh = [...found].filter((id) => !this.direct.has(id));
|
|
397
|
+
this.direct = new Set(found);
|
|
398
|
+
this.discovery = null;
|
|
399
|
+
this.discoveredAt = now();
|
|
400
|
+
if (fresh.length > 0) {
|
|
401
|
+
this.log(`direct messages: ${found.size} conversation(s), ${fresh.length} new`);
|
|
402
|
+
// The poll's cursor may already be past a message sent in a DM before it was found.
|
|
403
|
+
const filters = chunks(fresh, MAX_FILTERS).map((ids) => ({ kinds: [KIND_MESSAGE], "#h": ids, since }));
|
|
404
|
+
this.send(["REQ", `m${Date.now()}c`, ...filters]);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
249
407
|
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
250
408
|
publish(event) {
|
|
251
409
|
const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
|
|
@@ -267,10 +425,13 @@ export class BuzzRelay {
|
|
|
267
425
|
const timer = setInterval(tick, TYPING_INTERVAL_MS);
|
|
268
426
|
return () => clearInterval(timer);
|
|
269
427
|
}
|
|
270
|
-
|
|
428
|
+
/** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
|
|
429
|
+
reply(channel, text, replyTo, options = {}) {
|
|
271
430
|
const tags = [["h", channel]];
|
|
272
431
|
if (replyTo) {
|
|
273
|
-
tags.push(["e", replyTo.id]
|
|
432
|
+
tags.push(["e", replyTo.id]);
|
|
433
|
+
if (options.mention !== false)
|
|
434
|
+
tags.push(["p", replyTo.pubkey]);
|
|
274
435
|
}
|
|
275
436
|
return this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
276
437
|
}
|
|
@@ -345,3 +506,11 @@ export class BuzzRelay {
|
|
|
345
506
|
}
|
|
346
507
|
}
|
|
347
508
|
}
|
|
509
|
+
/** Split ids into at most `max` filters' worth; ids beyond that are dropped, never a filter too many. */
|
|
510
|
+
function chunks(ids, max) {
|
|
511
|
+
const out = [];
|
|
512
|
+
for (let at = 0; at < ids.length && out.length < max; at += IDS_PER_FILTER) {
|
|
513
|
+
out.push(ids.slice(at, at + IDS_PER_FILTER));
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
package/dist/turn.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
|
|
2
|
+
import { type Card } from "@kybernesis/cards";
|
|
2
3
|
import { type FetchedMedia, type MediaRef } from "./media.js";
|
|
3
4
|
import { SessionStore } from "./sessions.js";
|
|
4
5
|
/** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
|
|
@@ -64,6 +65,8 @@ export interface TurnOutcome {
|
|
|
64
65
|
streamIndex: number;
|
|
65
66
|
/** Failures seen on the stream, in order. Empty on a clean turn. */
|
|
66
67
|
failures: readonly TurnFailure[];
|
|
68
|
+
/** Cards the turn's tools handed back, read the way the apps read them. */
|
|
69
|
+
cards: readonly Card[];
|
|
67
70
|
}
|
|
68
71
|
/** Read a failure out of one stream event, or nothing when the event is not one. */
|
|
69
72
|
export declare function failureFromEvent(event: {
|
package/dist/turn.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClientError, createDataUrlFilePart } from "eve/client";
|
|
2
|
+
import { cardsOf, newStepState, readStep } from "@kybernesis/cards";
|
|
2
3
|
import { isImage } from "./media.js";
|
|
3
4
|
/** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
|
|
4
5
|
export const DEFAULT_AGENT_SILENCE_TIMEOUT_MS = 5 * 60_000;
|
|
@@ -210,6 +211,7 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
210
211
|
let status = "completed";
|
|
211
212
|
let inputRequests = [];
|
|
212
213
|
let failures = [];
|
|
214
|
+
let steps = newStepState();
|
|
213
215
|
let sawTurnStart = false;
|
|
214
216
|
let endedAtOwnBoundary = false;
|
|
215
217
|
let staleBoundaryFirst = false;
|
|
@@ -240,8 +242,10 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
240
242
|
status = "completed";
|
|
241
243
|
inputRequests = [];
|
|
242
244
|
failures = [];
|
|
245
|
+
steps = newStepState();
|
|
243
246
|
return false;
|
|
244
247
|
}
|
|
248
|
+
readStep(steps, event.type, event.data);
|
|
245
249
|
const failure = failureFromEvent(event);
|
|
246
250
|
if (failure)
|
|
247
251
|
failures.push(failure);
|
|
@@ -298,6 +302,7 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
298
302
|
sessionId: response.sessionId,
|
|
299
303
|
streamIndex: streamIndex(),
|
|
300
304
|
failures,
|
|
305
|
+
cards: cardsOf(steps.steps.values()),
|
|
301
306
|
};
|
|
302
307
|
}
|
|
303
308
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test": "node --test \"test/**/*.test.mjs\""
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@kybernesis/cards": "^0.
|
|
47
|
+
"@kybernesis/cards": "^0.4.0",
|
|
48
48
|
"@kybernesis/enterprise": "^0.10.0",
|
|
49
49
|
"nostr-tools": "^2.10.0",
|
|
50
50
|
"zod": "^4"
|