@kybernesis/buzz 0.14.0 → 0.15.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
@@ -100,7 +100,7 @@ can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
100
100
  When the agent needs a yes or an answer, the bridge posts the question as a reply to the message
101
101
  that asked: an approval as the email, event or message it would send (recipients, subject and
102
102
  the whole body, not "Approve tool call"), any other question as its sentence, then the options by
103
- number. Anyone can answer by replying with a number, an option or their own words. Since 0.14 the
103
+ number. The person who asked answers by replying with a number, an option or their own words. Since 0.14 the
104
104
  question also travels hidden (`<!-- kyb:ask v1 {...} -->`), and the plugin draws it as Studio
105
105
  does, with Send it, Edit and Don't send on an email, the amount and Approve in Link on a
106
106
  purchase, and the fields of a form. A press posts the person's answer as a reply to the
@@ -109,6 +109,16 @@ bridge accepts a hidden answer only as a direct reply to a prompt it posted, onl
109
109
  still open, and only with that request's own options. Replies inside a thread count, whichever
110
110
  `e` tag names the prompt. A vault request never takes a secret in the channel.
111
111
 
112
+ Since 0.15 a question is for the person whose message raised it. Anyone else who presses an
113
+ answer, or names the agent while it waits, is told whose answer it needs; a remark in the
114
+ question's thread is left alone. The question carries who asked, so the plugin shows everyone
115
+ else the email with "Waiting on" and no buttons. `BUZZ_ANSWERS=anyone` keeps the older rule.
116
+ When a question ends (answered, declined, sent back with changes, or no longer waited on because
117
+ its work stopped) the bridge edits its own prompt to say how and by whom, with the question
118
+ hidden as `<!-- kyb:settled v1 {...} -->` instead. Only a message's author can edit it, so every
119
+ client, with the plugin or without, shows the same outcome and nobody is offered the buttons
120
+ again. The edit names nobody, so it notifies nobody.
121
+
112
122
  If the work an answer restarts fails (a model refusing the continuation, say), the bridge says so
113
123
  in the channel with the stream's own error, rather than leaving the person who answered with
114
124
  silence. A declined call is the person's choice and is not reported as a failure.
@@ -124,6 +134,7 @@ silence. A declined call is the person's choice and is not reported as a failure
124
134
  | `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. |
125
135
  | `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. |
126
136
  | `BUZZ_CARDS` | `off` stops posting cards after replies and hidden questions in prompts (default on) |
137
+ | `BUZZ_ANSWERS` | Who may answer a question in a shared channel: `asker` (default), only the person whose message raised it, or `anyone` the agent knows there |
127
138
  | `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) |
128
139
  | `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
129
140
  | `KYBERNESIS_ISSUER` | The control plane |
package/dist/bridge.d.ts CHANGED
@@ -31,6 +31,15 @@ export type BuzzBridgeOptions = {
31
31
  * Default true; false posts the reply and questions as words alone.
32
32
  */
33
33
  cards?: boolean;
34
+ /**
35
+ * Who may answer a question the agent asks in a shared channel. `asker`
36
+ * (the default): only the person whose message raised it; anyone else is
37
+ * told so, and the plugin shows them the question without buttons.
38
+ * `anyone`: anyone the agent knows in the channel, as before 0.15.
39
+ * Either way, once a question ends the bridge edits it to say how, for
40
+ * everyone who can see it.
41
+ */
42
+ answers?: "asker" | "anyone";
34
43
  onLog?: (message: string) => void;
35
44
  };
