@kybernesis/buzz 0.6.2 → 0.7.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.
@@ -24,11 +24,13 @@ async function queryRelay(relayHttpUrl, secretKey, filters) {
24
24
  ],
25
25
  content: ""
26
26
  }, secretKey);
27
+ const authTag = process.env.BUZZ_AUTH_TAG?.trim();
27
28
  const response = await fetch(url, {
28
29
  method: "POST",
29
30
  headers: {
30
31
  authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`,
31
- "content-type": "application/json"
32
+ "content-type": "application/json",
33
+ ...authTag ? { "x-auth-tag": authTag } : {}
32
34
  },
33
35
  body
34
36
  });
package/dist/bridge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { channelIdentity } from "@kybernesis/enterprise";
2
+ import { fetchMedia, isImage, parseMedia } from "./media.js";
2
3
  import { speakerCredentials } from "./credentials.js";
3
4
  import { Client } from "eve/client";
4
5
  import { BuzzRelay } from "./relay.js";
@@ -103,7 +104,49 @@ export function buzzBridge(options) {
103
104
  * nothing. The bridge knew the answer the whole time: it is the connection
104
105
  * the message arrived on.
105
106
  */
106
- async function ask(channel, text, pubkey, community) {
107
+ /**
108
+ * One inbound message, as the parts a turn can carry.
109
+ *
110
+ * @remarks
111
+ * Returns a plain string when there is nothing but text, so the common case
112
+ * stays exactly as it was — a change in the shape of every ordinary turn is
113
+ * not worth paying for the rare one.
114
+ *
115
+ * A failed fetch does NOT throw. The person is owed an answer either way, and
116
+ * an agent told "there was an attachment I could not retrieve" can say so;
117
+ * an agent told nothing answers the caption and looks like it ignored the
118
+ * picture, which is the bug being fixed.
119
+ */
120
+ async function composeMessage(text, attachments) {
121
+ if (attachments.length === 0)
122
+ return text;
123
+ const parts = [];
124
+ if (text)
125
+ parts.push({ type: "text", text });
126
+ for (const ref of attachments) {
127
+ try {
128
+ const media = await fetchMedia(key, ref);
129
+ if (isImage(media.mediaType)) {
130
+ parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
131
+ }
132
+ else {
133
+ parts.push({
134
+ type: "text",
135
+ text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
136
+ });
137
+ }
138
+ }
139
+ catch (error) {
140
+ log(`could not fetch an attachment: ${error.message}`);
141
+ parts.push({
142
+ type: "text",
143
+ text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
144
+ });
145
+ }
146
+ }
147
+ return parts;
148
+ }
149
+ async function ask(channel, message, pubkey, community) {
107
150
  // Built per turn, for the person whose turn it is — and kept current for as
108
151
  // long as that turn runs.
109
152
  const client = clientFor(pubkey);
@@ -138,7 +181,7 @@ export function buzzBridge(options) {
138
181
  if (unread > 0) {
139
182
  log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
140
183
  }
141
- const response = await session.send(text, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
184
+ const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
142
185
  const result = await response.result();
143
186
  // Where this turn left the conversation, so the next one starts after it.
144
187
  sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
@@ -152,15 +195,15 @@ export function buzzBridge(options) {
152
195
  }
153
196
  }
154
197
  const created = await client.sessions.create({
155
- message: text,
198
+ message,
156
199
  clientContext: { buzzCommunity: community, buzzChannel: channel },
157
200
  });
158
- const message = (await created.response.result()).message ?? "";
201
+ const reply = (await created.response.result()).message ?? "";
159
202
  sessions.set(channel, {
160
203
  id: created.session.state.sessionId,
161
204
  streamIndex: created.session.state.streamIndex,
162
205
  });
163
- return message;
206
+ return reply;
164
207
  }
165
208
  /**
166
209
  * Send someone the link that makes them known, privately.
@@ -223,8 +266,21 @@ export function buzzBridge(options) {
223
266
  if (!channel || !addressed)
224
267
  return;
225
268
  const text = String(event.content ?? "").trim();
226
- if (!text)
269
+ const attachments = parseMedia(event);
270
+ /**
271
+ * Drop only a message with NOTHING in it — and say so when we do.
272
+ *
273
+ * This guard used to test the text alone, and sat above the log line, so an
274
+ * image sent without a caption was discarded before anything was written
275
+ * down. From the outside the agent had simply ignored someone; from the
276
+ * inside there was no record that a message had ever arrived. The reply
277
+ * path was hardened against exactly this failure — an empty answer now
278
+ * says so out loud — and the receiving path was left as it was.
279
+ */
280
+ if (!text && attachments.length === 0) {
281
+ log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
227
282
  return;
283
+ }
228
284
  let speaker;
229
285
  try {
230
286
  speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
@@ -242,11 +298,23 @@ export function buzzBridge(options) {
242
298
  await refuse(relay, event.pubkey, speaker.reason);
243
299
  return;
244
300
  }
245
- log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}`);
301
+ log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
302
+ (attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
303
+ /**
304
+ * Attachments become parts of the turn, or become words about themselves.
305
+ *
306
+ * An image the model can see goes in as bytes. Anything else — a zip, a
307
+ * PDF the model cannot read, a fetch that failed — goes in as a sentence
308
+ * saying so, because the alternative is an agent that answers the caption
309
+ * while the person waits for a reply about the file they sent. Being told
310
+ * "I got a file I cannot read" is a worse answer than the truth only if
311
+ * you never wanted the truth.
312
+ */
313
+ const message = await composeMessage(text, attachments);
246
314
  void relay.react(event.id, SEEN);
247
315
  const stopTyping = relay.typingIn(channel);
248
316
  try {
249
- const reply = await serialize(channel, () => ask(channel, text, event.pubkey, from));
317
+ const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
250
318
  if (!reply) {
251
319
  /**
252
320
  * Silence is the worst answer available.
@@ -4,5 +4,5 @@ import { createRequire as __eveCreateRequire } from "node:module";
4
4
  const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
- import { t as find_default } from "../../_chunks/find-B2f5dymb.mjs";
7
+ import { t as find_default } from "../../_chunks/find-Dww643iv.mjs";
8
8
  export { find_default as default };
@@ -0,0 +1,48 @@
1
+ import type { AgentKey } from "./keys.js";
2
+ import type { NostrEvent } from "./relay.js";
3
+ /** One attachment on an inbound message. */
4
+ export interface MediaRef {
5
+ url: string;
6
+ /** From the `m` field when the client sends one; the response may correct it. */
7
+ mediaType?: string;
8
+ /** From the `x` field: the hash the blob is addressed by. */
9
+ sha256?: string;
10
+ }
11
+ /** Fetched bytes, ready to become a message part. */
12
+ export interface FetchedMedia {
13
+ url: string;
14
+ mediaType: string;
15
+ bytes: Uint8Array;
16
+ }
17
+ /**
18
+ * The attachments on a message, from its `imeta` tags (NIP-92).
19
+ *
20
+ * @remarks
21
+ * A Buzz client does NOT inline an attachment into the message text. The
22
+ * content stays prose — often prose *about* the picture — and the media lives
23
+ * in a tag. A bridge that reads only `event.content` therefore sees a caption
24
+ * with nothing attached, and, when there is no caption, sees nothing at all.
25
+ *
26
+ * Both were reported from a live deployment: an image with a caption was
27
+ * answered as though the caption were the whole message, and an image without
28
+ * one produced no response and no log line. The agent could not tell either had
29
+ * happened, so it speculated about its own vision support — which was fine.
30
+ */
31
+ export declare function parseMedia(event: NostrEvent): MediaRef[];
32
+ /**
33
+ * Fetch one attachment as the agent.
34
+ *
35
+ * @remarks
36
+ * Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
37
+ * URL cannot simply be handed to the model — a provider fetching it would be
38
+ * refused, and the failure would surface as a broken image rather than as an
39
+ * auth problem. The bytes are fetched here, with the same Blossom
40
+ * authorisation this package already signs to upload an agent's avatar, and
41
+ * passed inline.
42
+ *
43
+ * `t get` rather than `t upload`, and the blob's own hash when the sender told
44
+ * us one, so the authorisation is scoped to reading that specific blob.
45
+ */
46
+ export declare function fetchMedia(key: AgentKey, ref: MediaRef, fetchImpl?: typeof fetch): Promise<FetchedMedia>;
47
+ /** Whether the model can be shown this directly, or only told about it. */
48
+ export declare function isImage(mediaType: string): boolean;
package/dist/media.js ADDED
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import { finalizeEvent } from "nostr-tools/pure";
3
+ /**
4
+ * The attachments on a message, from its `imeta` tags (NIP-92).
5
+ *
6
+ * @remarks
7
+ * A Buzz client does NOT inline an attachment into the message text. The
8
+ * content stays prose — often prose *about* the picture — and the media lives
9
+ * in a tag. A bridge that reads only `event.content` therefore sees a caption
10
+ * with nothing attached, and, when there is no caption, sees nothing at all.
11
+ *
12
+ * Both were reported from a live deployment: an image with a caption was
13
+ * answered as though the caption were the whole message, and an image without
14
+ * one produced no response and no log line. The agent could not tell either had
15
+ * happened, so it speculated about its own vision support — which was fine.
16
+ */
17
+ export function parseMedia(event) {
18
+ const media = [];
19
+ for (const tag of event.tags) {
20
+ if (tag[0] !== "imeta")
21
+ continue;
22
+ // An imeta tag is space-separated "key value" pairs, one per element.
23
+ const fields = new Map();
24
+ for (const entry of tag.slice(1)) {
25
+ const space = entry.indexOf(" ");
26
+ if (space > 0)
27
+ fields.set(entry.slice(0, space), entry.slice(space + 1));
28
+ }
29
+ const url = fields.get("url");
30
+ if (url)
31
+ media.push({ url, mediaType: fields.get("m"), sha256: fields.get("x") });
32
+ }
33
+ return media;
34
+ }
35
+ /**
36
+ * Fetch one attachment as the agent.
37
+ *
38
+ * @remarks
39
+ * Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
40
+ * URL cannot simply be handed to the model — a provider fetching it would be
41
+ * refused, and the failure would surface as a broken image rather than as an
42
+ * auth problem. The bytes are fetched here, with the same Blossom
43
+ * authorisation this package already signs to upload an agent's avatar, and
44
+ * passed inline.
45
+ *
46
+ * `t get` rather than `t upload`, and the blob's own hash when the sender told
47
+ * us one, so the authorisation is scoped to reading that specific blob.
48
+ */
49
+ export async function fetchMedia(key, ref, fetchImpl = fetch) {
50
+ const expires = Math.floor(Date.now() / 1000) + 300;
51
+ const auth = finalizeEvent({
52
+ kind: 24242,
53
+ created_at: Math.floor(Date.now() / 1000),
54
+ tags: [
55
+ ["t", "get"],
56
+ ...(ref.sha256 ? [["x", ref.sha256]] : []),
57
+ ["expiration", String(expires)],
58
+ ],
59
+ content: "Fetch buzz-media",
60
+ }, key.secretKey);
61
+ const response = await fetchImpl(ref.url, {
62
+ headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64url")}` },
63
+ });
64
+ if (!response.ok) {
65
+ throw new Error(`${response.status} fetching ${ref.url}`);
66
+ }
67
+ const bytes = new Uint8Array(await response.arrayBuffer());
68
+ // The server's answer wins over the sender's claim: the sender's `m` field is
69
+ // whatever their client asserted, and a wrong media type reaches the model as
70
+ // an image it cannot decode.
71
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || ref.mediaType || "application/octet-stream";
72
+ if (ref.sha256) {
73
+ // Blossom addresses a blob BY its hash, so a mismatch means the bytes are
74
+ // not the ones that were sent. Cheap to check and not worth trusting past.
75
+ const actual = createHash("sha256").update(bytes).digest("hex");
76
+ if (actual !== ref.sha256) {
77
+ throw new Error(`content hash mismatch for ${ref.url}`);
78
+ }
79
+ }
80
+ return { url: ref.url, mediaType, bytes };
81
+ }
82
+ /** Whether the model can be shown this directly, or only told about it. */
83
+ export function isImage(mediaType) {
84
+ return mediaType.startsWith("image/");
85
+ }
package/dist/query.js CHANGED
@@ -44,11 +44,23 @@ export async function queryRelay(relayHttpUrl, secretKey, filters) {
44
44
  // than the one that signed it can arrive unauthenticated — which this relay
45
45
  // answers with an empty list rather than a refusal, so it reads as "nothing
46
46
  // here" instead of "you are not who you said you were".
47
+ /**
48
+ * The owner-attestation tag, when the workspace issues one.
49
+ *
50
+ * @remarks
51
+ * NIP-98 proves which key made the request; this proves the workspace agreed
52
+ * that key may act on someone's behalf. Some operations — owner-reviewed
53
+ * agent drafts among them — are refused without it, and the refusal names
54
+ * neither the tag nor the header, so it reads as a permissions problem with
55
+ * no obvious cause. Optional, because most of the surface never asks.
56
+ */
57
+ const authTag = process.env.BUZZ_AUTH_TAG?.trim();
47
58
  const response = await fetch(url, {
48
59
  method: "POST",
49
60
  headers: {
50
61
  authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`,
51
62
  "content-type": "application/json",
63
+ ...(authTag ? { "x-auth-tag": authTag } : {}),
52
64
  },
53
65
  body,
54
66
  });
@@ -5,5 +5,5 @@ const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import buzz_default from "../extension/tools/buzz.mjs";
8
- import { t as find_default } from "../_chunks/find-B2f5dymb.mjs";
8
+ import { t as find_default } from "../_chunks/find-Dww643iv.mjs";
9
9
  export { buzz_default as buzz, find_default as find };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.6.2",
3
+ "version": "0.7.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",