@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 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 Map();
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
+ }
@@ -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.7.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",