@kybernesis/buzz 0.6.3 → 0.8.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/dist/bridge.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { channelIdentity } from "@kybernesis/enterprise";
2
+ import { fetchMedia, isImage, parseMedia } from "./media.js";
2
3
  import { speakerCredentials } from "./credentials.js";
4
+ import { SessionStore } from "./sessions.js";
3
5
  import { Client } from "eve/client";
4
6
  import { BuzzRelay } from "./relay.js";
5
7
  import { loadKey, npubEncode } from "./keys.js";
@@ -47,8 +49,16 @@ export function buzzBridge(options) {
47
49
  * previous person's answer. The turn itself runs correctly, as the right
48
50
  * person, against the right connections; only the text posted back is
49
51
  * somebody else's, which is the most convincing way to look broken.
52
+ *
53
+ * Kept on DISK, not in memory. The eve session is durable; the knowledge of
54
+ * which session belongs to a channel was not, so a restart orphaned every
55
+ * conversation — the next message opened a new session and the agent said it
56
+ * had no context, while the old session sat intact holding a reply nobody was
57
+ * left to read.
50
58
  */
51
- const sessions = new Map();
59
+ const sessions = new SessionStore(process.env.BUZZ_SESSIONS_FILE ?? ".buzz-sessions.json");
60
+ if (sessions.size > 0)
61
+ log(`resumed ${sessions.size} conversation(s) from the last run`);
52
62
  /** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
53
63
  const invited = new Map();
54
64
  /**
@@ -103,11 +113,53 @@ export function buzzBridge(options) {
103
113
  * nothing. The bridge knew the answer the whole time: it is the connection
104
114
  * the message arrived on.
105
115
  */
106
- async function ask(channel, text, pubkey, community) {
116
+ /**
117
+ * One inbound message, as the parts a turn can carry.
118
+ *
119
+ * @remarks
120
+ * Returns a plain string when there is nothing but text, so the common case
121
+ * stays exactly as it was — a change in the shape of every ordinary turn is
122
+ * not worth paying for the rare one.
123
+ *
124
+ * A failed fetch does NOT throw. The person is owed an answer either way, and
125
+ * an agent told "there was an attachment I could not retrieve" can say so;
126
+ * an agent told nothing answers the caption and looks like it ignored the
127
+ * picture, which is the bug being fixed.
128
+ */
129
+ async function composeMessage(text, attachments) {
130
+ if (attachments.length === 0)
131
+ return text;
132
+ const parts = [];
133
+ if (text)
134
+ parts.push({ type: "text", text });
135
+ for (const ref of attachments) {
136
+ try {
137
+ const media = await fetchMedia(key, ref);
138
+ if (isImage(media.mediaType)) {
139
+ parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
140
+ }
141
+ else {
142
+ parts.push({
143
+ type: "text",
144
+ 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.]`,
145
+ });
146
+ }
147
+ }
148
+ catch (error) {
149
+ log(`could not fetch an attachment: ${error.message}`);
150
+ parts.push({
151
+ type: "text",
152
+ 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.]`,
153
+ });
154
+ }
155
+ }
156
+ return parts;
157
+ }
158
+ async function ask(channel, message, pubkey, community) {
107
159
  // Built per turn, for the person whose turn it is — and kept current for as
108
160
  // long as that turn runs.
109
161
  const client = clientFor(pubkey);
110
- const existing = sessions.get(channel);
162
+ const existing = sessions.get(community, channel);
111
163
  if (existing) {
112
164
  try {
113
165
  const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
@@ -138,29 +190,29 @@ export function buzzBridge(options) {
138
190
  if (unread > 0) {
139
191
  log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
140
192
  }
141
- const response = await session.send(text, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
193
+ const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
142
194
  const result = await response.result();
143
195
  // Where this turn left the conversation, so the next one starts after it.
144
- sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
196
+ sessions.set(community, channel, { id: existing.id, streamIndex: session.state.streamIndex });
145
197
  return result.message ?? "";
146
198
  }
147
199
  catch (error) {
148
200
  // A session the agent no longer holds cannot be resumed. Starting a new one loses the
149
201
  // thread but keeps the conversation alive, which is the better of the two failures.
150
202
  log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
151
- sessions.delete(channel);
203
+ sessions.delete(community, channel);
152
204
  }
153
205
  }
154
206
  const created = await client.sessions.create({
155
- message: text,
207
+ message,
156
208
  clientContext: { buzzCommunity: community, buzzChannel: channel },
157
209
  });
158
- const message = (await created.response.result()).message ?? "";
159
- sessions.set(channel, {
210
+ const reply = (await created.response.result()).message ?? "";
211
+ sessions.set(community, channel, {
160
212
  id: created.session.state.sessionId,
161
213
  streamIndex: created.session.state.streamIndex,
162
214
  });
163
- return message;
215
+ return reply;
164
216
  }
165
217
  /**
166
218
  * Send someone the link that makes them known, privately.
@@ -223,8 +275,21 @@ export function buzzBridge(options) {
223
275
  if (!channel || !addressed)
224
276
  return;
225
277
  const text = String(event.content ?? "").trim();
226
- if (!text)
278
+ const attachments = parseMedia(event);
279
+ /**
280
+ * Drop only a message with NOTHING in it — and say so when we do.
281
+ *
282
+ * This guard used to test the text alone, and sat above the log line, so an
283
+ * image sent without a caption was discarded before anything was written
284
+ * down. From the outside the agent had simply ignored someone; from the
285
+ * inside there was no record that a message had ever arrived. The reply
286
+ * path was hardened against exactly this failure — an empty answer now
287
+ * says so out loud — and the receiving path was left as it was.
288
+ */
289
+ if (!text && attachments.length === 0) {
290
+ log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
227
291
  return;
292
+ }
228
293
  let speaker;
229
294
  try {
230
295
  speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
@@ -242,11 +307,23 @@ export function buzzBridge(options) {
242
307
  await refuse(relay, event.pubkey, speaker.reason);
243
308
  return;
244
309
  }
245
- log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}`);
310
+ log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
311
+ (attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
312
+ /**
313
+ * Attachments become parts of the turn, or become words about themselves.
314
+ *
315
+ * An image the model can see goes in as bytes. Anything else — a zip, a
316
+ * PDF the model cannot read, a fetch that failed — goes in as a sentence
317
+ * saying so, because the alternative is an agent that answers the caption
318
+ * while the person waits for a reply about the file they sent. Being told
319
+ * "I got a file I cannot read" is a worse answer than the truth only if
320
+ * you never wanted the truth.
321
+ */
322
+ const message = await composeMessage(text, attachments);
246
323
  void relay.react(event.id, SEEN);
247
324
  const stopTyping = relay.typingIn(channel);
248
325
  try {
249
- const reply = await serialize(channel, () => ask(channel, text, event.pubkey, from));
326
+ const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
250
327
  if (!reply) {
251
328
  /**
252
329
  * Silence is the worst answer available.
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ /** One conversation: which eve session it is, and how far the bridge has read. */
2
+ export interface StoredSession {
3
+ id: string;
4
+ streamIndex: number;
5
+ /** When it was last used, so a store that runs for years does not grow forever. */
6
+ updated: number;
7
+ }
8
+ /**
9
+ * Which eve session belongs to which channel, across restarts.
10
+ *
11
+ * @remarks
12
+ * The eve session itself is durable — it lives on the agent's disk and survives
13
+ * anything. What did NOT survive was the bridge's knowledge of WHICH session
14
+ * belonged to a channel: that was a Map in memory, so a restart silently
15
+ * orphaned every conversation. The next message opened a new session and the
16
+ * agent answered "I have no context", while the old session sat on disk intact,
17
+ * holding a turn whose reply nobody was left to read.
18
+ *
19
+ * Reported from a live deployment after a restart mid-conversation, and the
20
+ * diagnosis there was exactly right: durable on one side, ephemeral on the
21
+ * other, and the join between them was the part that could not survive.
22
+ *
23
+ * Keyed by community AND channel: channel ids are issued per relay, so an agent
24
+ * in two workspaces cannot assume they never collide.
25
+ */
26
+ export declare class SessionStore {
27
+ #private;
28
+ constructor(file: string);
29
+ static key(community: string, channel: string): string;
30
+ get(community: string, channel: string): StoredSession | undefined;
31
+ set(community: string, channel: string, session: {
32
+ id: string;
33
+ streamIndex: number;
34
+ }): void;
35
+ delete(community: string, channel: string): void;
36
+ get size(): number;
37
+ }
@@ -0,0 +1,80 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ /** How long an untouched conversation is kept before it is forgotten. */
4
+ const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
5
+ /**
6
+ * Which eve session belongs to which channel, across restarts.
7
+ *
8
+ * @remarks
9
+ * The eve session itself is durable — it lives on the agent's disk and survives
10
+ * anything. What did NOT survive was the bridge's knowledge of WHICH session
11
+ * belonged to a channel: that was a Map in memory, so a restart silently
12
+ * orphaned every conversation. The next message opened a new session and the
13
+ * agent answered "I have no context", while the old session sat on disk intact,
14
+ * holding a turn whose reply nobody was left to read.
15
+ *
16
+ * Reported from a live deployment after a restart mid-conversation, and the
17
+ * diagnosis there was exactly right: durable on one side, ephemeral on the
18
+ * other, and the join between them was the part that could not survive.
19
+ *
20
+ * Keyed by community AND channel: channel ids are issued per relay, so an agent
21
+ * in two workspaces cannot assume they never collide.
22
+ */
23
+ export class SessionStore {
24
+ #file;
25
+ #entries = new Map();
26
+ constructor(file) {
27
+ this.#file = file;
28
+ this.#load();
29
+ }
30
+ static key(community, channel) {
31
+ return `${community}|${channel}`;
32
+ }
33
+ get(community, channel) {
34
+ return this.#entries.get(SessionStore.key(community, channel));
35
+ }
36
+ set(community, channel, session) {
37
+ this.#entries.set(SessionStore.key(community, channel), { ...session, updated: Date.now() });
38
+ this.#save();
39
+ }
40
+ delete(community, channel) {
41
+ this.#entries.delete(SessionStore.key(community, channel));
42
+ this.#save();
43
+ }
44
+ get size() {
45
+ return this.#entries.size;
46
+ }
47
+ #load() {
48
+ if (!existsSync(this.#file))
49
+ return;
50
+ try {
51
+ const raw = JSON.parse(readFileSync(this.#file, "utf8"));
52
+ const cutoff = Date.now() - KEEP_MS;
53
+ for (const [key, value] of Object.entries(raw)) {
54
+ if (typeof value?.id === "string" && (value.updated ?? 0) > cutoff) {
55
+ this.#entries.set(key, value);
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ // A corrupt store is not worth refusing to start over. Losing the mapping
61
+ // costs conversation history; refusing to start costs the whole agent.
62
+ }
63
+ }
64
+ #save() {
65
+ try {
66
+ mkdirSync(dirname(this.#file), { recursive: true });
67
+ // Written beside the target and renamed, because the failure being
68
+ // guarded against is a restart — and a restart during a plain write
69
+ // leaves a truncated file that reads as "no sessions at all", which is
70
+ // the exact bug this class exists to prevent.
71
+ const tmp = `${this.#file}.tmp`;
72
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.#entries)), { mode: 0o600 });
73
+ renameSync(tmp, this.#file);
74
+ }
75
+ catch {
76
+ // Best effort: an agent that cannot write its store still answers, it
77
+ // just forgets across restarts — which is where this started.
78
+ }
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.6.3",
3
+ "version": "0.8.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",