@kybernesis/buzz 0.9.6 → 0.9.8
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 +2 -0
- package/dist/bridge.js +70 -7
- package/dist/hitl.d.ts +20 -0
- package/dist/hitl.js +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/surface.d.ts +45 -0
- package/dist/surface.js +82 -0
- package/package.json +1 -1
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 (
|
|
261
|
-
|
|
262
|
-
|
|
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;
|
package/dist/surface.js
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|