@kybernesis/buzz 0.7.0 → 0.8.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/dist/bridge.js +14 -5
- package/dist/sessions.d.ts +37 -0
- package/dist/sessions.js +80 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
2
|
import { fetchMedia, isImage, parseMedia } from "./media.js";
|
|
3
3
|
import { speakerCredentials } from "./credentials.js";
|
|
4
|
+
import { SessionStore } from "./sessions.js";
|
|
4
5
|
import { Client } from "eve/client";
|
|
5
6
|
import { BuzzRelay } from "./relay.js";
|
|
6
7
|
import { loadKey, npubEncode } from "./keys.js";
|
|
@@ -48,8 +49,16 @@ export function buzzBridge(options) {
|
|
|
48
49
|
* previous person's answer. The turn itself runs correctly, as the right
|
|
49
50
|
* person, against the right connections; only the text posted back is
|
|
50
51
|
* somebody else's, which is the most convincing way to look broken.
|
|
52
|
+
*
|
|
53
|
+
* Kept on DISK, not in memory. The eve session is durable; the knowledge of
|
|
54
|
+
* which session belongs to a channel was not, so a restart orphaned every
|
|
55
|
+
* conversation — the next message opened a new session and the agent said it
|
|
56
|
+
* had no context, while the old session sat intact holding a reply nobody was
|
|
57
|
+
* left to read.
|
|
51
58
|
*/
|
|
52
|
-
const sessions = new
|
|
59
|
+
const sessions = new SessionStore(process.env.BUZZ_SESSIONS_FILE ?? ".buzz-sessions.json");
|
|
60
|
+
if (sessions.size > 0)
|
|
61
|
+
log(`resumed ${sessions.size} conversation(s) from the last run`);
|
|
53
62
|
/** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
|
|
54
63
|
const invited = new Map();
|
|
55
64
|
/**
|
|
@@ -150,7 +159,7 @@ export function buzzBridge(options) {
|
|
|
150
159
|
// Built per turn, for the person whose turn it is — and kept current for as
|
|
151
160
|
// long as that turn runs.
|
|
152
161
|
const client = clientFor(pubkey);
|
|
153
|
-
const existing = sessions.get(channel);
|
|
162
|
+
const existing = sessions.get(community, channel);
|
|
154
163
|
if (existing) {
|
|
155
164
|
try {
|
|
156
165
|
const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
|
|
@@ -184,14 +193,14 @@ export function buzzBridge(options) {
|
|
|
184
193
|
const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
|
|
185
194
|
const result = await response.result();
|
|
186
195
|
// Where this turn left the conversation, so the next one starts after it.
|
|
187
|
-
sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
196
|
+
sessions.set(community, channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
188
197
|
return result.message ?? "";
|
|
189
198
|
}
|
|
190
199
|
catch (error) {
|
|
191
200
|
// A session the agent no longer holds cannot be resumed. Starting a new one loses the
|
|
192
201
|
// thread but keeps the conversation alive, which is the better of the two failures.
|
|
193
202
|
log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
|
|
194
|
-
sessions.delete(channel);
|
|
203
|
+
sessions.delete(community, channel);
|
|
195
204
|
}
|
|
196
205
|
}
|
|
197
206
|
const created = await client.sessions.create({
|
|
@@ -199,7 +208,7 @@ export function buzzBridge(options) {
|
|
|
199
208
|
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
200
209
|
});
|
|
201
210
|
const reply = (await created.response.result()).message ?? "";
|
|
202
|
-
sessions.set(channel, {
|
|
211
|
+
sessions.set(community, channel, {
|
|
203
212
|
id: created.session.state.sessionId,
|
|
204
213
|
streamIndex: created.session.state.streamIndex,
|
|
205
214
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** One conversation: which eve session it is, and how far the bridge has read. */
|
|
2
|
+
export interface StoredSession {
|
|
3
|
+
id: string;
|
|
4
|
+
streamIndex: number;
|
|
5
|
+
/** When it was last used, so a store that runs for years does not grow forever. */
|
|
6
|
+
updated: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Which eve session belongs to which channel, across restarts.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* The eve session itself is durable — it lives on the agent's disk and survives
|
|
13
|
+
* anything. What did NOT survive was the bridge's knowledge of WHICH session
|
|
14
|
+
* belonged to a channel: that was a Map in memory, so a restart silently
|
|
15
|
+
* orphaned every conversation. The next message opened a new session and the
|
|
16
|
+
* agent answered "I have no context", while the old session sat on disk intact,
|
|
17
|
+
* holding a turn whose reply nobody was left to read.
|
|
18
|
+
*
|
|
19
|
+
* Reported from a live deployment after a restart mid-conversation, and the
|
|
20
|
+
* diagnosis there was exactly right: durable on one side, ephemeral on the
|
|
21
|
+
* other, and the join between them was the part that could not survive.
|
|
22
|
+
*
|
|
23
|
+
* Keyed by community AND channel: channel ids are issued per relay, so an agent
|
|
24
|
+
* in two workspaces cannot assume they never collide.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SessionStore {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(file: string);
|
|
29
|
+
static key(community: string, channel: string): string;
|
|
30
|
+
get(community: string, channel: string): StoredSession | undefined;
|
|
31
|
+
set(community: string, channel: string, session: {
|
|
32
|
+
id: string;
|
|
33
|
+
streamIndex: number;
|
|
34
|
+
}): void;
|
|
35
|
+
delete(community: string, channel: string): void;
|
|
36
|
+
get size(): number;
|
|
37
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
/** How long an untouched conversation is kept before it is forgotten. */
|
|
4
|
+
const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Which eve session belongs to which channel, across restarts.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The eve session itself is durable — it lives on the agent's disk and survives
|
|
10
|
+
* anything. What did NOT survive was the bridge's knowledge of WHICH session
|
|
11
|
+
* belonged to a channel: that was a Map in memory, so a restart silently
|
|
12
|
+
* orphaned every conversation. The next message opened a new session and the
|
|
13
|
+
* agent answered "I have no context", while the old session sat on disk intact,
|
|
14
|
+
* holding a turn whose reply nobody was left to read.
|
|
15
|
+
*
|
|
16
|
+
* Reported from a live deployment after a restart mid-conversation, and the
|
|
17
|
+
* diagnosis there was exactly right: durable on one side, ephemeral on the
|
|
18
|
+
* other, and the join between them was the part that could not survive.
|
|
19
|
+
*
|
|
20
|
+
* Keyed by community AND channel: channel ids are issued per relay, so an agent
|
|
21
|
+
* in two workspaces cannot assume they never collide.
|
|
22
|
+
*/
|
|
23
|
+
export class SessionStore {
|
|
24
|
+
#file;
|
|
25
|
+
#entries = new Map();
|
|
26
|
+
constructor(file) {
|
|
27
|
+
this.#file = file;
|
|
28
|
+
this.#load();
|
|
29
|
+
}
|
|
30
|
+
static key(community, channel) {
|
|
31
|
+
return `${community}|${channel}`;
|
|
32
|
+
}
|
|
33
|
+
get(community, channel) {
|
|
34
|
+
return this.#entries.get(SessionStore.key(community, channel));
|
|
35
|
+
}
|
|
36
|
+
set(community, channel, session) {
|
|
37
|
+
this.#entries.set(SessionStore.key(community, channel), { ...session, updated: Date.now() });
|
|
38
|
+
this.#save();
|
|
39
|
+
}
|
|
40
|
+
delete(community, channel) {
|
|
41
|
+
this.#entries.delete(SessionStore.key(community, channel));
|
|
42
|
+
this.#save();
|
|
43
|
+
}
|
|
44
|
+
get size() {
|
|
45
|
+
return this.#entries.size;
|
|
46
|
+
}
|
|
47
|
+
#load() {
|
|
48
|
+
if (!existsSync(this.#file))
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(this.#file, "utf8"));
|
|
52
|
+
const cutoff = Date.now() - KEEP_MS;
|
|
53
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
54
|
+
if (typeof value?.id === "string" && (value.updated ?? 0) > cutoff) {
|
|
55
|
+
this.#entries.set(key, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A corrupt store is not worth refusing to start over. Losing the mapping
|
|
61
|
+
// costs conversation history; refusing to start costs the whole agent.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
#save() {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(this.#file), { recursive: true });
|
|
67
|
+
// Written beside the target and renamed, because the failure being
|
|
68
|
+
// guarded against is a restart — and a restart during a plain write
|
|
69
|
+
// leaves a truncated file that reads as "no sessions at all", which is
|
|
70
|
+
// the exact bug this class exists to prevent.
|
|
71
|
+
const tmp = `${this.#file}.tmp`;
|
|
72
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.#entries)), { mode: 0o600 });
|
|
73
|
+
renameSync(tmp, this.#file);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Best effort: an agent that cannot write its store still answers, it
|
|
77
|
+
// just forgets across restarts — which is where this started.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|