@sesamespace/sesame 0.2.3 → 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.
Files changed (2) hide show
  1. package/dist/index.js +266 -39
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -16,6 +16,9 @@
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
  }
@@ -146,6 +149,30 @@ const sesameChannelPlugin = {
146
149
  if (!account?.apiKey)
147
150
  throw new Error("Sesame not configured");
148
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
+ }
149
176
  const response = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
150
177
  method: "POST",
151
178
  headers: {
@@ -304,6 +331,90 @@ const sesameChannelPlugin = {
304
331
  },
305
332
  },
306
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
+ }
307
418
  // ── WebSocket connection ──
308
419
  async function connect(account, ctx) {
309
420
  const log = ctx.log ?? getLogger();
@@ -411,15 +522,33 @@ function handleEvent(event, account, state, ctx) {
411
522
  }, event.heartbeatIntervalMs ?? 30000);
412
523
  log.info?.("[sesame] Authenticated successfully");
413
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
+ }
414
529
  break;
415
530
  case "message": {
416
531
  const msg = event.message ?? event.data;
417
- // Skip voice messages without transcription wait for voice.transcribed event
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.
418
535
  if (msg?.kind === "voice") {
419
536
  const meta = msg.metadata ?? {};
420
537
  const transcript = meta.transcript;
421
- if (!transcript) {
422
- log.info?.(`[sesame] Voice message ${msg.id} waiting for transcription`);
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);
423
552
  break;
424
553
  }
425
554
  }
@@ -437,6 +566,9 @@ function handleEvent(event, account, state, ctx) {
437
566
  log.warn?.("[sesame] voice.transcribed event missing fields");
438
567
  break;
439
568
  }
569
+ // Clear pending fallback so we don't double-deliver
570
+ const pendingKey = `voice:${messageId}`;
571
+ state.sentMessageIds.delete(pendingKey);
440
572
  log.info?.(`[sesame] Voice transcribed for ${messageId}: "${transcript.slice(0, 80)}"`);
441
573
  // Build a synthetic message object so handleMessage can process it
442
574
  handleMessage({
@@ -484,8 +616,21 @@ async function handleMessage(message, account, state, ctx) {
484
616
  // For voice messages, prefer transcript from metadata over content
485
617
  const msgMeta = message.metadata ?? {};
486
618
  const voiceTranscript = message.kind === "voice" ? msgMeta.transcript : undefined;
487
- const rawText = voiceTranscript ?? message.content ?? message.plaintext ?? message.body ?? message.text ?? "";
488
- const bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
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
+ }
489
634
  const channelId = message.channelId;
490
635
  const messageId = message.id;
491
636
  log.info?.(`[sesame] Message from ${message.senderId} in ${channelId}: "${bodyText.slice(0, 100)}"`);
@@ -610,12 +755,23 @@ async function handleMessage(message, account, state, ctx) {
610
755
  sessionKey: inboundCtx.SessionKey ?? sesameSessionKey,
611
756
  ctx: inboundCtx,
612
757
  });
613
- // 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";
614
760
  const replyBuffer = [];
615
- const flushBuffer = async () => {
616
- const fullReply = replyBuffer.join("\n\n").trim();
617
- if (!fullReply)
618
- return;
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) => {
619
775
  // Outbound circuit breaker: max 5 messages per 10s window
620
776
  const cbNow = Date.now();
621
777
  if (cbNow - state.outboundWindowStart > 10000) {
@@ -623,45 +779,78 @@ async function handleMessage(message, account, state, ctx) {
623
779
  state.outboundWindowStart = cbNow;
624
780
  }
625
781
  if (state.recentOutboundCount >= 5) {
626
- log.error?.(`[sesame] Circuit breaker: suppressing outbound message (${state.recentOutboundCount} msgs in ${Math.round((cbNow - state.outboundWindowStart) / 1000)}s)`);
627
- return;
782
+ log.error?.(`[sesame] Circuit breaker: suppressing outbound message`);
783
+ return null;
628
784
  }
629
- log.info?.(`[sesame] Flushing buffered reply (${fullReply.length} chars): "${fullReply.slice(0, 100)}"`);
630
785
  const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
631
786
  method: "POST",
632
787
  headers: {
633
788
  "Content-Type": "application/json",
634
789
  Authorization: `Bearer ${account.apiKey}`,
635
790
  },
636
- body: JSON.stringify({
637
- content: fullReply,
638
- kind: "text",
639
- intent: "chat",
640
- }),
791
+ body: JSON.stringify({ content: text, kind: "text", intent: "chat" }),
641
792
  });
642
793
  if (!res.ok) {
643
794
  const err = await res.text().catch(() => "");
644
795
  log.error?.(`[sesame] Send failed (${res.status}): ${err.slice(0, 200)}`);
796
+ return null;
645
797
  }
646
- else {
647
- log.info?.(`[sesame] Sent buffered reply to ${channelId} (${res.status})`);
648
- // Track sent message ID to detect self-echoes
649
- try {
650
- const resData = (await res.clone().json().catch(() => ({})));
651
- const sentId = resData.data?.id ?? resData.id;
652
- if (sentId) {
653
- state.sentMessageIds.add(sentId);
654
- // Cap set size at 1000
655
- if (state.sentMessageIds.size > 1000) {
656
- const first = state.sentMessageIds.values().next().value;
657
- if (first)
658
- state.sentMessageIds.delete(first);
659
- }
660
- }
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);
661
825
  }
662
- catch { }
663
- // Track outbound count for circuit breaker
664
- state.recentOutboundCount++;
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`);
849
+ }
850
+ else {
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)`);
665
854
  }
666
855
  };
667
856
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
@@ -670,18 +859,56 @@ async function handleMessage(message, account, state, ctx) {
670
859
  onIdle: () => clearInterval(typingInterval),
671
860
  deliver: async (payload) => {
672
861
  const replyText = payload.text ?? payload.body ?? payload.content ?? "";
673
- if (replyText)
674
- 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
+ }
675
881
  },
676
882
  onError: (err) => {
677
883
  log.error?.(`[sesame] Reply failed: ${String(err)}`);
678
884
  },
679
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
+ }
680
907
  await core.channel.reply.dispatchReplyFromConfig({
681
908
  ctx: inboundCtx,
682
909
  cfg,
683
910
  dispatcher,
684
- replyOptions,
911
+ replyOptions: streamingReplyOptions,
685
912
  });
686
913
  typingStopped = true;
687
914
  clearInterval(typingInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sesamespace/sesame",
3
- "version": "0.2.3",
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,7 +56,7 @@
56
56
  "ws": "^8.18.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^25.3.3",
59
+ "@types/node": "^25.5.0",
60
60
  "typescript": "^5.9.3"
61
61
  }
62
62
  }