@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 +12 -1
- package/dist/bridge.d.ts +9 -0
- package/dist/bridge.js +77 -16
- package/dist/cli.js +2 -0
- package/dist/hitl.d.ts +39 -4
- package/dist/hitl.js +87 -8
- package/dist/relay.d.ts +9 -0
- package/dist/relay.js +12 -1
- package/dist/sessions.d.ts +20 -0
- package/package.json +2 -2
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.
|
|
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, { ...
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
575
|
+
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry, asker, askerOnly }), event);
|
|
513
576
|
if (promptId) {
|
|
514
577
|
relay.watchReplies([promptId]);
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
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("",
|
|
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("",
|
|
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
|
|
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 ||
|
|
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({
|
package/dist/sessions.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
47
|
+
"@kybernesis/cards": "^0.6.0",
|
|
48
48
|
"@kybernesis/enterprise": "^0.10.0",
|
|
49
49
|
"nostr-tools": "^2.10.0",
|
|
50
50
|
"zod": "^4"
|