36
45
  export declare function buzzBridge(options: BuzzBridgeOptions): {
package/dist/bridge.js CHANGED
@@ -5,7 +5,7 @@ import { speakerCredentials } from "./credentials.js";
5
5
  import { surfaceWriter } from "./surface.js";
6
6
  import { SessionStore } from "./sessions.js";
7
7
  import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
8
- import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
8
+ import { applySettlements, followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, isAsker, needsPendingFollower, notTheAskerReply, settlementsFor, settleRemaining, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
9
9
  import { existsSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { Client } from "eve/client";
@@ -35,6 +35,8 @@ export function buzzBridge(options) {
35
35
  const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
36
36
  const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
37
37
  const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
38
+ const askerOnly = options.answers !== "anyone";
39
+ const carry = options.cards !== false;
38
40
  /**
39
41
  * An artifact card, with a link everyone in the channel can open.
40
42
  *
@@ -262,6 +264,49 @@ export function buzzBridge(options) {
262
264
  followers.get(conversation)?.abort();
263
265
  followers.delete(conversation);
264
266
  }
267
+ /**
268
+ * Edit each posted question whose requests just ended, so everyone who can
269
+ * see it reads how it ended and nobody is offered its buttons again.
270
+ *
271
+ * @remarks
272
+ * The plugin used to keep a pressed question's outcome only on the device
273
+ * that pressed it. In a shared channel everyone else, and the same person on
274
+ * another device, still saw Send it on an email that had gone. The prompt is
275
+ * the agent's own message and only its author may edit it, so the edit is
276
+ * the one record every client draws. Runs inside the conversation's queue.
277
+ */
278
+ function settlePrompts(relay, community, channel, settlements) {
279
+ const current = sessions.get(community, channel);
280
+ if (!current?.prompts?.length || settlements.size === 0)
281
+ return;
282
+ const { edits, open } = applySettlements(current.prompts, settlements, { carry, askerOnly });
283
+ for (const { eventId, content } of edits) {
284
+ if (relay.edit(channel, eventId, content))
285
+ log(`marked a question in ${channel.slice(0, 8)} as ${[...new Set(settlements.values())].map((s) => s.outcome).join(", ")}`);
286
+ else
287
+ log(`could not mark a question in ${channel.slice(0, 8)} as ended: not connected`);
288
+ }
289
+ const { prompts: _prompts, ...rest } = current;
290
+ sessions.set(community, channel, open.length ? { ...rest, prompts: open } : rest);
291
+ }
292
+ /** Every question still open in this conversation ends the same way: answered elsewhere, or no longer waited on. */
293
+ function settleOpenPrompts(relay, community, channel, outcome) {
294
+ const current = sessions.get(community, channel);
295
+ if (!current?.prompts?.length)
296
+ return;
297
+ settlePrompts(relay, community, channel, settleRemaining(current.prompts, { outcome, at: Date.now() }));
298
+ }
299
+ /** Remember a posted question, so it can be edited when it ends. */
300
+ function rememberPrompt(community, channel, prompt) {
301
+ const current = sessions.get(community, channel);
302
+ if (!current)
303
+ return;
304
+ sessions.set(community, channel, {
305
+ ...current,
306
+ promptEventIds: [...(current.promptEventIds ?? []), prompt.eventId],
307
+ prompts: [...(current.prompts ?? []), prompt],
308
+ });
309
+ }
265
310
  function startFollower(community, channel) {
266
311
  if (stopped)
267
312
  return;
@@ -286,28 +331,32 @@ export function buzzBridge(options) {
286
331
  if (followers.get(conversation) !== controller)
287
332
  return;
288
333
  const current = sessions.get(community, channel);
334
+ // The follower began from a copy; who asked and what was posted are the store's.
335
+ const { prompts: _stale, asker: _staleAsker, ...fresh } = state;
336
+ const kept = {
337
+ ...(current?.asker ? { asker: current.asker } : {}),
338
+ ...(current?.prompts?.length ? { prompts: current.prompts } : {}),
339
+ };
289
340
  if (state.pendingInputRequests?.length) {
290
- sessions.set(community, channel, { ...state, promptEventIds: current?.promptEventIds });
341
+ sessions.set(community, channel, { ...fresh, ...kept, promptEventIds: current?.promptEventIds });
291
342
  return;
292
343
  }
293
344
  if (current?.promptEventIds?.length)
294
345
  relay.unwatchReplies(current.promptEventIds);
295
- sessions.set(community, channel, state);
346
+ sessions.set(community, channel, { ...fresh, ...kept });
347
+ // Nothing is waiting on a person any more. A question the bridge did not see answered
348
+ // was answered elsewhere if the turn resumed, and is no longer waited on if it ended.
349
+ settleOpenPrompts(relay, community, channel, state.resumeInFlight ? "answered" : "withdrawn");
296
350
  }),
297
351
  onInputRequested: (requests) => serialize(community, channel, async () => {
298
352
  if (followers.get(conversation) !== controller)
299
353
  return;
300
- const promptId = relay.reply(channel, formatInputRequests(requests, { carry: options.cards !== false }));
354
+ const asker = sessions.get(community, channel)?.asker;
355
+ const promptId = relay.reply(channel, formatInputRequests(requests, { carry, asker, askerOnly }));
301
356
  if (!promptId)
302
357
  throw new Error("Buzz relay is not connected");
303
358
  relay.watchReplies([promptId]);
304
- const current = sessions.get(community, channel);
305
- if (current) {
306
- sessions.set(community, channel, {
307
- ...current,
308
- promptEventIds: [...(current.promptEventIds ?? []), promptId],
309
- });
310
- }
359
+ rememberPrompt(community, channel, { eventId: promptId, requests, ...(asker ? { asker } : {}) });
311
360
  log(`posted another input request in ${channel.slice(0, 8)}`);
312
361
  }),
313
362
  onMessage: (message, cards) => serialize(community, channel, async () => {
@@ -364,7 +413,8 @@ export function buzzBridge(options) {
364
413
  return;
365
414
  if (current.promptEventIds?.length)
366
415
  relay.unwatchReplies(current.promptEventIds);
367
- const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, ...cleared } = current;
416
+ settleOpenPrompts(relay, community, channel, "answered");
417
+ const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, prompts: _q, ...cleared } = sessions.get(community, channel) ?? current;
368
418
  sessions.set(community, channel, cleared);
369
419
  relay.reply(channel, resumeStalledReply(agentWorkTimeoutMs));
370
420
  log(`gave up on the resumed turn in ${channel.slice(0, 8)}: no progress past index ${retry.index}`);
@@ -430,6 +480,14 @@ export function buzzBridge(options) {
430
480
  if (pending?.pendingInputRequests?.length) {
431
481
  let responses;
432
482
  const carried = readHiddenAnswers(text);
483
+ if (askerOnly && pending.asker && !isAsker(pending.asker, event.pubkey, speaker.user.id)) {
484
+ // Someone else's question. A remark in its thread is people talking, not an answer:
485
+ // only a press, or a message that names the agent, is told whose answer it waits for.
486
+ if (carried.length || addressed)
487
+ relay.reply(channel, notTheAskerReply(pending.asker), event);
488
+ log(`ignored an answer in ${channel.slice(0, 8)} from ${speaker.user.email}: the question is for someone else`);
489
+ return;
490
+ }
433
491
  if (carried.length) {
434
492
  // A press in the plugin. It counts only as a direct reply to a prompt this bridge
435
493
  // posted, so a message quoting someone else's question cannot answer this one.
@@ -466,6 +524,7 @@ export function buzzBridge(options) {
466
524
  });
467
525
  await respondToPendingConversation(session, responses);
468
526
  log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
527
+ const settlements = settlementsFor(responses, pending.pendingInputRequests, speaker.user.displayName?.trim() || undefined);
469
528
  /**
470
529
  * The answer is in, so this conversation is no longer waiting on a
471
530
  * person. It is waiting on the agent, and that is a thing the bridge
@@ -487,6 +546,7 @@ export function buzzBridge(options) {
487
546
  const { pendingInputRequests: _resolved, ...rest } = answered;
488
547
  sessions.set(from, channel, remaining.length ? { ...rest, pendingInputRequests: remaining } : { ...rest, resumeInFlight: true });
489
548
  }
549
+ settlePrompts(relay, from, channel, settlements);
490
550
  return;
491
551
  }
492
552
  if (pending?.resumeInFlight) {
@@ -503,18 +563,19 @@ export function buzzBridge(options) {
503
563
  const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
504
564
  const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
505
565
  if (result.status === "waiting" && result.inputRequests.length > 0) {
566
+ const name = speaker.user.displayName?.trim();
567
+ const asker = { key: event.pubkey, ...(speaker.user.id ? { userId: speaker.user.id } : {}), ...(name ? { name } : {}) };
506
568
  sessions.set(from, channel, {
507
569
  id: result.sessionId,
508
570
  streamIndex: result.streamIndex,
509
571
  pendingInputRequests: result.inputRequests,
510
572
  speakerPublicKey: event.pubkey,
573
+ asker,
511
574
  });
512
- const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry: options.cards !== false }), event);
575
+ const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry, asker, askerOnly }), event);
513
576
  if (promptId) {
514
577
  relay.watchReplies([promptId]);
515
- const current = sessions.get(from, channel);
516
- if (current)
517
- sessions.set(from, channel, { ...current, promptEventIds: [promptId] });
578
+ rememberPrompt(from, channel, { eventId: promptId, requests: result.inputRequests, asker });
518
579
  }
519
580
  log(`posted ${result.inputRequests.length} input request(s) in ${channel.slice(0, 8)}`);
520
581
  startFollower(from, channel);
package/dist/cli.js CHANGED
@@ -231,6 +231,7 @@ function run() {
231
231
  ? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
232
232
  : {}),
233
233
  ...(process.env.BUZZ_CARDS === "off" ? { cards: false } : {}),
234
+ ...(process.env.BUZZ_ANSWERS === "anyone" ? { answers: "anyone" } : {}),
234
235
  ...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
235
236
  ? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
236
237
  : {}),
@@ -424,6 +425,7 @@ else {
424
425
  BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
425
426
  BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
426
427
  BUZZ_CARDS=off post replies without the cards tools handed back
428
+ BUZZ_ANSWERS=anyone let anyone in a channel answer a question (default: only who asked)
427
429
  BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the name and
428
430
  picture its owner set (default 900000ms, fifteen
429
431
  minutes; 0 turns it off)
package/dist/hitl.d.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
2
- import { type AskAnswer, type Card } from "@kybernesis/cards";
3
- import type { StoredSession } from "./sessions.js";
2
+ import { type AskAnswer, type AskSettled, type Card } from "@kybernesis/cards";
3
+ import type { StoredAsker, StoredPrompt, StoredSession } from "./sessions.js";
4
4
  import { type TurnFailure } from "./turn.js";
5
+ /** How a question is posted: who it is for, and which of its requests have ended. */
6
+ export interface PromptOptions {
7
+ /** Carry each question hidden after the words, for the plugin. */
8
+ carry?: boolean;
9
+ /** The person whose message raised it. */
10
+ asker?: StoredAsker;
11
+ /** Only the asker may answer it. */
12
+ askerOnly?: boolean;
13
+ /** Requests that have ended, by request id: shown as how they ended, with nothing to answer. */
14
+ settled?: Readonly<Record<string, AskSettled>>;
15
+ }
5
16
  /**
6
17
  * Render pending Eve input in a Buzz channel.
7
18
  *
@@ -11,10 +22,34 @@ import { type TurnFailure } from "./turn.js";
11
22
  * (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
12
23
  * Studio does and answer it with a press. Only a posted prompt carries them:
13
24
  * a press is accepted only as a reply to a prompt the bridge is watching.
25
+ *
26
+ * The same text, with `settled`, is what the prompt is edited to once a
27
+ * question ends: how it ended and who ended it, then what it asked, and the
28
+ * question hidden as `kyb:settled` so no client offers to answer it again.
14
29
  */
15
- export declare function formatInputRequests(requests: readonly InputRequest[], options?: {
30
+ export declare function formatInputRequests(requests: readonly InputRequest[], options?: PromptOptions): string;
31
+ /** What someone other than the asker hears when they try to answer. */
32
+ export declare function notTheAskerReply(asker: StoredAsker): string;
33
+ /** Whether `pubkey`, resolved to `userId`, is the person a question is for. */
34
+ export declare function isAsker(asker: StoredAsker, pubkey: string, userId?: string): boolean;
35
+ /** How each response settles its request, said by `by`. */
36
+ export declare function settlementsFor(responses: readonly InputResponse[], requests: readonly InputRequest[], by?: string, at?: number): Map<string, AskSettled>;
37
+ /**
38
+ * Prompts with `settlements` applied: each one that changed, with the text it
39
+ * is edited to, and the prompts still waiting on something afterwards.
40
+ */
41
+ export declare function applySettlements(prompts: readonly StoredPrompt[], settlements: ReadonlyMap<string, AskSettled>, options: {
16
42
  carry?: boolean;
17
- }): string;
43
+ askerOnly?: boolean;
44
+ }): {
45
+ edits: {
46
+ eventId: string;
47
+ content: string;
48
+ }[];
49
+ open: StoredPrompt[];
50
+ };
51
+ /** Every request in `prompts` that has not ended yet, settled the same way. */
52
+ export declare function settleRemaining(prompts: readonly StoredPrompt[], settled: AskSettled): Map<string, AskSettled>;
18
53
  /**
19
54
  * Answers a client pressed, checked against what is actually open.
20
55
  *
package/dist/hitl.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseInputResponses, resolveTextToResponses, } from "eve/client";
2
- import { askFrom, askText, cardsOf, hiddenAsk, newStepState, readStep } from "@kybernesis/cards";
2
+ import { askFrom, askText, cardsOf, hiddenAsk, hiddenSettled, newStepState, readStep, settledLine, settleWith, } from "@kybernesis/cards";
3
3
  import { failureFromEvent, silentTurnReply } from "./turn.js";
4
4
  /**
5
5
  * Render pending Eve input in a Buzz channel.
@@ -10,34 +10,113 @@ import { failureFromEvent, silentTurnReply } from "./turn.js";
10
10
  * (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
11
11
  * Studio does and answer it with a press. Only a posted prompt carries them:
12
12
  * a press is accepted only as a reply to a prompt the bridge is watching.
13
+ *
14
+ * The same text, with `settled`, is what the prompt is edited to once a
15
+ * question ends: how it ended and who ended it, then what it asked, and the
16
+ * question hidden as `kyb:settled` so no client offers to answer it again.
13
17
  */
14
18
  export function formatInputRequests(requests, options = {}) {
15
- const asks = requests.map((request) => askFrom(request));
19
+ const person = options.asker ? { key: options.asker.key, ...(options.asker.name ? { name: options.asker.name } : {}) } : undefined;
20
+ const only = Boolean(options.askerOnly && person);
21
+ const asks = requests.map((request) => ({
22
+ ...askFrom(request),
23
+ ...(person ? { asker: person } : {}),
24
+ ...(only ? { askerOnly: true } : {}),
25
+ }));
26
+ const settledOf = (ask) => options.settled?.[ask.requestId];
16
27
  const text = asks.map((ask, requestIndex) => {
17
28
  const request = requests[requestIndex];
18
29
  const prompt = askText(ask);
19
- const lines = [
20
- requests.length > 1 ? `Question ${requestIndex + 1}: ${prompt}` : prompt,
21
- ];
30
+ const numbered = (line) => (requests.length > 1 ? `Question ${requestIndex + 1}: ${line}` : line);
31
+ const settled = settledOf(ask);
32
+ if (settled)
33
+ return [numbered(settledLine({ ...ask, settled })), "", prompt].join("\n");
34
+ const lines = [numbered(prompt)];
22
35
  if (request.options?.length) {
23
36
  lines.push("", ...request.options.map((option, optionIndex) => {
24
37
  const description = option.description ? ` — ${option.description}` : "";
25
38
  return `${optionIndex + 1}. ${option.label} [${option.id}]${description}`;
26
39
  }));
27
40
  }
41
+ const who = only && person?.name ? `${person.name}, reply` : "Reply";
28
42
  if (request.allowFreeform === true || !request.options?.length) {
29
- lines.push("", "Reply to this message with an option number, option ID, exact label, or your own answer.");
43
+ lines.push("", `${who} to this message with an option number, option ID, exact label, or your own answer.`);
30
44
  }
31
45
  else {
32
- lines.push("", "Reply to this message with an option number, option ID, or exact label.");
46
+ lines.push("", `${who} to this message with an option number, option ID, or exact label.`);
33
47
  }
34
48
  return lines.join("\n");
35
49
  }).join("\n\n");
36
50
  if (!options.carry)
37
51
  return text;
38
- const hidden = asks.map((ask) => hiddenAsk(ask)).filter((h) => h !== null);
52
+ const hidden = asks
53
+ .map((ask) => {
54
+ const settled = settledOf(ask);
55
+ return settled ? hiddenSettled({ ...ask, settled }) : hiddenAsk(ask);
56
+ })
57
+ .filter((h) => h !== null);
39
58
  return hidden.length ? `${text}\n\n${hidden.join("\n")}` : text;
40
59
  }
60
+ /** What someone other than the asker hears when they try to answer. */
61
+ export function notTheAskerReply(asker) {
62
+ return `Only ${asker.name ?? "the person who asked"} can answer this one. I can take something new once it's answered.`;
63
+ }
64
+ /** Whether `pubkey`, resolved to `userId`, is the person a question is for. */
65
+ export function isAsker(asker, pubkey, userId) {
66
+ return asker.key === pubkey || Boolean(asker.userId && userId && asker.userId === userId);
67
+ }
68
+ /** How each response settles its request, said by `by`. */
69
+ export function settlementsFor(responses, requests, by, at = Date.now()) {
70
+ const open = new Map(requests.map((request) => [request.requestId, request]));
71
+ const out = new Map();
72
+ for (const response of responses) {
73
+ const request = open.get(response.requestId);
74
+ if (!request)
75
+ continue;
76
+ const answer = {
77
+ ...(response.optionId !== undefined ? { optionId: response.optionId } : {}),
78
+ ...(response.text !== undefined ? { text: response.text } : {}),
79
+ };
80
+ out.set(response.requestId, settleWith(askFrom(request), answer, by, at));
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * Prompts with `settlements` applied: each one that changed, with the text it
86
+ * is edited to, and the prompts still waiting on something afterwards.
87
+ */
88
+ export function applySettlements(prompts, settlements, options) {
89
+ const edits = [];
90
+ const open = [];
91
+ for (const prompt of prompts) {
92
+ const fresh = prompt.requests.filter((request) => settlements.has(request.requestId) && !prompt.settled?.[request.requestId]);
93
+ let next = prompt;
94
+ if (fresh.length) {
95
+ const settled = { ...prompt.settled };
96
+ for (const request of fresh)
97
+ settled[request.requestId] = settlements.get(request.requestId);
98
+ next = { ...prompt, settled };
99
+ edits.push({
100
+ eventId: prompt.eventId,
101
+ content: formatInputRequests(prompt.requests, { carry: options.carry, asker: prompt.asker, askerOnly: options.askerOnly, settled }),
102
+ });
103
+ }
104
+ if (next.requests.some((request) => !next.settled?.[request.requestId]))
105
+ open.push(next);
106
+ }
107
+ return { edits, open };
108
+ }
109
+ /** Every request in `prompts` that has not ended yet, settled the same way. */
110
+ export function settleRemaining(prompts, settled) {
111
+ const out = new Map();
112
+ for (const prompt of prompts) {
113
+ for (const request of prompt.requests) {
114
+ if (!prompt.settled?.[request.requestId])
115
+ out.set(request.requestId, settled);
116
+ }
117
+ }
118
+ return out;
119
+ }
41
120
  /**
42
121
  * Answers a client pressed, checked against what is actually open.
43
122
  *
package/dist/relay.d.ts CHANGED
@@ -15,6 +15,8 @@ import type { AgentKey } from "./keys.js";
15
15
  export declare const KIND_MESSAGE = 9;
16
16
  /** NIP-25 reaction. */
17
17
  export declare const KIND_REACTION = 7;
18
+ /** A replacement edit of a message, by its author only (the relay enforces it). */
19
+ export declare const KIND_EDIT = 40003;
18
20
  /** NIP-42 relay authentication. */
19
21
  export declare const KIND_AUTH = 22242;
20
22
  /** NIP-98 HTTP authentication. */
@@ -163,6 +165,13 @@ export declare class BuzzRelay {
163
165
  reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
164
166
  mention?: boolean;
165
167
  }): string | null;
168
+ /**
169
+ * Replace the words of a message this agent posted. Every client shows the
170
+ * latest edit by the author, and only the author's edits count, so nobody
171
+ * else can make a message of the agent's say something else. No mention
172
+ * tag: an edit is not news, so nobody is notified again.
173
+ */
174
+ edit(channel: string, eventId: string, text: string): string | null;
166
175
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
167
176
  private httpAuth;
168
177
  private submit;
package/dist/relay.js CHANGED
@@ -16,6 +16,8 @@ import { finalizeEvent } from "nostr-tools/pure";
16
16
  export const KIND_MESSAGE = 9;
17
17
  /** NIP-25 reaction. */
18
18
  export const KIND_REACTION = 7;
19
+ /** A replacement edit of a message, by its author only (the relay enforces it). */
20
+ export const KIND_EDIT = 40003;
19
21
  /** NIP-42 relay authentication. */
20
22
  export const KIND_AUTH = 22242;
21
23
  /** NIP-98 HTTP authentication. */
@@ -425,7 +427,7 @@ export class BuzzRelay {
425
427
  if (this.authenticated && this.send(["EVENT", signed]))
426
428
  return signed.id;
427
429
  // Held only between connections of a running relay; one never started or closed sends nothing.
428
- if (this.closed || !this.socket || (event.kind !== KIND_MESSAGE && event.kind !== KIND_REACTION))
430
+ if (this.closed || !this.socket || ![KIND_MESSAGE, KIND_REACTION, KIND_EDIT].includes(event.kind))
429
431
  return null;
430
432
  if (this.outbox.length >= MAX_OUTBOX)
431
433
  this.outbox.shift();
@@ -475,6 +477,15 @@ export class BuzzRelay {
475
477
  }
476
478
  return this.publish({ kind: KIND_MESSAGE, tags, content: text });
477
479
  }
480
+ /**
481
+ * Replace the words of a message this agent posted. Every client shows the
482
+ * latest edit by the author, and only the author's edits count, so nobody
483
+ * else can make a message of the agent's say something else. No mention
484
+ * tag: an edit is not news, so nobody is notified again.
485
+ */
486
+ edit(channel, eventId, text) {
487
+ return this.publish({ kind: KIND_EDIT, tags: [["h", channel], ["e", eventId]], content: text });
488
+ }
478
489
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
479
490
  httpAuth(url, body) {
480
491
  const auth = finalizeEvent({
@@ -1,4 +1,20 @@
1
+ import type { AskSettled } from "@kybernesis/cards";
1
2
  import type { InputRequest } from "eve/client";
3
+ /** The person whose message raised a question: their chat key, account and name. */
4
+ export interface StoredAsker {
5
+ key: string;
6
+ /** Their control-plane account, so another device of theirs counts as them. */
7
+ userId?: string;
8
+ name?: string;
9
+ }
10
+ /** A question the bridge posted, kept until every request in it has settled, so it can be rewritten. */
11
+ export interface StoredPrompt {
12
+ eventId: string;
13
+ requests: readonly InputRequest[];
14
+ asker?: StoredAsker;
15
+ /** Requests that have ended, by request id. */
16
+ settled?: Readonly<Record<string, AskSettled>>;
17
+ }
2
18
  /** One conversation: which eve session it is, and how far the bridge has read. */
3
19
  export interface StoredSession {
4
20
  id: string;
@@ -14,6 +30,10 @@ export interface StoredSession {
14
30
  * counts as an answer whether or not it mentions this agent.
15
31
  */
16
32
  promptEventIds?: readonly string[];
33
+ /** Who the open questions are for. Absent on conversations from before 0.15: anyone may answer those. */
34
+ asker?: StoredAsker;
35
+ /** The posted questions not yet fully settled, so the bridge can rewrite each one when it ends. */
36
+ prompts?: readonly StoredPrompt[];
17
37
  /** When it was last used, so a store that runs for years does not grow forever. */
18
38
  updated: number;
19
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.14.0",
3
+ "version": "0.15.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.5.0",
47
+ "@kybernesis/cards": "^0.6.0",
48
48
  "@kybernesis/enterprise": "^0.10.0",
49
49
  "nostr-tools": "^2.10.0",
50
50
  "zod": "^4"