@kybernesis/buzz 0.9.6 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.d.ts CHANGED
@@ -31,6 +31,8 @@ export declare function buzzBridge(options: BuzzBridgeOptions): {
31
31
  pubkey: string;
32
32
  /** The communities this agent is a member of. */
33
33
  relays: string[];
34
+ /** Where this bridge declares itself for the management routes to read. */
35
+ surfaceFile: string;
34
36
  start(): void;
35
37
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
36
38
  stop(): void;
package/dist/bridge.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { channelIdentity } from "@kybernesis/enterprise";
2
2
  import { fetchMedia, parseMedia } from "./media.js";
3
3
  import { speakerCredentials } from "./credentials.js";
4
+ import { surfaceWriter } from "./surface.js";
4
5
  import { SessionStore } from "./sessions.js";
5
6
  import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
6
- import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
7
+ import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, resumeStalledReply, } from "./hitl.js";
7
8
  import { existsSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
9
10
  import { Client } from "eve/client";
@@ -178,6 +179,14 @@ export function buzzBridge(options) {
178
179
  /** One abortable durable-stream follower owns each parked conversation. */
179
180
  const followers = new Map();
180
181
  const followerRestartTimers = new Map();
182
+ /**
183
+ * Why a follower keeps reattaching, per conversation.
184
+ *
185
+ * `index` is the durable cursor it last reattached at. The cursor moving is
186
+ * the only honest evidence that anything is happening: KYB-545's spin held a
187
+ * frozen index across 25 minutes of retries while the room was told nothing.
188
+ */
189
+ const followerRetries = new Map();
181
190
  let stopped = false;
182
191
  function clearFollowerRestart(conversation) {
183
192
  const timer = followerRestartTimers.get(conversation);
@@ -188,6 +197,7 @@ export function buzzBridge(options) {
188
197
  function stopFollower(community, channel) {
189
198
  const conversation = SessionStore.key(community, channel);
190
199
  clearFollowerRestart(conversation);
200
+ followerRetries.delete(conversation);
191
201
  followers.get(conversation)?.abort();
192
202
  followers.delete(conversation);
193
203
  }
@@ -257,13 +267,42 @@ export function buzzBridge(options) {
257
267
  if (followers.get(conversation) === controller)
258
268
  followers.delete(conversation);
259
269
  const recoverable = sessions.get(community, channel);
260
- if (!controller.signal.aborted && recoverable && needsPendingFollower(recoverable)) {
261
- const timer = setTimeout(() => {
262
- followerRestartTimers.delete(conversation);
263
- startFollower(community, channel);
264
- }, 1_000);
265
- followerRestartTimers.set(conversation, timer);
270
+ if (controller.signal.aborted || !recoverable || !needsPendingFollower(recoverable)) {
271
+ followerRetries.delete(conversation);
272
+ return;
266
273
  }
274
+ const previous = followerRetries.get(conversation);
275
+ const progressed = previous === undefined || recoverable.streamIndex > previous.index;
276
+ const retry = progressed
277
+ ? { cycles: 0, index: recoverable.streamIndex, since: Date.now() }
278
+ : { ...previous, cycles: previous.cycles + 1 };
279
+ followerRetries.set(conversation, retry);
280
+ /**
281
+ * A resumed turn is the agent working, so it answers to the same ceiling
282
+ * a live turn does. A question nobody has answered yet is not: a person
283
+ * may reply tomorrow, and waiting for them is the entire point. Only the
284
+ * first case can be a stall, and only it gives up.
285
+ */
286
+ if (recoverable.resumeInFlight && Date.now() - retry.since >= agentWorkTimeoutMs) {
287
+ followerRetries.delete(conversation);
288
+ void serialize(community, channel, async () => {
289
+ const current = sessions.get(community, channel);
290
+ if (!current || !needsPendingFollower(current))
291
+ return;
292
+ if (current.promptEventIds?.length)
293
+ relay.unwatchReplies(current.promptEventIds);
294
+ const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, ...cleared } = current;
295
+ sessions.set(community, channel, cleared);
296
+ relay.reply(channel, resumeStalledReply(agentWorkTimeoutMs));
297
+ log(`gave up on the resumed turn in ${channel.slice(0, 8)}: no progress past index ${retry.index}`);
298
+ });
299
+ return;
300
+ }
301
+ const timer = setTimeout(() => {
302
+ followerRestartTimers.delete(conversation);
303
+ startFollower(community, channel);
304
+ }, followerRetryDelayMs(retry.cycles));
305
+ followerRestartTimers.set(conversation, timer);
267
306
  });
268
307
  }
269
308
  async function handle(event, from) {
@@ -328,6 +367,24 @@ export function buzzBridge(options) {
328
367
  });
329
368
  await respondToPendingConversation(session, responses, from, channel);
330
369
  log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
370
+ /**
371
+ * The answer is in, so this conversation is no longer waiting on a
372
+ * person. It is waiting on the agent, and that is a thing the bridge
373
+ * may time out.
374
+ *
375
+ * Recording it here rather than leaving it to the follower's
376
+ * `input.resolved` is what makes a stalled resume detectable at all.
377
+ * In KYB-545 the follower could not reach the agent, so it never saw
378
+ * that event, and the store still read "one question pending" an hour
379
+ * after the person had answered. Nothing may ever time out a wait for
380
+ * a human, so while that flag was set the room could only be told
381
+ * nothing.
382
+ */
383
+ const answered = sessions.get(from, channel);
384
+ if (answered?.pendingInputRequests?.length) {
385
+ const { pendingInputRequests: _resolved, ...rest } = answered;
386
+ sessions.set(from, channel, { ...rest, resumeInFlight: true });
387
+ }
331
388
  return;
332
389
  }
333
390
  if (pending?.resumeInFlight) {
@@ -401,14 +458,19 @@ export function buzzBridge(options) {
401
458
  stopTyping();
402
459
  }
403
460
  }
461
+ // What this bridge is, for the consoles: see surface.ts.
462
+ const surface = surfaceWriter({ relays: urls, npub: key.npub, conversations: () => sessions.size }, log);
404
463
  return {
405
464
  /** The key the workspace has to invite for any of this to happen. */
406
465
  npub: key.npub,
407
466
  pubkey: key.publicKey,
408
467
  /** The communities this agent is a member of. */
409
468
  relays: urls,
469
+ /** Where this bridge declares itself for the management routes to read. */
470
+ surfaceFile: surface.file,
410
471
  start() {
411
472
  stopped = false;
473
+ surface.start();
412
474
  for (const relay of relays.values())
413
475
  relay.connect();
414
476
  for (const { community, channel, session } of sessions.entries()) {
@@ -426,6 +488,7 @@ export function buzzBridge(options) {
426
488
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
427
489
  stop() {
428
490
  stopped = true;
491
+ surface.stop();
429
492
  for (const controller of followers.values())
430
493
  controller.abort();
431
494
  followers.clear();
package/dist/hitl.d.ts CHANGED
@@ -8,6 +8,26 @@ export declare function resolveInputReply(text: string, requests: readonly Input
8
8
  export declare function invalidInputReply(requests: readonly InputRequest[]): string;
9
9
  /** Submit a matched reply without consuming its response stream. */
10
10
  export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[], community: string, channel: string): Promise<void>;
11
+ /**
12
+ * How long a follower waits between reattaching when it saw no progress.
13
+ *
14
+ * @remarks
15
+ * The follower used to reattach on a flat one-second timer. With eve's client
16
+ * spending about 100 s on an idle stream before giving up, that produced the
17
+ * fixed ~110 s cycle observed in KYB-545: forever, for as long as the process
18
+ * lived. Backing off turns a spin into a wait, and the cap keeps a genuinely
19
+ * slow resume from being abandoned for minutes at a time.
20
+ */
21
+ export declare function followerRetryDelayMs(cycles: number, capMs?: number): number;
22
+ /**
23
+ * What the room is told when a resumed turn produced nothing for too long.
24
+ *
25
+ * @remarks
26
+ * KYB-529's rule applied to the resumed phase: the person committed attention
27
+ * to answering a question, so silence is the one outcome they must not get.
28
+ * Say the answer arrived, say the work did not, and say the channel is usable.
29
+ */
30
+ export declare function resumeStalledReply(stallMs: number): string;
11
31
  /** A session needs its follower while asking or while resumed output is unpublished. */
12
32
  export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
13
33
  export interface PendingFollowerCallbacks {
package/dist/hitl.js CHANGED
@@ -38,6 +38,34 @@ export async function respondToPendingConversation(session, responses, community
38
38
  streamReconnectPolicy: { reconnect: false },
39
39
  });
40
40
  }
41
+ /**
42
+ * How long a follower waits between reattaching when it saw no progress.
43
+ *
44
+ * @remarks
45
+ * The follower used to reattach on a flat one-second timer. With eve's client
46
+ * spending about 100 s on an idle stream before giving up, that produced the
47
+ * fixed ~110 s cycle observed in KYB-545: forever, for as long as the process
48
+ * lived. Backing off turns a spin into a wait, and the cap keeps a genuinely
49
+ * slow resume from being abandoned for minutes at a time.
50
+ */
51
+ export function followerRetryDelayMs(cycles, capMs = 60_000) {
52
+ const safeCycles = Number.isFinite(cycles) ? Math.max(0, Math.trunc(cycles)) : 0;
53
+ return Math.min(1_000 * 2 ** Math.min(safeCycles, 30), capMs);
54
+ }
55
+ /**
56
+ * What the room is told when a resumed turn produced nothing for too long.
57
+ *
58
+ * @remarks
59
+ * KYB-529's rule applied to the resumed phase: the person committed attention
60
+ * to answering a question, so silence is the one outcome they must not get.
61
+ * Say the answer arrived, say the work did not, and say the channel is usable.
62
+ */
63
+ export function resumeStalledReply(stallMs) {
64
+ const minutes = Math.max(1, Math.round(stallMs / 60_000));
65
+ return (`Your answer reached me, but the turn it restarted then went quiet for over ${minutes} minute` +
66
+ `${minutes === 1 ? "" : "s"} without producing anything, so I have stopped waiting on it. ` +
67
+ `Nothing was posted and no work landed from it. This channel is clear — ask me again and I will start fresh.`);
68
+ }
41
69
  /** A session needs its follower while asking or while resumed output is unpublished. */
42
70
  export function needsPendingFollower(session) {
43
71
  return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export { BuzzRelay, type NostrEvent, type PresenceStatus, type RelayOptions, KIN
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode, type AgentKey } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
5
  export type { Profile } from "./profile.js";
6
+ export { readSurfaceManifest, surfaceWriter, surfacesDir, type SurfaceManifest } from "./surface.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { buzzBridge } from "./bridge.js";
2
2
  export { BuzzRelay, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
+ export { readSurfaceManifest, surfaceWriter, surfacesDir } from "./surface.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The bridge, declaring itself.
3
+ *
4
+ * eve's `/eve/v1/info` lists the channels an agent authored; a workspace bridge
5
+ * is a separate process the agent knows nothing about, so every console showed
6
+ * an agent that was plainly answering in a workspace as reachable "only through
7
+ * this app". Rather than have a console guess from process names, the bridge
8
+ * writes one small file saying what it is connected to and touches it every
9
+ * minute. Anything that reads the file — the agent's management routes, and
10
+ * through them the desktop and the phone — can tell a live bridge from a stale
11
+ * file by the heartbeat alone.
12
+ */
13
+ export declare const HEARTBEAT_MS = 60000;
14
+ /** Where every surface on this host writes its manifest; the management routes read the same directory. */
15
+ export declare function surfacesDir(): string;
16
+ export interface SurfaceManifest {
17
+ kind: "buzz";
18
+ name: string;
19
+ /** The workspaces this bridge is connected to. */
20
+ relays: string[];
21
+ /** The key the workspace invited. */
22
+ npub: string;
23
+ /** Channels with a live conversation. */
24
+ conversations: number;
25
+ pid: number;
26
+ startedAt: string;
27
+ heartbeatAt: string;
28
+ }
29
+ export interface SurfaceWriter {
30
+ /** Write the manifest now and keep its heartbeat fresh until `stop()`. */
31
+ start(): void;
32
+ /** Refresh what the manifest says, without waiting for the next beat. */
33
+ update(patch: Partial<Pick<SurfaceManifest, "conversations">>): void;
34
+ /** Remove the manifest: a stopped bridge should not look like a stale one. */
35
+ stop(): void;
36
+ readonly file: string;
37
+ }
38
+ export declare function surfaceWriter(input: {
39
+ relays: string[];
40
+ npub: string;
41
+ conversations(): number;
42
+ dir?: string;
43
+ }, onError?: (message: string) => void): SurfaceWriter;
44
+ /** Read one manifest back; null when it is not one. */
45
+ export declare function readSurfaceManifest(file: string): SurfaceManifest | null;
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The bridge, declaring itself.
6
+ *
7
+ * eve's `/eve/v1/info` lists the channels an agent authored; a workspace bridge
8
+ * is a separate process the agent knows nothing about, so every console showed
9
+ * an agent that was plainly answering in a workspace as reachable "only through
10
+ * this app". Rather than have a console guess from process names, the bridge
11
+ * writes one small file saying what it is connected to and touches it every
12
+ * minute. Anything that reads the file — the agent's management routes, and
13
+ * through them the desktop and the phone — can tell a live bridge from a stale
14
+ * file by the heartbeat alone.
15
+ */
16
+ export const HEARTBEAT_MS = 60_000;
17
+ /** Where every surface on this host writes its manifest; the management routes read the same directory. */
18
+ export function surfacesDir() {
19
+ return process.env.KYB_SURFACES_DIR ?? join(homedir(), ".kybernesis", "surfaces");
20
+ }
21
+ export function surfaceWriter(input, onError = () => undefined) {
22
+ const dir = input.dir ?? surfacesDir();
23
+ // One file per connection set, so two bridges on one host for different workspaces do not overwrite each other.
24
+ const file = join(dir, `buzz-${input.npub.slice(-8)}.json`);
25
+ const startedAt = new Date().toISOString();
26
+ let timer;
27
+ const write = () => {
28
+ const manifest = {
29
+ kind: "buzz",
30
+ name: "Buzz",
31
+ relays: input.relays,
32
+ npub: input.npub,
33
+ conversations: input.conversations(),
34
+ pid: process.pid,
35
+ startedAt,
36
+ heartbeatAt: new Date().toISOString(),
37
+ };
38
+ try {
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
41
+ }
42
+ catch (error) {
43
+ // A manifest that cannot be written costs a row on a console, not a conversation.
44
+ onError(`could not write surface manifest ${file}: ${error.message}`);
45
+ }
46
+ };
47
+ return {
48
+ file,
49
+ start() {
50
+ write();
51
+ timer = setInterval(write, HEARTBEAT_MS);
52
+ timer.unref?.();
53
+ },
54
+ update() {
55
+ write();
56
+ },
57
+ stop() {
58
+ if (timer)
59
+ clearInterval(timer);
60
+ timer = undefined;
61
+ try {
62
+ if (existsSync(file))
63
+ unlinkSync(file);
64
+ }
65
+ catch {
66
+ /* a stale file is told apart by its heartbeat anyway */
67
+ }
68
+ },
69
+ };
70
+ }
71
+ /** Read one manifest back; null when it is not one. */
72
+ export function readSurfaceManifest(file) {
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
75
+ if (parsed.kind !== "buzz" || !Array.isArray(parsed.relays) || typeof parsed.heartbeatAt !== "string")
76
+ return null;
77
+ return parsed;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
package/dist/turn.d.ts CHANGED
@@ -101,5 +101,15 @@ export declare function answerTurn(client: Client, sessions: SessionStore, chann
101
101
  */
102
102
  export declare function isSessionGone(error: unknown): boolean;
103
103
  export declare function rejectedTurnReply(error: unknown): string | null;
104
- /** Give the originating channel one accurate recovery message for bridge-owned silence. */
104
+ /**
105
+ * Give the originating channel one accurate recovery message for bridge-owned silence.
106
+ *
107
+ * Two different failures wear the same timeout. Before the send, a stalled
108
+ * unread drain means the message never reached the agent: "try again here" is
109
+ * actively wrong, because there is nothing to resume and the agent will answer
110
+ * whatever it last genuinely received — a confident reply to a question nobody
111
+ * asked. After the send, the message did reach the agent and the conversation
112
+ * is kept, so trying again is the right advice. The phase on the error tells
113
+ * the two apart.
114
+ */
105
115
  export declare function agentSilenceReply(error: unknown): string | null;
package/dist/turn.js CHANGED
@@ -17,6 +17,16 @@ export const DEFAULT_AGENT_SILENCE_TIMEOUT_MS = 5 * 60_000;
17
17
  */
18
18
  export const DEFAULT_AGENT_WORK_TIMEOUT_MS = 60 * 60_000;
19
19
  const isWorkPhase = (phase) => phase.endsWith("response stream");
20
+ /**
21
+ * A phase that runs BEFORE the message is sent to the agent.
22
+ *
23
+ * The unread drain runs before the send, so a timeout here means the message
24
+ * never reached the agent and no run was created. Every other phase — the
25
+ * send/create acknowledgement and the response stream — is after the POST that
26
+ * delivers it, so by then the agent has the message. This is the difference
27
+ * between a lost message and a lost turn, and the recovery text turns on it.
28
+ */
29
+ const isPreSendPhase = (phase) => phase === "unread drain";
20
30
  /** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
21
31
  export class AgentSilenceTimeoutError extends Error {
22
32
  phase;
@@ -405,9 +415,22 @@ export function rejectedTurnReply(error) {
405
415
  ? `I couldn't read that message (${detail}). Try sending it again, or as plain text.`
406
416
  : "I couldn't read that message. Try sending it again, or as plain text.";
407
417
  }
408
- /** Give the originating channel one accurate recovery message for bridge-owned silence. */
418
+ /**
419
+ * Give the originating channel one accurate recovery message for bridge-owned silence.
420
+ *
421
+ * Two different failures wear the same timeout. Before the send, a stalled
422
+ * unread drain means the message never reached the agent: "try again here" is
423
+ * actively wrong, because there is nothing to resume and the agent will answer
424
+ * whatever it last genuinely received — a confident reply to a question nobody
425
+ * asked. After the send, the message did reach the agent and the conversation
426
+ * is kept, so trying again is the right advice. The phase on the error tells
427
+ * the two apart.
428
+ */
409
429
  export function agentSilenceReply(error) {
410
430
  if (!(error instanceof AgentSilenceTimeoutError))
411
431
  return null;
432
+ if (isPreSendPhase(error.phase)) {
433
+ return "I couldn't get your message to the agent before it went quiet, so it was not delivered — nothing was sent and no answer is coming. Please send it again.";
434
+ }
412
435
  return "The agent was silent for too long, so I stopped waiting. I kept this conversation, and you can try again here.";
413
436
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.9.6",
3
+ "version": "0.9.9",
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",