@sesamespace/sesame 0.2.3 → 0.2.5
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/index.js +280 -62
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -12,10 +12,25 @@
|
|
|
12
12
|
* That's it. The plugin handles WebSocket connection, authentication,
|
|
13
13
|
* heartbeats, reconnection, and message routing automatically.
|
|
14
14
|
*/
|
|
15
|
+
// Plugin version (injected from package.json at build time, fallback to static)
|
|
16
|
+
const PLUGIN_VERSION = "0.2.5";
|
|
17
|
+
/** Standard headers for Sesame API calls */
|
|
18
|
+
function sesameHeaders(apiKey, json = true) {
|
|
19
|
+
const h = {
|
|
20
|
+
Authorization: `Bearer ${apiKey}`,
|
|
21
|
+
"X-Sesame-Plugin-Version": PLUGIN_VERSION,
|
|
22
|
+
};
|
|
23
|
+
if (json)
|
|
24
|
+
h["Content-Type"] = "application/json";
|
|
25
|
+
return h;
|
|
26
|
+
}
|
|
15
27
|
// Runtime state
|
|
16
28
|
let pluginRuntime = null;
|
|
17
29
|
// Connection state per account
|
|
18
30
|
const connections = new Map();
|
|
31
|
+
// Streaming state: tracks channels where streaming already delivered the message
|
|
32
|
+
// Key: channelId, Value: { messageId, text } — outbound.sendText should edit instead of send
|
|
33
|
+
const streamDelivered = new Map();
|
|
19
34
|
function getLogger() {
|
|
20
35
|
return (pluginRuntime?.logging?.getChildLogger?.({ plugin: "sesame" }) ?? console);
|
|
21
36
|
}
|
|
@@ -91,7 +106,7 @@ const sesameChannelPlugin = {
|
|
|
91
106
|
probeAccount: async ({ account, timeoutMs }) => {
|
|
92
107
|
try {
|
|
93
108
|
const response = await fetch(`${account.apiUrl}/api/v1/agents/me/manifest`, {
|
|
94
|
-
headers:
|
|
109
|
+
headers: sesameHeaders(account.apiKey, false),
|
|
95
110
|
signal: AbortSignal.timeout(timeoutMs ?? 5000),
|
|
96
111
|
});
|
|
97
112
|
if (response.ok) {
|
|
@@ -146,12 +161,30 @@ const sesameChannelPlugin = {
|
|
|
146
161
|
if (!account?.apiKey)
|
|
147
162
|
throw new Error("Sesame not configured");
|
|
148
163
|
const channelId = to?.startsWith("sesame:") ? to.slice(7) : to;
|
|
164
|
+
const log = getLogger();
|
|
165
|
+
// Check if streaming already delivered this message
|
|
166
|
+
const delivered = streamDelivered.get(channelId);
|
|
167
|
+
if (delivered) {
|
|
168
|
+
streamDelivered.delete(channelId);
|
|
169
|
+
// Do a final edit to ensure the complete text is correct, then return
|
|
170
|
+
log.info?.(`[sesame] outbound.sendText: streaming already sent to ${channelId}, doing final edit`);
|
|
171
|
+
const editRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${delivered.messageId}`, {
|
|
172
|
+
method: "PATCH",
|
|
173
|
+
headers: sesameHeaders(account.apiKey),
|
|
174
|
+
body: JSON.stringify({ content: text }),
|
|
175
|
+
});
|
|
176
|
+
if (!editRes.ok) {
|
|
177
|
+
log.warn?.(`[sesame] Final stream edit in sendText failed (${editRes.status})`);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
channel: "sesame",
|
|
181
|
+
messageId: delivered.messageId,
|
|
182
|
+
chatId: channelId,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
149
185
|
const response = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
150
186
|
method: "POST",
|
|
151
|
-
headers:
|
|
152
|
-
"Content-Type": "application/json",
|
|
153
|
-
Authorization: `Bearer ${account.apiKey}`,
|
|
154
|
-
},
|
|
187
|
+
headers: sesameHeaders(account.apiKey),
|
|
155
188
|
body: JSON.stringify({ content: text, kind: "text", intent: "chat" }),
|
|
156
189
|
});
|
|
157
190
|
if (!response.ok) {
|
|
@@ -207,10 +240,7 @@ const sesameChannelPlugin = {
|
|
|
207
240
|
// 1. Get presigned upload URL from Sesame Drive
|
|
208
241
|
const uploadRes = await fetch(`${account.apiUrl}/api/v1/drive/files/upload-url`, {
|
|
209
242
|
method: "POST",
|
|
210
|
-
headers:
|
|
211
|
-
"Content-Type": "application/json",
|
|
212
|
-
Authorization: `Bearer ${account.apiKey}`,
|
|
213
|
-
},
|
|
243
|
+
headers: sesameHeaders(account.apiKey),
|
|
214
244
|
body: JSON.stringify({ fileName, contentType, size: fileBuffer.length }),
|
|
215
245
|
});
|
|
216
246
|
if (!uploadRes.ok)
|
|
@@ -228,10 +258,7 @@ const sesameChannelPlugin = {
|
|
|
228
258
|
// 3. Register file in Drive
|
|
229
259
|
const regRes = await fetch(`${account.apiUrl}/api/v1/drive/files`, {
|
|
230
260
|
method: "POST",
|
|
231
|
-
headers:
|
|
232
|
-
"Content-Type": "application/json",
|
|
233
|
-
Authorization: `Bearer ${account.apiKey}`,
|
|
234
|
-
},
|
|
261
|
+
headers: sesameHeaders(account.apiKey),
|
|
235
262
|
body: JSON.stringify({ fileId, s3Key, fileName, contentType, size: fileBuffer.length }),
|
|
236
263
|
});
|
|
237
264
|
if (!regRes.ok)
|
|
@@ -239,10 +266,7 @@ const sesameChannelPlugin = {
|
|
|
239
266
|
// 4. Send message with attachment
|
|
240
267
|
const msgRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
241
268
|
method: "POST",
|
|
242
|
-
headers:
|
|
243
|
-
"Content-Type": "application/json",
|
|
244
|
-
Authorization: `Bearer ${account.apiKey}`,
|
|
245
|
-
},
|
|
269
|
+
headers: sesameHeaders(account.apiKey),
|
|
246
270
|
body: JSON.stringify({
|
|
247
271
|
content: caption || fileName,
|
|
248
272
|
kind: "text",
|
|
@@ -304,6 +328,90 @@ const sesameChannelPlugin = {
|
|
|
304
328
|
},
|
|
305
329
|
},
|
|
306
330
|
};
|
|
331
|
+
// ── Wake Context (cold start optimization) ──
|
|
332
|
+
async function fetchWakeContext(account, agentId, ctx) {
|
|
333
|
+
const log = getLogger();
|
|
334
|
+
try {
|
|
335
|
+
const response = await fetch(`${account.apiUrl}/api/v1/agents/${agentId}/wake`, { headers: sesameHeaders(account.apiKey, false) });
|
|
336
|
+
if (!response.ok) {
|
|
337
|
+
log.warn?.(`[sesame] Wake endpoint returned ${response.status}`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const result = await response.json();
|
|
341
|
+
if (!result.ok)
|
|
342
|
+
return;
|
|
343
|
+
const data = result.data;
|
|
344
|
+
const lines = ["[Sesame Platform Context]"];
|
|
345
|
+
// Focused task
|
|
346
|
+
if (data.focusedTask) {
|
|
347
|
+
const t = data.focusedTask;
|
|
348
|
+
lines.push(`\nFocused Task: T-${t.taskNumber} — ${t.title} (${t.status})`);
|
|
349
|
+
if (t.context?.notes)
|
|
350
|
+
lines.push(`Notes: ${t.context.notes}`);
|
|
351
|
+
if (t.context?.background)
|
|
352
|
+
lines.push(`Background: ${t.context.background}`);
|
|
353
|
+
}
|
|
354
|
+
// Active tasks summary
|
|
355
|
+
const active = data.tasks?.active ?? [];
|
|
356
|
+
const blocked = data.tasks?.blocked ?? [];
|
|
357
|
+
const todo = data.tasks?.todo ?? [];
|
|
358
|
+
if (active.length + blocked.length + todo.length > 0) {
|
|
359
|
+
lines.push(`\nTasks: ${active.length} active, ${blocked.length} blocked, ${todo.length} todo`);
|
|
360
|
+
for (const t of active.slice(0, 5)) {
|
|
361
|
+
lines.push(` [active] T-${t.taskNumber}: ${t.title}`);
|
|
362
|
+
}
|
|
363
|
+
for (const t of blocked.slice(0, 3)) {
|
|
364
|
+
lines.push(` [blocked] T-${t.taskNumber}: ${t.title}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Unread channels
|
|
368
|
+
const unread = data.channels?.unread ?? [];
|
|
369
|
+
if (unread.length > 0) {
|
|
370
|
+
lines.push(`\nUnread: ${unread.length} channel${unread.length > 1 ? "s" : ""} with unread messages`);
|
|
371
|
+
}
|
|
372
|
+
// Upcoming schedule
|
|
373
|
+
const upcoming = data.schedule?.upcoming ?? [];
|
|
374
|
+
if (upcoming.length > 0) {
|
|
375
|
+
lines.push(`\nSchedule: ${upcoming.length} upcoming event${upcoming.length > 1 ? "s" : ""}`);
|
|
376
|
+
for (const e of upcoming.slice(0, 3)) {
|
|
377
|
+
lines.push(` ${e.title} — ${e.nextOccurrenceAt ?? "soon"}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Pinned memories
|
|
381
|
+
const pinned = data.memory?.pinned ?? [];
|
|
382
|
+
if (pinned.length > 0) {
|
|
383
|
+
lines.push(`\nPinned Memories:`);
|
|
384
|
+
for (const m of pinned.slice(0, 10)) {
|
|
385
|
+
lines.push(` [${m.category}/${m.key}] ${m.content.slice(0, 200)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Session state
|
|
389
|
+
if (data.state?.state) {
|
|
390
|
+
lines.push(`\nSession State: ${JSON.stringify(data.state.state).slice(0, 500)}`);
|
|
391
|
+
}
|
|
392
|
+
// Inject as system context via wake event
|
|
393
|
+
if (lines.length > 1) {
|
|
394
|
+
const contextText = lines.join("\n");
|
|
395
|
+
log.info?.(`[sesame] Injecting wake context (${contextText.length} chars)`);
|
|
396
|
+
// Use the OpenClaw plugin API to inject a wake event
|
|
397
|
+
try {
|
|
398
|
+
const runtime = pluginRuntime;
|
|
399
|
+
if (runtime?.events?.emit) {
|
|
400
|
+
runtime.events.emit("wake", {
|
|
401
|
+
text: contextText,
|
|
402
|
+
source: "sesame-wake",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
log.warn?.(`[sesame] Could not inject wake event: ${e}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
log.warn?.(`[sesame] Wake context error: ${err}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
307
415
|
// ── WebSocket connection ──
|
|
308
416
|
async function connect(account, ctx) {
|
|
309
417
|
const log = ctx.log ?? getLogger();
|
|
@@ -326,7 +434,7 @@ async function connect(account, ctx) {
|
|
|
326
434
|
try {
|
|
327
435
|
// Fetch manifest: resolve agent ID + cache channel metadata
|
|
328
436
|
try {
|
|
329
|
-
const res = await fetch(`${account.apiUrl}/api/v1/agents/me/manifest`, { headers:
|
|
437
|
+
const res = await fetch(`${account.apiUrl}/api/v1/agents/me/manifest`, { headers: sesameHeaders(account.apiKey, false) });
|
|
330
438
|
if (res.ok) {
|
|
331
439
|
const manifest = (await res.json());
|
|
332
440
|
const mData = manifest.data ?? manifest;
|
|
@@ -411,15 +519,33 @@ function handleEvent(event, account, state, ctx) {
|
|
|
411
519
|
}, event.heartbeatIntervalMs ?? 30000);
|
|
412
520
|
log.info?.("[sesame] Authenticated successfully");
|
|
413
521
|
state.ws?.send(JSON.stringify({ type: "replay", cursors: {} }));
|
|
522
|
+
// Call wake endpoint for cold start context (fire and forget)
|
|
523
|
+
if (state.agentId) {
|
|
524
|
+
fetchWakeContext(account, state.agentId, ctx).catch((err) => log.warn?.(`[sesame] Wake context fetch failed: ${err}`));
|
|
525
|
+
}
|
|
414
526
|
break;
|
|
415
527
|
case "message": {
|
|
416
528
|
const msg = event.message ?? event.data;
|
|
417
|
-
//
|
|
529
|
+
// For voice messages: check if transcript is available.
|
|
530
|
+
// If not, check plaintext (API may have set it during sync transcription).
|
|
531
|
+
// Only skip if neither is available — wait for voice.transcribed event.
|
|
418
532
|
if (msg?.kind === "voice") {
|
|
419
533
|
const meta = msg.metadata ?? {};
|
|
420
534
|
const transcript = meta.transcript;
|
|
421
|
-
|
|
422
|
-
|
|
535
|
+
const plaintext = msg.plaintext;
|
|
536
|
+
const hasTranscript = transcript || (plaintext && plaintext !== "(voice note)");
|
|
537
|
+
if (!hasTranscript) {
|
|
538
|
+
log.info?.(`[sesame] Voice message ${msg.id} — no transcript yet, waiting for voice.transcribed (30s fallback)`);
|
|
539
|
+
// Track pending voice messages for fallback delivery
|
|
540
|
+
const pendingKey = `voice:${msg.id}`;
|
|
541
|
+
state.sentMessageIds.add(pendingKey); // reuse set to track pending
|
|
542
|
+
setTimeout(() => {
|
|
543
|
+
if (state.sentMessageIds.has(pendingKey)) {
|
|
544
|
+
state.sentMessageIds.delete(pendingKey);
|
|
545
|
+
log.warn?.(`[sesame] Voice message ${msg.id} — transcription timeout, delivering without transcript`);
|
|
546
|
+
handleMessage(msg, account, state, ctx);
|
|
547
|
+
}
|
|
548
|
+
}, 30000);
|
|
423
549
|
break;
|
|
424
550
|
}
|
|
425
551
|
}
|
|
@@ -437,6 +563,9 @@ function handleEvent(event, account, state, ctx) {
|
|
|
437
563
|
log.warn?.("[sesame] voice.transcribed event missing fields");
|
|
438
564
|
break;
|
|
439
565
|
}
|
|
566
|
+
// Clear pending fallback so we don't double-deliver
|
|
567
|
+
const pendingKey = `voice:${messageId}`;
|
|
568
|
+
state.sentMessageIds.delete(pendingKey);
|
|
440
569
|
log.info?.(`[sesame] Voice transcribed for ${messageId}: "${transcript.slice(0, 80)}"`);
|
|
441
570
|
// Build a synthetic message object so handleMessage can process it
|
|
442
571
|
handleMessage({
|
|
@@ -484,8 +613,21 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
484
613
|
// For voice messages, prefer transcript from metadata over content
|
|
485
614
|
const msgMeta = message.metadata ?? {};
|
|
486
615
|
const voiceTranscript = message.kind === "voice" ? msgMeta.transcript : undefined;
|
|
487
|
-
|
|
488
|
-
|
|
616
|
+
// Prefer plaintext over content — plaintext includes attachment info block
|
|
617
|
+
// appended by the API (download URLs, file names, sizes)
|
|
618
|
+
const rawText = voiceTranscript ?? message.plaintext ?? message.content ?? message.body ?? message.text ?? "";
|
|
619
|
+
// If plaintext didn't include attachments but metadata does, build the block ourselves
|
|
620
|
+
let bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
|
|
621
|
+
const attachments = msgMeta.attachments;
|
|
622
|
+
if (attachments?.length && !bodyText.includes("[Attachments]")) {
|
|
623
|
+
const lines = attachments.map((a) => {
|
|
624
|
+
const sizeKB = Math.round((a.size ?? 0) / 1024);
|
|
625
|
+
const sizeStr = sizeKB >= 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`;
|
|
626
|
+
const dl = a.downloadUrl ? `\n Download: ${a.downloadUrl}` : "";
|
|
627
|
+
return `- ${a.fileName ?? "file"} (${a.contentType ?? "unknown"}, ${sizeStr})${dl}`;
|
|
628
|
+
});
|
|
629
|
+
bodyText = bodyText + `\n\n[Attachments]\n${lines.join("\n")}`;
|
|
630
|
+
}
|
|
489
631
|
const channelId = message.channelId;
|
|
490
632
|
const messageId = message.id;
|
|
491
633
|
log.info?.(`[sesame] Message from ${message.senderId} in ${channelId}: "${bodyText.slice(0, 100)}"`);
|
|
@@ -525,7 +667,7 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
525
667
|
if (!channelInfo) {
|
|
526
668
|
// Channel not in cache — fetch from API
|
|
527
669
|
try {
|
|
528
|
-
const chRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}`, { headers:
|
|
670
|
+
const chRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}`, { headers: sesameHeaders(account.apiKey, false) });
|
|
529
671
|
if (chRes.ok) {
|
|
530
672
|
const chData = (await chRes.json());
|
|
531
673
|
const ch = chData.data ?? chData;
|
|
@@ -610,12 +752,23 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
610
752
|
sessionKey: inboundCtx.SessionKey ?? sesameSessionKey,
|
|
611
753
|
ctx: inboundCtx,
|
|
612
754
|
});
|
|
613
|
-
//
|
|
755
|
+
// ── Streaming reply: send initial message, then edit in place ──
|
|
756
|
+
const sesameStreamMode = cfg.channels?.sesame?.streamMode ?? "buffer";
|
|
614
757
|
const replyBuffer = [];
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
758
|
+
let streamMessageId = null;
|
|
759
|
+
let streamSending = false; // lock to prevent concurrent first-message sends
|
|
760
|
+
let streamEditTimer = null;
|
|
761
|
+
let streamPendingText = "";
|
|
762
|
+
const STREAM_EDIT_DEBOUNCE_MS = 300; // don't edit more than ~3x/sec
|
|
763
|
+
const trackSentMessage = (sentId) => {
|
|
764
|
+
state.sentMessageIds.add(sentId);
|
|
765
|
+
if (state.sentMessageIds.size > 1000) {
|
|
766
|
+
const first = state.sentMessageIds.values().next().value;
|
|
767
|
+
if (first)
|
|
768
|
+
state.sentMessageIds.delete(first);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
const sendNewMessage = async (text) => {
|
|
619
772
|
// Outbound circuit breaker: max 5 messages per 10s window
|
|
620
773
|
const cbNow = Date.now();
|
|
621
774
|
if (cbNow - state.outboundWindowStart > 10000) {
|
|
@@ -623,45 +776,72 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
623
776
|
state.outboundWindowStart = cbNow;
|
|
624
777
|
}
|
|
625
778
|
if (state.recentOutboundCount >= 5) {
|
|
626
|
-
log.error?.(`[sesame] Circuit breaker: suppressing outbound message
|
|
627
|
-
return;
|
|
779
|
+
log.error?.(`[sesame] Circuit breaker: suppressing outbound message`);
|
|
780
|
+
return null;
|
|
628
781
|
}
|
|
629
|
-
log.info?.(`[sesame] Flushing buffered reply (${fullReply.length} chars): "${fullReply.slice(0, 100)}"`);
|
|
630
782
|
const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
631
783
|
method: "POST",
|
|
632
|
-
headers:
|
|
633
|
-
|
|
634
|
-
Authorization: `Bearer ${account.apiKey}`,
|
|
635
|
-
},
|
|
636
|
-
body: JSON.stringify({
|
|
637
|
-
content: fullReply,
|
|
638
|
-
kind: "text",
|
|
639
|
-
intent: "chat",
|
|
640
|
-
}),
|
|
784
|
+
headers: sesameHeaders(account.apiKey),
|
|
785
|
+
body: JSON.stringify({ content: text, kind: "text", intent: "chat" }),
|
|
641
786
|
});
|
|
642
787
|
if (!res.ok) {
|
|
643
788
|
const err = await res.text().catch(() => "");
|
|
644
789
|
log.error?.(`[sesame] Send failed (${res.status}): ${err.slice(0, 200)}`);
|
|
790
|
+
return null;
|
|
645
791
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
792
|
+
state.recentOutboundCount++;
|
|
793
|
+
const resData = (await res.json().catch(() => ({})));
|
|
794
|
+
const sentId = resData.data?.id ?? resData.id ?? null;
|
|
795
|
+
if (sentId)
|
|
796
|
+
trackSentMessage(sentId);
|
|
797
|
+
return sentId;
|
|
798
|
+
};
|
|
799
|
+
const editMessage = async (msgId, text) => {
|
|
800
|
+
const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${msgId}`, {
|
|
801
|
+
method: "PATCH",
|
|
802
|
+
headers: sesameHeaders(account.apiKey),
|
|
803
|
+
body: JSON.stringify({ content: text }),
|
|
804
|
+
});
|
|
805
|
+
if (!res.ok) {
|
|
806
|
+
log.warn?.(`[sesame] Edit failed (${res.status})`);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
const scheduleStreamEdit = () => {
|
|
810
|
+
if (streamEditTimer)
|
|
811
|
+
return; // already scheduled
|
|
812
|
+
streamEditTimer = setTimeout(async () => {
|
|
813
|
+
streamEditTimer = null;
|
|
814
|
+
if (streamMessageId && streamPendingText) {
|
|
815
|
+
await editMessage(streamMessageId, streamPendingText);
|
|
661
816
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
817
|
+
}, STREAM_EDIT_DEBOUNCE_MS);
|
|
818
|
+
};
|
|
819
|
+
const flushBuffer = async () => {
|
|
820
|
+
const fullReply = replyBuffer.join("\n\n").trim();
|
|
821
|
+
// If nothing in buffer but we streamed via onPartialReply, do final edit with last known text
|
|
822
|
+
if (!fullReply && streamMessageId && streamPendingText) {
|
|
823
|
+
clearTimeout(streamEditTimer);
|
|
824
|
+
streamEditTimer = null;
|
|
825
|
+
await editMessage(streamMessageId, streamPendingText);
|
|
826
|
+
streamDelivered.set(channelId, { messageId: streamMessageId, text: streamPendingText });
|
|
827
|
+
log.info?.(`[sesame] Final stream edit from partial (${streamPendingText.length} chars), marked as delivered`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!fullReply)
|
|
831
|
+
return;
|
|
832
|
+
if (streamMessageId) {
|
|
833
|
+
// Final edit with complete text
|
|
834
|
+
clearTimeout(streamEditTimer);
|
|
835
|
+
streamEditTimer = null;
|
|
836
|
+
await editMessage(streamMessageId, fullReply);
|
|
837
|
+
// Signal to outbound.sendText that this channel was already handled
|
|
838
|
+
streamDelivered.set(channelId, { messageId: streamMessageId, text: fullReply });
|
|
839
|
+
log.info?.(`[sesame] Final stream edit (${fullReply.length} chars), marked as delivered`);
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
// No streaming happened — don't send here, let outbound.sendText handle it
|
|
843
|
+
// (avoids double-send when OpenClaw calls both deliver + outbound.sendText)
|
|
844
|
+
log.info?.(`[sesame] Buffer mode: deferring to outbound.sendText (${fullReply.length} chars)`);
|
|
665
845
|
}
|
|
666
846
|
};
|
|
667
847
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
@@ -670,18 +850,56 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
670
850
|
onIdle: () => clearInterval(typingInterval),
|
|
671
851
|
deliver: async (payload) => {
|
|
672
852
|
const replyText = payload.text ?? payload.body ?? payload.content ?? "";
|
|
673
|
-
if (replyText)
|
|
674
|
-
|
|
853
|
+
if (!replyText)
|
|
854
|
+
return;
|
|
855
|
+
replyBuffer.push(replyText);
|
|
856
|
+
const fullSoFar = replyBuffer.join("\n\n").trim();
|
|
857
|
+
if (sesameStreamMode === "partial" && fullSoFar.length > 0) {
|
|
858
|
+
streamPendingText = fullSoFar;
|
|
859
|
+
if (!streamMessageId && !streamSending) {
|
|
860
|
+
// First chunk — send initial message (with lock to prevent race)
|
|
861
|
+
streamSending = true;
|
|
862
|
+
streamMessageId = await sendNewMessage(fullSoFar);
|
|
863
|
+
streamSending = false;
|
|
864
|
+
log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
|
|
865
|
+
}
|
|
866
|
+
else if (streamMessageId) {
|
|
867
|
+
// Subsequent chunks — debounced edit
|
|
868
|
+
scheduleStreamEdit();
|
|
869
|
+
}
|
|
870
|
+
// If streamSending is true, another deliver call is already creating the message — skip
|
|
871
|
+
}
|
|
675
872
|
},
|
|
676
873
|
onError: (err) => {
|
|
677
874
|
log.error?.(`[sesame] Reply failed: ${String(err)}`);
|
|
678
875
|
},
|
|
679
876
|
});
|
|
877
|
+
// Add onPartialReply for real-time streaming (token-by-token updates)
|
|
878
|
+
const streamingReplyOptions = { ...replyOptions };
|
|
879
|
+
if (sesameStreamMode === "partial") {
|
|
880
|
+
streamingReplyOptions.onPartialReply = async (payload) => {
|
|
881
|
+
const text = payload.text;
|
|
882
|
+
if (!text)
|
|
883
|
+
return;
|
|
884
|
+
streamPendingText = text;
|
|
885
|
+
if (!streamMessageId && !streamSending) {
|
|
886
|
+
streamSending = true;
|
|
887
|
+
streamMessageId = await sendNewMessage(text);
|
|
888
|
+
streamSending = false;
|
|
889
|
+
if (streamMessageId) {
|
|
890
|
+
log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
else if (streamMessageId) {
|
|
894
|
+
scheduleStreamEdit();
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
680
898
|
await core.channel.reply.dispatchReplyFromConfig({
|
|
681
899
|
ctx: inboundCtx,
|
|
682
900
|
cfg,
|
|
683
901
|
dispatcher,
|
|
684
|
-
replyOptions,
|
|
902
|
+
replyOptions: streamingReplyOptions,
|
|
685
903
|
});
|
|
686
904
|
typingStopped = true;
|
|
687
905
|
clearInterval(typingInterval);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sesamespace/sesame",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Sesame channel plugin for OpenClaw — connect your AI agent to the Sesame messaging platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"ws": "^8.18.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@types/node": "^25.
|
|
59
|
+
"@types/node": "^25.5.0",
|
|
60
60
|
"typescript": "^5.9.3"
|
|
61
61
|
}
|
|
62
62
|
}
|