@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.
Files changed (2) hide show
  1. package/dist/index.js +280 -62
  2. 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: { Authorization: `Bearer ${account.apiKey}` },
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: { Authorization: `Bearer ${account.apiKey}` } });
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
- // Skip voice messages without transcription wait for voice.transcribed event
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
- if (!transcript) {
422
- log.info?.(`[sesame] Voice message ${msg.id} waiting for transcription`);
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
- const rawText = voiceTranscript ?? message.content ?? message.plaintext ?? message.body ?? message.text ?? "";
488
- const bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
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: { Authorization: `Bearer ${account.apiKey}` } });
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
- // Buffer reply chunks and send as a single message
755
+ // ── Streaming reply: send initial message, then edit in place ──
756
+ const sesameStreamMode = cfg.channels?.sesame?.streamMode ?? "buffer";
614
757
  const replyBuffer = [];
615
- const flushBuffer = async () => {
616
- const fullReply = replyBuffer.join("\n\n").trim();
617
- if (!fullReply)
618
- return;
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 (${state.recentOutboundCount} msgs in ${Math.round((cbNow - state.outboundWindowStart) / 1000)}s)`);
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
- "Content-Type": "application/json",
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
- 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
- }
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
- catch { }
663
- // Track outbound count for circuit breaker
664
- state.recentOutboundCount++;
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
- replyBuffer.push(replyText);
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",
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.3.3",
59
+ "@types/node": "^25.5.0",
60
60
  "typescript": "^5.9.3"
61
61
  }
62
62
  }