@sesamespace/sesame 0.2.2 → 0.2.4

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 CHANGED
@@ -16,9 +16,24 @@
16
16
  let pluginRuntime = null;
17
17
  // Connection state per account
18
18
  const connections = new Map();
19
+ // Streaming state: tracks channels where streaming already delivered the message
20
+ // Key: channelId, Value: { messageId, text } — outbound.sendText should edit instead of send
21
+ const streamDelivered = new Map();
19
22
  function getLogger() {
20
23
  return (pluginRuntime?.logging?.getChildLogger?.({ plugin: "sesame" }) ?? console);
21
24
  }
25
+ function guessContentType(fileName) {
26
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
27
+ const map = {
28
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
29
+ webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
30
+ mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm",
31
+ json: "application/json", txt: "text/plain", md: "text/markdown",
32
+ csv: "text/csv", html: "text/html", xml: "application/xml",
33
+ zip: "application/zip", tar: "application/x-tar", gz: "application/gzip",
34
+ };
35
+ return map[ext] ?? "application/octet-stream";
36
+ }
22
37
  const sesameChannelPlugin = {
23
38
  id: "sesame",
24
39
  meta: {
@@ -31,7 +46,7 @@ const sesameChannelPlugin = {
31
46
  },
32
47
  capabilities: {
33
48
  chatTypes: ["direct", "group"],
34
- media: false,
49
+ media: true,
35
50
  reactions: true,
36
51
  threads: true,
37
52
  editing: true,
@@ -134,6 +149,30 @@ const sesameChannelPlugin = {
134
149
  if (!account?.apiKey)
135
150
  throw new Error("Sesame not configured");
136
151
  const channelId = to?.startsWith("sesame:") ? to.slice(7) : to;
152
+ const log = getLogger();
153
+ // Check if streaming already delivered this message
154
+ const delivered = streamDelivered.get(channelId);
155
+ if (delivered) {
156
+ streamDelivered.delete(channelId);
157
+ // Do a final edit to ensure the complete text is correct, then return
158
+ log.info?.(`[sesame] outbound.sendText: streaming already sent to ${channelId}, doing final edit`);
159
+ const editRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${delivered.messageId}`, {
160
+ method: "PATCH",
161
+ headers: {
162
+ "Content-Type": "application/json",
163
+ Authorization: `Bearer ${account.apiKey}`,
164
+ },
165
+ body: JSON.stringify({ content: text }),
166
+ });
167
+ if (!editRes.ok) {
168
+ log.warn?.(`[sesame] Final stream edit in sendText failed (${editRes.status})`);
169
+ }
170
+ return {
171
+ channel: "sesame",
172
+ messageId: delivered.messageId,
173
+ chatId: channelId,
174
+ };
175
+ }
137
176
  const response = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
138
177
  method: "POST",
139
178
  headers: {
@@ -154,9 +193,108 @@ const sesameChannelPlugin = {
154
193
  };
155
194
  },
156
195
  sendMedia: async (ctx) => {
157
- // Sesame doesn't support media attachments yet fall back to text
158
- const text = ctx.caption ?? ctx.text ?? "[media attachment]";
159
- return sesameChannelPlugin.outbound.sendText({ ...ctx, text });
196
+ const { text, mediaUrl, cfg, accountId } = ctx;
197
+ const account = sesameChannelPlugin.config.resolveAccount(cfg, accountId);
198
+ if (!account?.apiKey)
199
+ throw new Error("Sesame not configured");
200
+ const channelId = ctx.to?.startsWith("sesame:") ? ctx.to.slice(7) : ctx.to;
201
+ const caption = ctx.caption ?? text ?? "";
202
+ const log = getLogger();
203
+ try {
204
+ // Resolve media source: local file path or URL
205
+ const fs = await import("fs");
206
+ const path = await import("path");
207
+ let fileBuffer;
208
+ let fileName;
209
+ let contentType;
210
+ if (mediaUrl && (mediaUrl.startsWith("/") || mediaUrl.startsWith("~"))) {
211
+ // Local file path
212
+ const resolvedPath = mediaUrl.startsWith("~")
213
+ ? mediaUrl.replace("~", process.env.HOME ?? "")
214
+ : mediaUrl;
215
+ fileBuffer = fs.readFileSync(resolvedPath);
216
+ fileName = path.basename(resolvedPath);
217
+ contentType = guessContentType(fileName);
218
+ }
219
+ else if (mediaUrl) {
220
+ // Remote URL — download it
221
+ const response = await fetch(mediaUrl);
222
+ if (!response.ok)
223
+ throw new Error(`Failed to download media: ${response.status}`);
224
+ fileBuffer = Buffer.from(await response.arrayBuffer());
225
+ // Extract filename from URL or Content-Disposition
226
+ const urlPath = new URL(mediaUrl).pathname;
227
+ fileName = path.basename(urlPath) || "attachment";
228
+ contentType = response.headers.get("content-type") ?? guessContentType(fileName);
229
+ }
230
+ else {
231
+ // No media URL — fall back to text
232
+ return sesameChannelPlugin.outbound.sendText({ ...ctx, text: caption || "[media attachment]" });
233
+ }
234
+ // 1. Get presigned upload URL from Sesame Drive
235
+ const uploadRes = await fetch(`${account.apiUrl}/api/v1/drive/files/upload-url`, {
236
+ method: "POST",
237
+ headers: {
238
+ "Content-Type": "application/json",
239
+ Authorization: `Bearer ${account.apiKey}`,
240
+ },
241
+ body: JSON.stringify({ fileName, contentType, size: fileBuffer.length }),
242
+ });
243
+ if (!uploadRes.ok)
244
+ throw new Error(`Drive upload-url failed: ${uploadRes.status}`);
245
+ const uploadData = (await uploadRes.json());
246
+ const { uploadUrl, fileId, s3Key } = uploadData.data;
247
+ // 2. Upload to S3
248
+ const s3Res = await fetch(uploadUrl, {
249
+ method: "PUT",
250
+ headers: { "Content-Type": contentType },
251
+ body: new Uint8Array(fileBuffer),
252
+ });
253
+ if (!s3Res.ok)
254
+ throw new Error(`S3 upload failed: ${s3Res.status}`);
255
+ // 3. Register file in Drive
256
+ const regRes = await fetch(`${account.apiUrl}/api/v1/drive/files`, {
257
+ method: "POST",
258
+ headers: {
259
+ "Content-Type": "application/json",
260
+ Authorization: `Bearer ${account.apiKey}`,
261
+ },
262
+ body: JSON.stringify({ fileId, s3Key, fileName, contentType, size: fileBuffer.length }),
263
+ });
264
+ if (!regRes.ok)
265
+ throw new Error(`Drive register failed: ${regRes.status}`);
266
+ // 4. Send message with attachment
267
+ const msgRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
268
+ method: "POST",
269
+ headers: {
270
+ "Content-Type": "application/json",
271
+ Authorization: `Bearer ${account.apiKey}`,
272
+ },
273
+ body: JSON.stringify({
274
+ content: caption || fileName,
275
+ kind: "text",
276
+ intent: "chat",
277
+ attachmentIds: [fileId],
278
+ }),
279
+ });
280
+ if (!msgRes.ok)
281
+ throw new Error(`Send message failed: ${msgRes.status}`);
282
+ const msgData = (await msgRes.json());
283
+ log.info?.(`[sesame] Sent file "${fileName}" (${fileBuffer.length} bytes) to ${channelId}`);
284
+ return {
285
+ channel: "sesame",
286
+ messageId: msgData.data?.id ?? msgData.id ?? "unknown",
287
+ chatId: channelId,
288
+ };
289
+ }
290
+ catch (err) {
291
+ log.error?.(`[sesame] sendMedia failed: ${err}`);
292
+ // Fall back to text with caption
293
+ return sesameChannelPlugin.outbound.sendText({
294
+ ...ctx,
295
+ text: caption || "[media attachment — upload failed]",
296
+ });
297
+ }
160
298
  },
161
299
  },
162
300
  messaging: {
@@ -193,6 +331,90 @@ const sesameChannelPlugin = {
193
331
  },
194
332
  },
195
333
  };
334
+ // ── Wake Context (cold start optimization) ──
335
+ async function fetchWakeContext(account, agentId, ctx) {
336
+ const log = getLogger();
337
+ try {
338
+ const response = await fetch(`${account.apiUrl}/api/v1/agents/${agentId}/wake`, { headers: { Authorization: `Bearer ${account.apiKey}` } });
339
+ if (!response.ok) {
340
+ log.warn?.(`[sesame] Wake endpoint returned ${response.status}`);
341
+ return;
342
+ }
343
+ const result = await response.json();
344
+ if (!result.ok)
345
+ return;
346
+ const data = result.data;
347
+ const lines = ["[Sesame Platform Context]"];
348
+ // Focused task
349
+ if (data.focusedTask) {
350
+ const t = data.focusedTask;
351
+ lines.push(`\nFocused Task: T-${t.taskNumber} — ${t.title} (${t.status})`);
352
+ if (t.context?.notes)
353
+ lines.push(`Notes: ${t.context.notes}`);
354
+ if (t.context?.background)
355
+ lines.push(`Background: ${t.context.background}`);
356
+ }
357
+ // Active tasks summary
358
+ const active = data.tasks?.active ?? [];
359
+ const blocked = data.tasks?.blocked ?? [];
360
+ const todo = data.tasks?.todo ?? [];
361
+ if (active.length + blocked.length + todo.length > 0) {
362
+ lines.push(`\nTasks: ${active.length} active, ${blocked.length} blocked, ${todo.length} todo`);
363
+ for (const t of active.slice(0, 5)) {
364
+ lines.push(` [active] T-${t.taskNumber}: ${t.title}`);
365
+ }
366
+ for (const t of blocked.slice(0, 3)) {
367
+ lines.push(` [blocked] T-${t.taskNumber}: ${t.title}`);
368
+ }
369
+ }
370
+ // Unread channels
371
+ const unread = data.channels?.unread ?? [];
372
+ if (unread.length > 0) {
373
+ lines.push(`\nUnread: ${unread.length} channel${unread.length > 1 ? "s" : ""} with unread messages`);
374
+ }
375
+ // Upcoming schedule
376
+ const upcoming = data.schedule?.upcoming ?? [];
377
+ if (upcoming.length > 0) {
378
+ lines.push(`\nSchedule: ${upcoming.length} upcoming event${upcoming.length > 1 ? "s" : ""}`);
379
+ for (const e of upcoming.slice(0, 3)) {
380
+ lines.push(` ${e.title} — ${e.nextOccurrenceAt ?? "soon"}`);
381
+ }
382
+ }
383
+ // Pinned memories
384
+ const pinned = data.memory?.pinned ?? [];
385
+ if (pinned.length > 0) {
386
+ lines.push(`\nPinned Memories:`);
387
+ for (const m of pinned.slice(0, 10)) {
388
+ lines.push(` [${m.category}/${m.key}] ${m.content.slice(0, 200)}`);
389
+ }
390
+ }
391
+ // Session state
392
+ if (data.state?.state) {
393
+ lines.push(`\nSession State: ${JSON.stringify(data.state.state).slice(0, 500)}`);
394
+ }
395
+ // Inject as system context via wake event
396
+ if (lines.length > 1) {
397
+ const contextText = lines.join("\n");
398
+ log.info?.(`[sesame] Injecting wake context (${contextText.length} chars)`);
399
+ // Use the OpenClaw plugin API to inject a wake event
400
+ try {
401
+ const runtime = pluginRuntime;
402
+ if (runtime?.events?.emit) {
403
+ runtime.events.emit("wake", {
404
+ text: contextText,
405
+ source: "sesame-wake",
406
+ });
407
+ }
408
+ }
409
+ catch (e) {
410
+ log.warn?.(`[sesame] Could not inject wake event: ${e}`);
411
+ }
412
+ }
413
+ }
414
+ catch (err) {
415
+ log.warn?.(`[sesame] Wake context error: ${err}`);
416
+ }
417
+ }
196
418
  // ── WebSocket connection ──
197
419
  async function connect(account, ctx) {
198
420
  const log = ctx.log ?? getLogger();
@@ -207,6 +429,9 @@ async function connect(account, ctx) {
207
429
  stopping: false,
208
430
  ctx,
209
431
  channelMap: new Map(),
432
+ sentMessageIds: new Set(),
433
+ recentOutboundCount: 0,
434
+ outboundWindowStart: Date.now(),
210
435
  };
211
436
  connections.set(account.accountId, state);
212
437
  try {
@@ -286,6 +511,10 @@ function handleEvent(event, account, state, ctx) {
286
511
  switch (event.type) {
287
512
  case "authenticated":
288
513
  state.authenticated = true;
514
+ if (event.principalId && !state.agentId) {
515
+ state.agentId = event.principalId;
516
+ log.info?.(`[sesame] Agent ID from auth: ${state.agentId}`);
517
+ }
289
518
  state.heartbeatTimer = setInterval(() => {
290
519
  if (state.ws?.readyState === 1) {
291
520
  state.ws.send(JSON.stringify({ type: "ping" }));
@@ -293,10 +522,67 @@ function handleEvent(event, account, state, ctx) {
293
522
  }, event.heartbeatIntervalMs ?? 30000);
294
523
  log.info?.("[sesame] Authenticated successfully");
295
524
  state.ws?.send(JSON.stringify({ type: "replay", cursors: {} }));
525
+ // Call wake endpoint for cold start context (fire and forget)
526
+ if (state.agentId) {
527
+ fetchWakeContext(account, state.agentId, ctx).catch((err) => log.warn?.(`[sesame] Wake context fetch failed: ${err}`));
528
+ }
296
529
  break;
297
- case "message":
298
- handleMessage(event.message ?? event.data, account, state, ctx);
530
+ case "message": {
531
+ const msg = event.message ?? event.data;
532
+ // For voice messages: check if transcript is available.
533
+ // If not, check plaintext (API may have set it during sync transcription).
534
+ // Only skip if neither is available — wait for voice.transcribed event.
535
+ if (msg?.kind === "voice") {
536
+ const meta = msg.metadata ?? {};
537
+ const transcript = meta.transcript;
538
+ const plaintext = msg.plaintext;
539
+ const hasTranscript = transcript || (plaintext && plaintext !== "(voice note)");
540
+ if (!hasTranscript) {
541
+ log.info?.(`[sesame] Voice message ${msg.id} — no transcript yet, waiting for voice.transcribed (30s fallback)`);
542
+ // Track pending voice messages for fallback delivery
543
+ const pendingKey = `voice:${msg.id}`;
544
+ state.sentMessageIds.add(pendingKey); // reuse set to track pending
545
+ setTimeout(() => {
546
+ if (state.sentMessageIds.has(pendingKey)) {
547
+ state.sentMessageIds.delete(pendingKey);
548
+ log.warn?.(`[sesame] Voice message ${msg.id} — transcription timeout, delivering without transcript`);
549
+ handleMessage(msg, account, state, ctx);
550
+ }
551
+ }, 30000);
552
+ break;
553
+ }
554
+ }
555
+ handleMessage(msg, account, state, ctx);
556
+ break;
557
+ }
558
+ case "voice.transcribed": {
559
+ // Transcription completed — dispatch as a regular message with transcript text
560
+ const vtData = event;
561
+ const transcript = vtData.transcript;
562
+ const messageId = vtData.messageId;
563
+ const channelId = vtData.channelId;
564
+ const senderId = vtData.senderId;
565
+ if (!transcript || !messageId || !channelId) {
566
+ log.warn?.("[sesame] voice.transcribed event missing fields");
567
+ break;
568
+ }
569
+ // Clear pending fallback so we don't double-deliver
570
+ const pendingKey = `voice:${messageId}`;
571
+ state.sentMessageIds.delete(pendingKey);
572
+ log.info?.(`[sesame] Voice transcribed for ${messageId}: "${transcript.slice(0, 80)}"`);
573
+ // Build a synthetic message object so handleMessage can process it
574
+ handleMessage({
575
+ id: messageId,
576
+ channelId,
577
+ senderId,
578
+ kind: "voice",
579
+ content: transcript,
580
+ plaintext: transcript,
581
+ metadata: { transcript },
582
+ createdAt: new Date().toISOString(),
583
+ }, account, state, ctx);
299
584
  break;
585
+ }
300
586
  case "membership": {
301
587
  // Track new channel joins so ChatType resolves correctly
302
588
  const mData = event.data ?? event.membership ?? event;
@@ -327,13 +613,40 @@ async function handleMessage(message, account, state, ctx) {
327
613
  const core = pluginRuntime;
328
614
  if (!core)
329
615
  return;
330
- const bodyText = message.content ?? message.plaintext ?? message.body ?? message.text ?? "";
616
+ // For voice messages, prefer transcript from metadata over content
617
+ const msgMeta = message.metadata ?? {};
618
+ const voiceTranscript = message.kind === "voice" ? msgMeta.transcript : undefined;
619
+ // Prefer plaintext over content — plaintext includes attachment info block
620
+ // appended by the API (download URLs, file names, sizes)
621
+ const rawText = voiceTranscript ?? message.plaintext ?? message.content ?? message.body ?? message.text ?? "";
622
+ // If plaintext didn't include attachments but metadata does, build the block ourselves
623
+ let bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
624
+ const attachments = msgMeta.attachments;
625
+ if (attachments?.length && !bodyText.includes("[Attachments]")) {
626
+ const lines = attachments.map((a) => {
627
+ const sizeKB = Math.round((a.size ?? 0) / 1024);
628
+ const sizeStr = sizeKB >= 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`;
629
+ const dl = a.downloadUrl ? `\n Download: ${a.downloadUrl}` : "";
630
+ return `- ${a.fileName ?? "file"} (${a.contentType ?? "unknown"}, ${sizeStr})${dl}`;
631
+ });
632
+ bodyText = bodyText + `\n\n[Attachments]\n${lines.join("\n")}`;
633
+ }
331
634
  const channelId = message.channelId;
332
635
  const messageId = message.id;
333
636
  log.info?.(`[sesame] Message from ${message.senderId} in ${channelId}: "${bodyText.slice(0, 100)}"`);
334
- // Skip own messages
637
+ // Skip own messages (by sender ID)
335
638
  if (message.senderId === state.agentId)
336
639
  return;
640
+ // Skip messages we sent (by message ID tracking)
641
+ if (state.sentMessageIds.has(messageId)) {
642
+ state.sentMessageIds.delete(messageId);
643
+ return;
644
+ }
645
+ // Don't process messages until we know our own agent ID
646
+ if (!state.agentId) {
647
+ log.warn?.(`[sesame] Skipping message — agentId not resolved yet`);
648
+ return;
649
+ }
337
650
  // Channel filter (empty = all channels)
338
651
  if (account.channels.length > 0 && !account.channels.includes(channelId))
339
652
  return;
@@ -442,31 +755,102 @@ async function handleMessage(message, account, state, ctx) {
442
755
  sessionKey: inboundCtx.SessionKey ?? sesameSessionKey,
443
756
  ctx: inboundCtx,
444
757
  });
445
- // Buffer reply chunks and send as a single message
758
+ // ── Streaming reply: send initial message, then edit in place ──
759
+ const sesameStreamMode = cfg.channels?.sesame?.streamMode ?? "buffer";
446
760
  const replyBuffer = [];
447
- const flushBuffer = async () => {
448
- const fullReply = replyBuffer.join("\n\n").trim();
449
- if (!fullReply)
450
- return;
451
- log.info?.(`[sesame] Flushing buffered reply (${fullReply.length} chars): "${fullReply.slice(0, 100)}"`);
761
+ let streamMessageId = null;
762
+ let streamSending = false; // lock to prevent concurrent first-message sends
763
+ let streamEditTimer = null;
764
+ let streamPendingText = "";
765
+ const STREAM_EDIT_DEBOUNCE_MS = 300; // don't edit more than ~3x/sec
766
+ const trackSentMessage = (sentId) => {
767
+ state.sentMessageIds.add(sentId);
768
+ if (state.sentMessageIds.size > 1000) {
769
+ const first = state.sentMessageIds.values().next().value;
770
+ if (first)
771
+ state.sentMessageIds.delete(first);
772
+ }
773
+ };
774
+ const sendNewMessage = async (text) => {
775
+ // Outbound circuit breaker: max 5 messages per 10s window
776
+ const cbNow = Date.now();
777
+ if (cbNow - state.outboundWindowStart > 10000) {
778
+ state.recentOutboundCount = 0;
779
+ state.outboundWindowStart = cbNow;
780
+ }
781
+ if (state.recentOutboundCount >= 5) {
782
+ log.error?.(`[sesame] Circuit breaker: suppressing outbound message`);
783
+ return null;
784
+ }
452
785
  const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
453
786
  method: "POST",
454
787
  headers: {
455
788
  "Content-Type": "application/json",
456
789
  Authorization: `Bearer ${account.apiKey}`,
457
790
  },
458
- body: JSON.stringify({
459
- content: fullReply,
460
- kind: "text",
461
- intent: "chat",
462
- }),
791
+ body: JSON.stringify({ content: text, kind: "text", intent: "chat" }),
463
792
  });
464
793
  if (!res.ok) {
465
794
  const err = await res.text().catch(() => "");
466
795
  log.error?.(`[sesame] Send failed (${res.status}): ${err.slice(0, 200)}`);
796
+ return null;
797
+ }
798
+ state.recentOutboundCount++;
799
+ const resData = (await res.json().catch(() => ({})));
800
+ const sentId = resData.data?.id ?? resData.id ?? null;
801
+ if (sentId)
802
+ trackSentMessage(sentId);
803
+ return sentId;
804
+ };
805
+ const editMessage = async (msgId, text) => {
806
+ const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${msgId}`, {
807
+ method: "PATCH",
808
+ headers: {
809
+ "Content-Type": "application/json",
810
+ Authorization: `Bearer ${account.apiKey}`,
811
+ },
812
+ body: JSON.stringify({ content: text }),
813
+ });
814
+ if (!res.ok) {
815
+ log.warn?.(`[sesame] Edit failed (${res.status})`);
816
+ }
817
+ };
818
+ const scheduleStreamEdit = () => {
819
+ if (streamEditTimer)
820
+ return; // already scheduled
821
+ streamEditTimer = setTimeout(async () => {
822
+ streamEditTimer = null;
823
+ if (streamMessageId && streamPendingText) {
824
+ await editMessage(streamMessageId, streamPendingText);
825
+ }
826
+ }, STREAM_EDIT_DEBOUNCE_MS);
827
+ };
828
+ const flushBuffer = async () => {
829
+ const fullReply = replyBuffer.join("\n\n").trim();
830
+ // If nothing in buffer but we streamed via onPartialReply, do final edit with last known text
831
+ if (!fullReply && streamMessageId && streamPendingText) {
832
+ clearTimeout(streamEditTimer);
833
+ streamEditTimer = null;
834
+ await editMessage(streamMessageId, streamPendingText);
835
+ streamDelivered.set(channelId, { messageId: streamMessageId, text: streamPendingText });
836
+ log.info?.(`[sesame] Final stream edit from partial (${streamPendingText.length} chars), marked as delivered`);
837
+ return;
838
+ }
839
+ if (!fullReply)
840
+ return;
841
+ if (streamMessageId) {
842
+ // Final edit with complete text
843
+ clearTimeout(streamEditTimer);
844
+ streamEditTimer = null;
845
+ await editMessage(streamMessageId, fullReply);
846
+ // Signal to outbound.sendText that this channel was already handled
847
+ streamDelivered.set(channelId, { messageId: streamMessageId, text: fullReply });
848
+ log.info?.(`[sesame] Final stream edit (${fullReply.length} chars), marked as delivered`);
467
849
  }
468
850
  else {
469
- log.info?.(`[sesame] Sent buffered reply to ${channelId} (${res.status})`);
851
+ // No streaming happened don't send here, let outbound.sendText handle it
852
+ // (avoids double-send when OpenClaw calls both deliver + outbound.sendText)
853
+ log.info?.(`[sesame] Buffer mode: deferring to outbound.sendText (${fullReply.length} chars)`);
470
854
  }
471
855
  };
472
856
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
@@ -475,18 +859,56 @@ async function handleMessage(message, account, state, ctx) {
475
859
  onIdle: () => clearInterval(typingInterval),
476
860
  deliver: async (payload) => {
477
861
  const replyText = payload.text ?? payload.body ?? payload.content ?? "";
478
- if (replyText)
479
- replyBuffer.push(replyText);
862
+ if (!replyText)
863
+ return;
864
+ replyBuffer.push(replyText);
865
+ const fullSoFar = replyBuffer.join("\n\n").trim();
866
+ if (sesameStreamMode === "partial" && fullSoFar.length > 0) {
867
+ streamPendingText = fullSoFar;
868
+ if (!streamMessageId && !streamSending) {
869
+ // First chunk — send initial message (with lock to prevent race)
870
+ streamSending = true;
871
+ streamMessageId = await sendNewMessage(fullSoFar);
872
+ streamSending = false;
873
+ log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
874
+ }
875
+ else if (streamMessageId) {
876
+ // Subsequent chunks — debounced edit
877
+ scheduleStreamEdit();
878
+ }
879
+ // If streamSending is true, another deliver call is already creating the message — skip
880
+ }
480
881
  },
481
882
  onError: (err) => {
482
883
  log.error?.(`[sesame] Reply failed: ${String(err)}`);
483
884
  },
484
885
  });
886
+ // Add onPartialReply for real-time streaming (token-by-token updates)
887
+ const streamingReplyOptions = { ...replyOptions };
888
+ if (sesameStreamMode === "partial") {
889
+ streamingReplyOptions.onPartialReply = async (payload) => {
890
+ const text = payload.text;
891
+ if (!text)
892
+ return;
893
+ streamPendingText = text;
894
+ if (!streamMessageId && !streamSending) {
895
+ streamSending = true;
896
+ streamMessageId = await sendNewMessage(text);
897
+ streamSending = false;
898
+ if (streamMessageId) {
899
+ log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
900
+ }
901
+ }
902
+ else if (streamMessageId) {
903
+ scheduleStreamEdit();
904
+ }
905
+ };
906
+ }
485
907
  await core.channel.reply.dispatchReplyFromConfig({
486
908
  ctx: inboundCtx,
487
909
  cfg,
488
910
  dispatcher,
489
- replyOptions,
911
+ replyOptions: streamingReplyOptions,
490
912
  });
491
913
  typingStopped = true;
492
914
  clearInterval(typingInterval);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "sesame",
3
3
  "name": "Sesame",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
5
5
  "description": "Connect your OpenClaw agent to Sesame — the agent-native messaging platform",
6
6
  "channels": ["sesame"],
7
7
  "channel": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sesamespace/sesame",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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,6 +56,7 @@
56
56
  "ws": "^8.18.0"
57
57
  },
58
58
  "devDependencies": {
59
+ "@types/node": "^25.5.0",
59
60
  "typescript": "^5.9.3"
60
61
  }
61
62
  }