@kybernesis/buzz 0.6.3 → 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 +90 -13
- package/dist/media.d.ts +48 -0
- package/dist/media.js +85 -0
- package/dist/sessions.d.ts +37 -0
- package/dist/sessions.js +80 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
|
+
import { fetchMedia, isImage, parseMedia } from "./media.js";
|
|
2
3
|
import { speakerCredentials } from "./credentials.js";
|
|
4
|
+
import { SessionStore } from "./sessions.js";
|
|
3
5
|
import { Client } from "eve/client";
|
|
4
6
|
import { BuzzRelay } from "./relay.js";
|
|
5
7
|
import { loadKey, npubEncode } from "./keys.js";
|
|
@@ -47,8 +49,16 @@ export function buzzBridge(options) {
|
|
|
47
49
|
* previous person's answer. The turn itself runs correctly, as the right
|
|
48
50
|
* person, against the right connections; only the text posted back is
|
|
49
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.
|
|
50
58
|
*/
|
|
51
|
-
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`);
|
|
52
62
|
/** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
|
|
53
63
|
const invited = new Map();
|
|
54
64
|
/**
|
|
@@ -103,11 +113,53 @@ export function buzzBridge(options) {
|
|
|
103
113
|
* nothing. The bridge knew the answer the whole time: it is the connection
|
|
104
114
|
* the message arrived on.
|
|
105
115
|
*/
|
|
106
|
-
|
|
116
|
+
/**
|
|
117
|
+
* One inbound message, as the parts a turn can carry.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* Returns a plain string when there is nothing but text, so the common case
|
|
121
|
+
* stays exactly as it was — a change in the shape of every ordinary turn is
|
|
122
|
+
* not worth paying for the rare one.
|
|
123
|
+
*
|
|
124
|
+
* A failed fetch does NOT throw. The person is owed an answer either way, and
|
|
125
|
+
* an agent told "there was an attachment I could not retrieve" can say so;
|
|
126
|
+
* an agent told nothing answers the caption and looks like it ignored the
|
|
127
|
+
* picture, which is the bug being fixed.
|
|
128
|
+
*/
|
|
129
|
+
async function composeMessage(text, attachments) {
|
|
130
|
+
if (attachments.length === 0)
|
|
131
|
+
return text;
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (text)
|
|
134
|
+
parts.push({ type: "text", text });
|
|
135
|
+
for (const ref of attachments) {
|
|
136
|
+
try {
|
|
137
|
+
const media = await fetchMedia(key, ref);
|
|
138
|
+
if (isImage(media.mediaType)) {
|
|
139
|
+
parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
parts.push({
|
|
143
|
+
type: "text",
|
|
144
|
+
text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
log(`could not fetch an attachment: ${error.message}`);
|
|
150
|
+
parts.push({
|
|
151
|
+
type: "text",
|
|
152
|
+
text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return parts;
|
|
157
|
+
}
|
|
158
|
+
async function ask(channel, message, pubkey, community) {
|
|
107
159
|
// Built per turn, for the person whose turn it is — and kept current for as
|
|
108
160
|
// long as that turn runs.
|
|
109
161
|
const client = clientFor(pubkey);
|
|
110
|
-
const existing = sessions.get(channel);
|
|
162
|
+
const existing = sessions.get(community, channel);
|
|
111
163
|
if (existing) {
|
|
112
164
|
try {
|
|
113
165
|
const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
|
|
@@ -138,29 +190,29 @@ export function buzzBridge(options) {
|
|
|
138
190
|
if (unread > 0) {
|
|
139
191
|
log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
|
|
140
192
|
}
|
|
141
|
-
const response = await session.send(
|
|
193
|
+
const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
|
|
142
194
|
const result = await response.result();
|
|
143
195
|
// Where this turn left the conversation, so the next one starts after it.
|
|
144
|
-
sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
196
|
+
sessions.set(community, channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
145
197
|
return result.message ?? "";
|
|
146
198
|
}
|
|
147
199
|
catch (error) {
|
|
148
200
|
// A session the agent no longer holds cannot be resumed. Starting a new one loses the
|
|
149
201
|
// thread but keeps the conversation alive, which is the better of the two failures.
|
|
150
202
|
log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
|
|
151
|
-
sessions.delete(channel);
|
|
203
|
+
sessions.delete(community, channel);
|
|
152
204
|
}
|
|
153
205
|
}
|
|
154
206
|
const created = await client.sessions.create({
|
|
155
|
-
message
|
|
207
|
+
message,
|
|
156
208
|
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
157
209
|
});
|
|
158
|
-
const
|
|
159
|
-
sessions.set(channel, {
|
|
210
|
+
const reply = (await created.response.result()).message ?? "";
|
|
211
|
+
sessions.set(community, channel, {
|
|
160
212
|
id: created.session.state.sessionId,
|
|
161
213
|
streamIndex: created.session.state.streamIndex,
|
|
162
214
|
});
|
|
163
|
-
return
|
|
215
|
+
return reply;
|
|
164
216
|
}
|
|
165
217
|
/**
|
|
166
218
|
* Send someone the link that makes them known, privately.
|
|
@@ -223,8 +275,21 @@ export function buzzBridge(options) {
|
|
|
223
275
|
if (!channel || !addressed)
|
|
224
276
|
return;
|
|
225
277
|
const text = String(event.content ?? "").trim();
|
|
226
|
-
|
|
278
|
+
const attachments = parseMedia(event);
|
|
279
|
+
/**
|
|
280
|
+
* Drop only a message with NOTHING in it — and say so when we do.
|
|
281
|
+
*
|
|
282
|
+
* This guard used to test the text alone, and sat above the log line, so an
|
|
283
|
+
* image sent without a caption was discarded before anything was written
|
|
284
|
+
* down. From the outside the agent had simply ignored someone; from the
|
|
285
|
+
* inside there was no record that a message had ever arrived. The reply
|
|
286
|
+
* path was hardened against exactly this failure — an empty answer now
|
|
287
|
+
* says so out loud — and the receiving path was left as it was.
|
|
288
|
+
*/
|
|
289
|
+
if (!text && attachments.length === 0) {
|
|
290
|
+
log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
|
|
227
291
|
return;
|
|
292
|
+
}
|
|
228
293
|
let speaker;
|
|
229
294
|
try {
|
|
230
295
|
speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
|
|
@@ -242,11 +307,23 @@ export function buzzBridge(options) {
|
|
|
242
307
|
await refuse(relay, event.pubkey, speaker.reason);
|
|
243
308
|
return;
|
|
244
309
|
}
|
|
245
|
-
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}`
|
|
310
|
+
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
|
|
311
|
+
(attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
|
|
312
|
+
/**
|
|
313
|
+
* Attachments become parts of the turn, or become words about themselves.
|
|
314
|
+
*
|
|
315
|
+
* An image the model can see goes in as bytes. Anything else — a zip, a
|
|
316
|
+
* PDF the model cannot read, a fetch that failed — goes in as a sentence
|
|
317
|
+
* saying so, because the alternative is an agent that answers the caption
|
|
318
|
+
* while the person waits for a reply about the file they sent. Being told
|
|
319
|
+
* "I got a file I cannot read" is a worse answer than the truth only if
|
|
320
|
+
* you never wanted the truth.
|
|
321
|
+
*/
|
|
322
|
+
const message = await composeMessage(text, attachments);
|
|
246
323
|
void relay.react(event.id, SEEN);
|
|
247
324
|
const stopTyping = relay.typingIn(channel);
|
|
248
325
|
try {
|
|
249
|
-
const reply = await serialize(channel, () => ask(channel,
|
|
326
|
+
const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
|
|
250
327
|
if (!reply) {
|
|
251
328
|
/**
|
|
252
329
|
* Silence is the worst answer available.
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AgentKey } from "./keys.js";
|
|
2
|
+
import type { NostrEvent } from "./relay.js";
|
|
3
|
+
/** One attachment on an inbound message. */
|
|
4
|
+
export interface MediaRef {
|
|
5
|
+
url: string;
|
|
6
|
+
/** From the `m` field when the client sends one; the response may correct it. */
|
|
7
|
+
mediaType?: string;
|
|
8
|
+
/** From the `x` field: the hash the blob is addressed by. */
|
|
9
|
+
sha256?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Fetched bytes, ready to become a message part. */
|
|
12
|
+
export interface FetchedMedia {
|
|
13
|
+
url: string;
|
|
14
|
+
mediaType: string;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The attachments on a message, from its `imeta` tags (NIP-92).
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* A Buzz client does NOT inline an attachment into the message text. The
|
|
22
|
+
* content stays prose — often prose *about* the picture — and the media lives
|
|
23
|
+
* in a tag. A bridge that reads only `event.content` therefore sees a caption
|
|
24
|
+
* with nothing attached, and, when there is no caption, sees nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* Both were reported from a live deployment: an image with a caption was
|
|
27
|
+
* answered as though the caption were the whole message, and an image without
|
|
28
|
+
* one produced no response and no log line. The agent could not tell either had
|
|
29
|
+
* happened, so it speculated about its own vision support — which was fine.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseMedia(event: NostrEvent): MediaRef[];
|
|
32
|
+
/**
|
|
33
|
+
* Fetch one attachment as the agent.
|
|
34
|
+
*
|
|
35
|
+
* @remarks
|
|
36
|
+
* Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
|
|
37
|
+
* URL cannot simply be handed to the model — a provider fetching it would be
|
|
38
|
+
* refused, and the failure would surface as a broken image rather than as an
|
|
39
|
+
* auth problem. The bytes are fetched here, with the same Blossom
|
|
40
|
+
* authorisation this package already signs to upload an agent's avatar, and
|
|
41
|
+
* passed inline.
|
|
42
|
+
*
|
|
43
|
+
* `t get` rather than `t upload`, and the blob's own hash when the sender told
|
|
44
|
+
* us one, so the authorisation is scoped to reading that specific blob.
|
|
45
|
+
*/
|
|
46
|
+
export declare function fetchMedia(key: AgentKey, ref: MediaRef, fetchImpl?: typeof fetch): Promise<FetchedMedia>;
|
|
47
|
+
/** Whether the model can be shown this directly, or only told about it. */
|
|
48
|
+
export declare function isImage(mediaType: string): boolean;
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { finalizeEvent } from "nostr-tools/pure";
|
|
3
|
+
/**
|
|
4
|
+
* The attachments on a message, from its `imeta` tags (NIP-92).
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A Buzz client does NOT inline an attachment into the message text. The
|
|
8
|
+
* content stays prose — often prose *about* the picture — and the media lives
|
|
9
|
+
* in a tag. A bridge that reads only `event.content` therefore sees a caption
|
|
10
|
+
* with nothing attached, and, when there is no caption, sees nothing at all.
|
|
11
|
+
*
|
|
12
|
+
* Both were reported from a live deployment: an image with a caption was
|
|
13
|
+
* answered as though the caption were the whole message, and an image without
|
|
14
|
+
* one produced no response and no log line. The agent could not tell either had
|
|
15
|
+
* happened, so it speculated about its own vision support — which was fine.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMedia(event) {
|
|
18
|
+
const media = [];
|
|
19
|
+
for (const tag of event.tags) {
|
|
20
|
+
if (tag[0] !== "imeta")
|
|
21
|
+
continue;
|
|
22
|
+
// An imeta tag is space-separated "key value" pairs, one per element.
|
|
23
|
+
const fields = new Map();
|
|
24
|
+
for (const entry of tag.slice(1)) {
|
|
25
|
+
const space = entry.indexOf(" ");
|
|
26
|
+
if (space > 0)
|
|
27
|
+
fields.set(entry.slice(0, space), entry.slice(space + 1));
|
|
28
|
+
}
|
|
29
|
+
const url = fields.get("url");
|
|
30
|
+
if (url)
|
|
31
|
+
media.push({ url, mediaType: fields.get("m"), sha256: fields.get("x") });
|
|
32
|
+
}
|
|
33
|
+
return media;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fetch one attachment as the agent.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Buzz media is authenticated: an anonymous GET answers 401, not 404. So the
|
|
40
|
+
* URL cannot simply be handed to the model — a provider fetching it would be
|
|
41
|
+
* refused, and the failure would surface as a broken image rather than as an
|
|
42
|
+
* auth problem. The bytes are fetched here, with the same Blossom
|
|
43
|
+
* authorisation this package already signs to upload an agent's avatar, and
|
|
44
|
+
* passed inline.
|
|
45
|
+
*
|
|
46
|
+
* `t get` rather than `t upload`, and the blob's own hash when the sender told
|
|
47
|
+
* us one, so the authorisation is scoped to reading that specific blob.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchMedia(key, ref, fetchImpl = fetch) {
|
|
50
|
+
const expires = Math.floor(Date.now() / 1000) + 300;
|
|
51
|
+
const auth = finalizeEvent({
|
|
52
|
+
kind: 24242,
|
|
53
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
54
|
+
tags: [
|
|
55
|
+
["t", "get"],
|
|
56
|
+
...(ref.sha256 ? [["x", ref.sha256]] : []),
|
|
57
|
+
["expiration", String(expires)],
|
|
58
|
+
],
|
|
59
|
+
content: "Fetch buzz-media",
|
|
60
|
+
}, key.secretKey);
|
|
61
|
+
const response = await fetchImpl(ref.url, {
|
|
62
|
+
headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64url")}` },
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`${response.status} fetching ${ref.url}`);
|
|
66
|
+
}
|
|
67
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
68
|
+
// The server's answer wins over the sender's claim: the sender's `m` field is
|
|
69
|
+
// whatever their client asserted, and a wrong media type reaches the model as
|
|
70
|
+
// an image it cannot decode.
|
|
71
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || ref.mediaType || "application/octet-stream";
|
|
72
|
+
if (ref.sha256) {
|
|
73
|
+
// Blossom addresses a blob BY its hash, so a mismatch means the bytes are
|
|
74
|
+
// not the ones that were sent. Cheap to check and not worth trusting past.
|
|
75
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
76
|
+
if (actual !== ref.sha256) {
|
|
77
|
+
throw new Error(`content hash mismatch for ${ref.url}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { url: ref.url, mediaType, bytes };
|
|
81
|
+
}
|
|
82
|
+
/** Whether the model can be shown this directly, or only told about it. */
|
|
83
|
+
export function isImage(mediaType) {
|
|
84
|
+
return mediaType.startsWith("image/");
|
|
85
|
+
}
|
|
@@ -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",
|