@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 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 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
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
- log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
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
- if (!channel || !(addressed || answersPrompt))
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 the community and becomes a URL; a URL is used
161
- // as given. Nobody should need storage of their own to give an agent a face.
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
- uploaded = await profile.uploadImage(url, key, bytes, profile.mediaTypeOf(path));
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 (!uploaded) {
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 = uploaded;
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
- await profile.write(url, key, wanted);
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. 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.
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. 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.
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
- // Where the picture lives. Null leaves each community's picture as it is.
107
- let pictureUrl = null;
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 && /^https:\/\//.test(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 (!picture) {
115
- pictureProblem = "the picture is not an image this can send";
142
+ if (picture) {
143
+ sha256 = picture.sha256;
144
+ bytes = picture;
116
145
  }
117
146
  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
- }
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", ...(pictureProblem ? { detail: `picture not sent: ${pictureProblem}` } : {}) });
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
- onMessage(message: string): void | Promise<void>;
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
- if (completedMessage.trim())
117
- await callbacks.onMessage(completedMessage);
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
- const expires = Math.floor(Date.now() / 1000) + 300;
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: Math.floor(Date.now() / 1000),
55
+ created_at: issued,
54
56
  tags: [
55
57
  ["t", "get"],
56
58
  ...(ref.sha256 ? [["x", ref.sha256]] : []),
57
- ["expiration", String(expires)],
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
- const expires = now() + 300;
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
- reply(channel: string, text: string, replyTo?: NostrEvent): string | null;
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
- reply(channel, text, replyTo) {
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], ["p", replyTo.pubkey]);
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.12.1",
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.1.1",
47
+ "@kybernesis/cards": "^0.4.0",
48
48
  "@kybernesis/enterprise": "^0.10.0",
49
49
  "nostr-tools": "^2.10.0",
50
50
  "zod": "^4"