@hywkp/sider 0.0.3 → 0.0.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.
@@ -1,34 +1,11 @@
1
1
  import { DEFAULT_ACCOUNT_ID, createReplyPrefixOptions, createTypingCallbacks, formatTextWithAttachmentLinks, normalizeAccountId, resolveOutboundMediaUrls, } from "openclaw/plugin-sdk";
2
+ import { resolveInboundSiderMedia } from "./inbound-media.js";
3
+ import { buildSiderPartsFromReplyPayload } from "./media-upload.js";
2
4
  const CHANNEL_ID = "sider";
3
5
  const DEFAULT_GATEWAY_URL = "http://47.82.167.142:3001";
4
6
  const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
5
7
  const DEFAULT_SEND_TIMEOUT_MS = 12_000;
6
8
  const DEFAULT_RECONNECT_DELAY_MS = 2_000;
7
- const IMAGE_EXTENSIONS = new Set([
8
- ".jpg",
9
- ".jpeg",
10
- ".png",
11
- ".gif",
12
- ".webp",
13
- ".bmp",
14
- ".svg",
15
- ".heic",
16
- ".heif",
17
- ".avif",
18
- ]);
19
- const MIME_BY_EXTENSION = {
20
- ".jpg": "image/jpeg",
21
- ".jpeg": "image/jpeg",
22
- ".png": "image/png",
23
- ".gif": "image/gif",
24
- ".webp": "image/webp",
25
- ".bmp": "image/bmp",
26
- ".svg": "image/svg+xml",
27
- ".pdf": "application/pdf",
28
- ".txt": "text/plain",
29
- ".json": "application/json",
30
- ".zip": "application/zip",
31
- };
32
9
  const meta = {
33
10
  id: CHANNEL_ID,
34
11
  label: "Sider",
@@ -40,6 +17,9 @@ const meta = {
40
17
  order: 97,
41
18
  };
42
19
  let runtimeRef = null;
20
+ const SIDER_SESSION_BINDING_TTL_MS = 6 * 60 * 60 * 1000;
21
+ const SIDER_SESSION_BINDING_MAX = 512;
22
+ const siderSessionBindings = new Map();
43
23
  export function setSiderRuntime(runtime) {
44
24
  runtimeRef = runtime;
45
25
  }
@@ -65,6 +45,96 @@ function logWarn(message, data) {
65
45
  function sleep(ms) {
66
46
  return new Promise((resolve) => setTimeout(resolve, ms));
67
47
  }
48
+ function normalizeSessionBindingKey(raw) {
49
+ const key = raw?.trim().toLowerCase();
50
+ return key || undefined;
51
+ }
52
+ function pruneSiderSessionBindings(now = Date.now()) {
53
+ for (const [key, binding] of siderSessionBindings) {
54
+ if (now - binding.lastSeenAt > SIDER_SESSION_BINDING_TTL_MS) {
55
+ siderSessionBindings.delete(key);
56
+ }
57
+ }
58
+ if (siderSessionBindings.size <= SIDER_SESSION_BINDING_MAX) {
59
+ return;
60
+ }
61
+ const sorted = [...siderSessionBindings.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
62
+ const overflow = siderSessionBindings.size - SIDER_SESSION_BINDING_MAX;
63
+ for (let index = 0; index < overflow; index += 1) {
64
+ const key = sorted[index]?.[0];
65
+ if (key) {
66
+ siderSessionBindings.delete(key);
67
+ }
68
+ }
69
+ }
70
+ function rememberSiderSessionBinding(params) {
71
+ const key = normalizeSessionBindingKey(params.sessionKey);
72
+ if (!key) {
73
+ return;
74
+ }
75
+ const now = Date.now();
76
+ const existing = siderSessionBindings.get(key);
77
+ if (existing) {
78
+ existing.account = params.account;
79
+ existing.sessionId = params.sessionId;
80
+ existing.lastSeenAt = now;
81
+ pruneSiderSessionBindings(now);
82
+ return;
83
+ }
84
+ siderSessionBindings.set(key, {
85
+ account: params.account,
86
+ sessionId: params.sessionId,
87
+ lastSeenAt: now,
88
+ toolSeq: 0,
89
+ currentToolCallId: undefined,
90
+ callIdByToolCallId: new Map(),
91
+ });
92
+ pruneSiderSessionBindings(now);
93
+ }
94
+ function resolveSiderSessionBinding(sessionKey) {
95
+ const key = normalizeSessionBindingKey(sessionKey);
96
+ if (!key) {
97
+ return undefined;
98
+ }
99
+ const binding = siderSessionBindings.get(key);
100
+ if (!binding) {
101
+ return undefined;
102
+ }
103
+ binding.lastSeenAt = Date.now();
104
+ return binding;
105
+ }
106
+ function toJsonSafeValue(value) {
107
+ if (value === undefined) {
108
+ return undefined;
109
+ }
110
+ try {
111
+ return JSON.parse(JSON.stringify(value));
112
+ }
113
+ catch {
114
+ return String(value);
115
+ }
116
+ }
117
+ function extractToolResultText(result) {
118
+ const maxChars = 4000;
119
+ const clip = (text) => {
120
+ if (text.length <= maxChars) {
121
+ return text;
122
+ }
123
+ return `${text.slice(0, maxChars)}...`;
124
+ };
125
+ const record = toRecord(result);
126
+ if (!record) {
127
+ return "";
128
+ }
129
+ const candidateKeys = ["text", "content", "message", "output", "stdout"];
130
+ for (const key of candidateKeys) {
131
+ const value = record[key];
132
+ if (typeof value === "string" && value.trim()) {
133
+ return clip(value.trim());
134
+ }
135
+ }
136
+ return "";
137
+ }
68
138
  function toRecord(value) {
69
139
  if (!value || typeof value !== "object" || Array.isArray(value)) {
70
140
  return null;
@@ -138,6 +208,26 @@ function parseSessionTarget(raw) {
138
208
  }
139
209
  return trimmed;
140
210
  }
211
+ function normalizeSiderMessagingTarget(raw) {
212
+ const trimmed = raw.trim();
213
+ if (!trimmed) {
214
+ return undefined;
215
+ }
216
+ const withoutProviderPrefix = trimmed.replace(/^sider:/i, "").trim();
217
+ return withoutProviderPrefix || undefined;
218
+ }
219
+ function looksLikeSiderTargetId(raw, normalized) {
220
+ const candidate = (normalized ?? raw ?? "").trim();
221
+ if (!candidate) {
222
+ return false;
223
+ }
224
+ const match = candidate.match(/^session:(.+)$/i);
225
+ if (match) {
226
+ return match[1].trim().length > 0;
227
+ }
228
+ // Sider has no directory lookup yet; treat non-whitespace tokens as explicit ids.
229
+ return !/\s/.test(candidate);
230
+ }
141
231
  function resolveOutboundSessionId(params) {
142
232
  const target = params.to?.trim() || params.account.defaultTo?.trim() || "";
143
233
  if (!target) {
@@ -415,94 +505,6 @@ async function sendSiderEventBestEffort(params) {
415
505
  });
416
506
  }
417
507
  }
418
- function extFromMediaUrl(raw) {
419
- try {
420
- if (/^https?:\/\//i.test(raw)) {
421
- const pathname = new URL(raw).pathname;
422
- return pathname.includes(".") ? pathname.slice(pathname.lastIndexOf(".")).toLowerCase() : "";
423
- }
424
- }
425
- catch {
426
- // no-op
427
- }
428
- const qPos = raw.indexOf("?");
429
- const sanitized = qPos >= 0 ? raw.slice(0, qPos) : raw;
430
- const slashPos = sanitized.lastIndexOf("/");
431
- const last = slashPos >= 0 ? sanitized.slice(slashPos + 1) : sanitized;
432
- const dotPos = last.lastIndexOf(".");
433
- return dotPos >= 0 ? last.slice(dotPos).toLowerCase() : "";
434
- }
435
- function fileNameFromMediaUrl(raw) {
436
- try {
437
- if (/^https?:\/\//i.test(raw)) {
438
- const pathname = new URL(raw).pathname;
439
- const seg = pathname.split("/").filter(Boolean).pop();
440
- return seg || undefined;
441
- }
442
- }
443
- catch {
444
- // no-op
445
- }
446
- const qPos = raw.indexOf("?");
447
- const sanitized = qPos >= 0 ? raw.slice(0, qPos) : raw;
448
- const seg = sanitized.split("/").filter(Boolean).pop();
449
- return seg || undefined;
450
- }
451
- function inferMediaKind(mediaUrl) {
452
- const ext = extFromMediaUrl(mediaUrl);
453
- return IMAGE_EXTENSIONS.has(ext) ? "image" : "file";
454
- }
455
- function inferMediaMimeType(mediaUrl) {
456
- const ext = extFromMediaUrl(mediaUrl);
457
- return MIME_BY_EXTENSION[ext];
458
- }
459
- async function uploadMediaToSider(params) {
460
- const mediaKind = inferMediaKind(params.mediaUrl);
461
- const fileName = fileNameFromMediaUrl(params.mediaUrl);
462
- const mimeType = inferMediaMimeType(params.mediaUrl);
463
- // TODO(siderclaw-upload): Upload local/remote media to sider server and return hosted resource URL.
464
- // Current placeholder returns the original mediaUrl directly.
465
- const resourceUrl = params.mediaUrl;
466
- void params.account;
467
- return {
468
- resourceUrl,
469
- mediaKind,
470
- mimeType,
471
- fileName,
472
- };
473
- }
474
- async function buildSiderPartsFromReplyPayload(params) {
475
- const parts = [];
476
- const text = params.payload.text?.trim();
477
- if (text) {
478
- parts.push({
479
- type: "core.text",
480
- spec_version: 1,
481
- payload: { text },
482
- });
483
- }
484
- const mediaUrls = resolveOutboundMediaUrls(params.payload);
485
- for (const mediaUrl of mediaUrls) {
486
- const uploaded = await uploadMediaToSider({
487
- account: params.account,
488
- mediaUrl,
489
- });
490
- parts.push({
491
- type: "core.media",
492
- spec_version: 1,
493
- payload: {
494
- media_type: uploaded.mediaKind,
495
- url: uploaded.resourceUrl,
496
- mime_type: uploaded.mimeType,
497
- file_name: uploaded.fileName,
498
- },
499
- meta: {
500
- source_media_url: mediaUrl,
501
- },
502
- });
503
- }
504
- return parts;
505
- }
506
508
  function parseTextFromPart(part) {
507
509
  if (part.type !== "core.text") {
508
510
  return null;
@@ -519,21 +521,27 @@ function parseTextFromPart(part) {
519
521
  return null;
520
522
  }
521
523
  function parseMediaFromPart(part) {
522
- if (part.type !== "core.media") {
524
+ if (part.type !== "core.media" && part.type !== "core.file") {
523
525
  return null;
524
526
  }
525
527
  const payload = toRecord(part.payload);
526
528
  if (!payload) {
527
529
  return null;
528
530
  }
529
- const urlCandidates = [payload.url, payload.resource_url, payload.media_url];
531
+ const urlCandidates = [payload.download_url, payload.url, payload.resource_url, payload.media_url];
530
532
  const url = urlCandidates.find((entry) => typeof entry === "string" && entry.trim());
531
533
  if (!url) {
532
534
  return null;
533
535
  }
534
- const mimeCandidates = [payload.mime_type, payload.content_type];
536
+ const mimeCandidates = [payload.mime, payload.mime_type, payload.content_type];
535
537
  const mimeType = mimeCandidates.find((entry) => typeof entry === "string" && entry.trim());
536
- return { url: url.trim(), mimeType: mimeType?.trim() || undefined };
538
+ const fileNameCandidates = [payload.name, payload.file_name];
539
+ const fileName = fileNameCandidates.find((entry) => typeof entry === "string" && entry.trim());
540
+ return {
541
+ url: url.trim(),
542
+ mimeType: mimeType?.trim() || undefined,
543
+ fileName: fileName?.trim() || undefined,
544
+ };
537
545
  }
538
546
  function buildEventMeta(params) {
539
547
  return {
@@ -594,83 +602,409 @@ function buildStreamingDoneEvent(params) {
594
602
  meta: buildEventMeta({ accountId: params.accountId }),
595
603
  };
596
604
  }
597
- async function deliverReplyPayloadToSider(params) {
598
- if (params.kind === "block") {
599
- const delta = typeof params.payload.text === "string" ? params.payload.text : "";
600
- if (delta.length === 0) {
601
- return;
602
- }
603
- if (!params.streamState.active || !params.streamState.streamId) {
604
- params.streamState.active = true;
605
- params.streamState.streamId = crypto.randomUUID();
606
- params.streamState.seq = 0;
607
- const streamStartEvent = buildStreamingStartEvent({
608
- sessionId: params.sessionId,
609
- streamId: params.streamState.streamId,
610
- accountId: params.account.accountId,
611
- });
612
- await sendSiderEventBestEffort({
613
- account: params.account,
614
- sessionId: params.sessionId,
615
- event: streamStartEvent,
616
- context: "stream.start",
617
- });
605
+ function buildToolCallEvent(params) {
606
+ return {
607
+ eventType: "tool.call",
608
+ payload: {
609
+ session_id: params.sessionId,
610
+ seq: params.seq,
611
+ call_id: params.callId,
612
+ phase: params.phase,
613
+ tool_name: params.toolName,
614
+ tool_call_id: params.toolCallId,
615
+ run_id: params.runId,
616
+ session_key: params.sessionKey,
617
+ tool_args: params.toolArgs,
618
+ error: params.error,
619
+ duration_ms: params.durationMs,
620
+ ts: Date.now(),
621
+ },
622
+ meta: buildEventMeta({ accountId: params.accountId }),
623
+ };
624
+ }
625
+ function buildToolResultEvent(params) {
626
+ const text = extractToolResultText(params.result);
627
+ const safeResult = toJsonSafeValue(params.result);
628
+ const safeToolArgs = toJsonSafeValue(params.toolArgs);
629
+ return {
630
+ eventType: "tool.result",
631
+ payload: {
632
+ session_id: params.sessionId,
633
+ seq: params.seq,
634
+ call_id: params.callId,
635
+ tool_name: params.toolName,
636
+ tool_call_id: params.toolCallId,
637
+ run_id: params.runId,
638
+ session_key: params.sessionKey,
639
+ tool_args: safeToolArgs,
640
+ result: safeResult,
641
+ error: params.error,
642
+ duration_ms: params.durationMs,
643
+ text,
644
+ has_text: text.trim().length > 0,
645
+ media_urls: [],
646
+ media_count: 0,
647
+ is_error: Boolean(params.error),
648
+ ts: Date.now(),
649
+ },
650
+ meta: buildEventMeta({ accountId: params.accountId }),
651
+ };
652
+ }
653
+ function resolveRelayCallIdForToolEvent(params) {
654
+ if (params.toolCallId) {
655
+ const existing = params.binding.callIdByToolCallId.get(params.toolCallId);
656
+ if (existing) {
657
+ params.binding.currentToolCallId = existing;
658
+ return existing;
618
659
  }
619
- params.streamState.seq += 1;
620
- const streamDeltaEvent = buildStreamingDeltaEvent({
621
- sessionId: params.sessionId,
622
- streamId: params.streamState.streamId,
623
- seq: params.streamState.seq,
624
- delta,
625
- accountId: params.account.accountId,
660
+ const created = crypto.randomUUID();
661
+ params.binding.callIdByToolCallId.set(params.toolCallId, created);
662
+ params.binding.currentToolCallId = created;
663
+ return created;
664
+ }
665
+ if (params.phase === "start" || !params.binding.currentToolCallId) {
666
+ params.binding.currentToolCallId = crypto.randomUUID();
667
+ }
668
+ return params.binding.currentToolCallId;
669
+ }
670
+ function clearRelayCallIdForToolEvent(params) {
671
+ if (params.toolCallId) {
672
+ params.binding.callIdByToolCallId.delete(params.toolCallId);
673
+ }
674
+ if (params.binding.currentToolCallId === params.callId) {
675
+ params.binding.currentToolCallId = undefined;
676
+ }
677
+ }
678
+ export async function emitSiderToolHookEvent(params) {
679
+ const binding = resolveSiderSessionBinding(params.sessionKey);
680
+ if (!binding) {
681
+ logDebug("skip sider tool hook event: session binding not found", {
682
+ sessionKey: params.sessionKey,
683
+ toolName: params.toolName,
684
+ toolCallId: params.toolCallId,
685
+ phase: params.phase,
626
686
  });
627
- logDebug("sending stream delta event", {
628
- accountId: params.account.accountId,
629
- sessionId: params.sessionId,
630
- eventType: streamDeltaEvent.eventType,
631
- streamId: params.streamState.streamId,
632
- seq: params.streamState.seq,
633
- deltaLength: delta.length,
687
+ return;
688
+ }
689
+ const callId = resolveRelayCallIdForToolEvent({
690
+ binding,
691
+ phase: params.phase,
692
+ toolCallId: params.toolCallId,
693
+ });
694
+ if (params.phase === "start") {
695
+ binding.toolSeq += 1;
696
+ const toolCallEvent = buildToolCallEvent({
697
+ sessionId: binding.sessionId,
698
+ accountId: binding.account.accountId,
699
+ seq: binding.toolSeq,
700
+ callId,
701
+ phase: "start",
702
+ toolName: params.toolName,
703
+ toolCallId: params.toolCallId,
704
+ runId: params.runId,
705
+ sessionKey: params.sessionKey,
706
+ toolArgs: params.params,
707
+ error: params.error,
708
+ durationMs: params.durationMs,
634
709
  });
635
710
  await sendSiderEventBestEffort({
636
- account: params.account,
637
- sessionId: params.sessionId,
638
- event: streamDeltaEvent,
639
- context: "stream.delta",
711
+ account: binding.account,
712
+ sessionId: binding.sessionId,
713
+ event: toolCallEvent,
714
+ context: "tool.call.hook.start",
640
715
  });
641
- return;
642
716
  }
717
+ if (params.phase !== "start") {
718
+ binding.toolSeq += 1;
719
+ const toolResultEvent = buildToolResultEvent({
720
+ sessionId: binding.sessionId,
721
+ accountId: binding.account.accountId,
722
+ seq: binding.toolSeq,
723
+ callId,
724
+ toolName: params.toolName,
725
+ toolCallId: params.toolCallId,
726
+ runId: params.runId,
727
+ sessionKey: params.sessionKey,
728
+ toolArgs: params.params,
729
+ result: params.result,
730
+ error: params.error,
731
+ durationMs: params.durationMs,
732
+ });
733
+ await sendSiderEventBestEffort({
734
+ account: binding.account,
735
+ sessionId: binding.sessionId,
736
+ event: toolResultEvent,
737
+ context: `tool.result.hook.${params.phase}`,
738
+ });
739
+ clearRelayCallIdForToolEvent({
740
+ binding,
741
+ callId,
742
+ toolCallId: params.toolCallId,
743
+ });
744
+ }
745
+ }
746
+ async function openStreamingSessionIfNeeded(params) {
643
747
  if (params.streamState.active && params.streamState.streamId) {
644
- params.streamState.seq += 1;
645
- const streamDoneEvent = buildStreamingDoneEvent({
646
- sessionId: params.sessionId,
647
- streamId: params.streamState.streamId,
648
- seq: params.streamState.seq,
748
+ return;
749
+ }
750
+ params.streamState.active = true;
751
+ params.streamState.streamId = crypto.randomUUID();
752
+ params.streamState.seq = 0;
753
+ const streamStartEvent = buildStreamingStartEvent({
754
+ sessionId: params.sessionId,
755
+ streamId: params.streamState.streamId,
756
+ accountId: params.account.accountId,
757
+ });
758
+ await sendSiderEventBestEffort({
759
+ account: params.account,
760
+ sessionId: params.sessionId,
761
+ event: streamStartEvent,
762
+ context: "stream.start",
763
+ });
764
+ }
765
+ async function sendStreamingDeltaEvent(params) {
766
+ if (!params.delta) {
767
+ return;
768
+ }
769
+ await openStreamingSessionIfNeeded({
770
+ account: params.account,
771
+ sessionId: params.sessionId,
772
+ streamState: params.streamState,
773
+ });
774
+ params.streamState.seq += 1;
775
+ const streamDeltaEvent = buildStreamingDeltaEvent({
776
+ sessionId: params.sessionId,
777
+ streamId: params.streamState.streamId,
778
+ seq: params.streamState.seq,
779
+ delta: params.delta,
780
+ accountId: params.account.accountId,
781
+ });
782
+ logDebug("sending stream delta event", {
783
+ accountId: params.account.accountId,
784
+ sessionId: params.sessionId,
785
+ eventType: streamDeltaEvent.eventType,
786
+ streamId: params.streamState.streamId,
787
+ seq: params.streamState.seq,
788
+ deltaLength: params.delta.length,
789
+ context: params.context,
790
+ });
791
+ await sendSiderEventBestEffort({
792
+ account: params.account,
793
+ sessionId: params.sessionId,
794
+ event: streamDeltaEvent,
795
+ context: params.context,
796
+ });
797
+ }
798
+ async function closeStreamingSessionIfActive(params) {
799
+ if (!params.streamState.active || !params.streamState.streamId) {
800
+ return;
801
+ }
802
+ params.streamState.seq += 1;
803
+ const streamDoneEvent = buildStreamingDoneEvent({
804
+ sessionId: params.sessionId,
805
+ streamId: params.streamState.streamId,
806
+ seq: params.streamState.seq,
807
+ accountId: params.account.accountId,
808
+ reason: params.reason,
809
+ });
810
+ await sendSiderEventBestEffort({
811
+ account: params.account,
812
+ sessionId: params.sessionId,
813
+ event: streamDoneEvent,
814
+ context: params.context,
815
+ });
816
+ params.streamState.active = false;
817
+ params.streamState.streamId = undefined;
818
+ params.streamState.seq = 0;
819
+ }
820
+ function enqueueStreamEventTask(params) {
821
+ const next = params.streamState.streamEventQueue.then(params.task).catch((err) => {
822
+ logWarn("sider stream event queue task failed", {
823
+ error: String(err),
824
+ });
825
+ });
826
+ params.streamState.streamEventQueue = next;
827
+ return next;
828
+ }
829
+ async function flushStreamEventQueue(streamState) {
830
+ await streamState.streamEventQueue;
831
+ }
832
+ function derivePartialDelta(params) {
833
+ const prev = params.previous;
834
+ const next = params.next;
835
+ if (!next) {
836
+ return "";
837
+ }
838
+ if (!prev) {
839
+ return next;
840
+ }
841
+ if (next === prev) {
842
+ return "";
843
+ }
844
+ if (next.startsWith(prev)) {
845
+ return next.slice(prev.length);
846
+ }
847
+ if (prev.startsWith(next)) {
848
+ // Snapshot regressed (restart/truncation); wait for next stable snapshot.
849
+ return "";
850
+ }
851
+ const maxOverlap = Math.min(prev.length, next.length);
852
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
853
+ if (prev.endsWith(next.slice(0, overlap))) {
854
+ return next.slice(overlap);
855
+ }
856
+ }
857
+ return next;
858
+ }
859
+ function mergeBlockTextIntoStreamState(params) {
860
+ const next = params.text;
861
+ if (!next) {
862
+ return;
863
+ }
864
+ const prev = params.streamState.accumulatedBlockText;
865
+ if (!prev) {
866
+ params.streamState.accumulatedBlockText = next;
867
+ return;
868
+ }
869
+ if (next.startsWith(prev)) {
870
+ // Some runtimes emit cumulative text snapshots.
871
+ params.streamState.accumulatedBlockText = next;
872
+ return;
873
+ }
874
+ if (prev.endsWith(next)) {
875
+ // Avoid duplicating identical trailing chunks.
876
+ return;
877
+ }
878
+ params.streamState.accumulatedBlockText += next;
879
+ }
880
+ async function handleStreamingPartialSnapshot(params) {
881
+ if (!params.snapshot) {
882
+ return;
883
+ }
884
+ const delta = derivePartialDelta({
885
+ previous: params.streamState.partialSnapshot,
886
+ next: params.snapshot,
887
+ });
888
+ params.streamState.partialSnapshot = params.snapshot;
889
+ if (!delta) {
890
+ return;
891
+ }
892
+ await sendStreamingDeltaEvent({
893
+ account: params.account,
894
+ sessionId: params.sessionId,
895
+ streamState: params.streamState,
896
+ delta,
897
+ context: "stream.delta.partial",
898
+ });
899
+ params.streamState.partialDeltaCount += 1;
900
+ // Partial callbacks carry the latest visible assistant snapshot.
901
+ params.streamState.accumulatedBlockText = params.snapshot;
902
+ }
903
+ async function deliverReplyPayloadToSider(params) {
904
+ const payloadMediaUrls = resolveOutboundMediaUrls(params.payload);
905
+ if (params.kind === "block") {
906
+ const delta = typeof params.payload.text === "string" ? params.payload.text : "";
907
+ if (delta.length > 0) {
908
+ params.streamState.blockDeltaCount += 1;
909
+ mergeBlockTextIntoStreamState({
910
+ streamState: params.streamState,
911
+ text: delta,
912
+ });
913
+ if (params.streamState.partialDeltaCount === 0) {
914
+ await sendStreamingDeltaEvent({
915
+ account: params.account,
916
+ sessionId: params.sessionId,
917
+ streamState: params.streamState,
918
+ delta,
919
+ context: "stream.delta.block",
920
+ });
921
+ }
922
+ else {
923
+ logDebug("skip block stream delta because partial streaming is active", {
924
+ accountId: params.account.accountId,
925
+ sessionId: params.sessionId,
926
+ deltaLength: delta.length,
927
+ partialDeltaCount: params.streamState.partialDeltaCount,
928
+ });
929
+ }
930
+ }
931
+ if (payloadMediaUrls.length === 0) {
932
+ return;
933
+ }
934
+ logDebug("block payload contains media; sending persisted sider message", {
649
935
  accountId: params.account.accountId,
650
- reason: "final",
936
+ sessionId: params.sessionId,
937
+ mediaCount: payloadMediaUrls.length,
938
+ hasText: delta.length > 0,
651
939
  });
652
- await sendSiderEventBestEffort({
940
+ }
941
+ if (params.kind === "final") {
942
+ await flushStreamEventQueue(params.streamState);
943
+ if (!params.streamState.active && params.streamState.blockDeltaCount === 0) {
944
+ const finalText = typeof params.payload.text === "string" ? params.payload.text : "";
945
+ if (finalText.length > 0) {
946
+ logDebug("no block delta observed; sending synthetic stream events from final payload", {
947
+ accountId: params.account.accountId,
948
+ sessionId: params.sessionId,
949
+ textLength: finalText.length,
950
+ });
951
+ await sendStreamingDeltaEvent({
952
+ account: params.account,
953
+ sessionId: params.sessionId,
954
+ streamState: params.streamState,
955
+ delta: finalText,
956
+ context: "stream.delta.final-fallback",
957
+ });
958
+ }
959
+ }
960
+ await closeStreamingSessionIfActive({
653
961
  account: params.account,
654
962
  sessionId: params.sessionId,
655
- event: streamDoneEvent,
963
+ streamState: params.streamState,
964
+ reason: "final",
656
965
  context: "stream.done",
657
966
  });
658
- params.streamState.active = false;
659
- params.streamState.streamId = undefined;
660
- params.streamState.seq = 0;
661
967
  }
662
968
  const parts = await buildSiderPartsFromReplyPayload({
663
969
  account: params.account,
970
+ sessionId: params.sessionId,
664
971
  payload: params.payload,
972
+ logger: {
973
+ debug: logDebug,
974
+ warn: logWarn,
975
+ },
665
976
  });
666
977
  if (parts.length === 0) {
978
+ logDebug("skip sider outbound message: empty parts", {
979
+ accountId: params.account.accountId,
980
+ sessionId: params.sessionId,
981
+ kind: params.kind,
982
+ hasText: typeof params.payload.text === "string" && params.payload.text.length > 0,
983
+ mediaCount: payloadMediaUrls.length,
984
+ });
667
985
  return;
668
986
  }
987
+ logDebug("sending sider outbound message from payload", {
988
+ accountId: params.account.accountId,
989
+ sessionId: params.sessionId,
990
+ kind: params.kind,
991
+ partTypes: parts.map((part) => part.type),
992
+ mediaCount: payloadMediaUrls.length,
993
+ });
669
994
  await sendMessageToSider({
670
995
  account: params.account,
671
996
  sessionId: params.sessionId,
672
997
  parts,
673
998
  });
999
+ if (params.kind === "final" || payloadMediaUrls.length > 0) {
1000
+ params.streamState.persistedFinalText = true;
1001
+ }
1002
+ if (params.kind === "final") {
1003
+ params.streamState.accumulatedBlockText = "";
1004
+ params.streamState.blockDeltaCount = 0;
1005
+ params.streamState.partialDeltaCount = 0;
1006
+ params.streamState.partialSnapshot = "";
1007
+ }
674
1008
  }
675
1009
  async function handleInboundRealtimeMessage(params) {
676
1010
  const { cfg, account, event } = params;
@@ -698,7 +1032,7 @@ async function handleInboundRealtimeMessage(params) {
698
1032
  }
699
1033
  const rawText = textChunks.join("\n").trim();
700
1034
  const mediaUrls = mediaItems.map((item) => item.url);
701
- const mediaTypes = mediaItems
1035
+ const parsedMediaTypes = mediaItems
702
1036
  .map((item) => item.mimeType)
703
1037
  .filter((item) => Boolean(item));
704
1038
  const hasControlCommand = rawText ? core.channel.text.hasControlCommand(rawText, cfg) : false;
@@ -737,7 +1071,27 @@ async function handleInboundRealtimeMessage(params) {
737
1071
  commandAuthorized,
738
1072
  });
739
1073
  }
740
- const bodyForAgent = formatTextWithAttachmentLinks(rawText, mediaUrls);
1074
+ const resolvedInboundMedia = await resolveInboundSiderMedia({
1075
+ runtime: core,
1076
+ cfg,
1077
+ accountId: account.accountId,
1078
+ mediaItems,
1079
+ logger: {
1080
+ debug: logDebug,
1081
+ warn: logWarn,
1082
+ },
1083
+ });
1084
+ const unresolvedMediaUrls = resolvedInboundMedia.unresolvedMedia.map((item) => item.url);
1085
+ if (unresolvedMediaUrls.length > 0) {
1086
+ logWarn("sider inbound media fallback to attachment url", {
1087
+ accountId: account.accountId,
1088
+ sessionId,
1089
+ unresolvedCount: unresolvedMediaUrls.length,
1090
+ unresolvedMediaUrls,
1091
+ });
1092
+ }
1093
+ const bodyForAgent = formatTextWithAttachmentLinks(rawText, unresolvedMediaUrls);
1094
+ const mediaPayload = resolvedInboundMedia.mediaPayload;
741
1095
  const route = core.channel.routing.resolveAgentRoute({
742
1096
  cfg,
743
1097
  channel: CHANNEL_ID,
@@ -779,12 +1133,21 @@ async function handleInboundRealtimeMessage(params) {
779
1133
  WasMentioned: true,
780
1134
  OriginatingChannel: CHANNEL_ID,
781
1135
  OriginatingTo: to,
782
- MediaUrl: mediaUrls[0],
783
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
784
- MediaType: mediaTypes[0],
785
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
1136
+ ...mediaPayload,
1137
+ MediaUrl: mediaPayload.MediaUrl ||
1138
+ (unresolvedMediaUrls.length > 0 ? unresolvedMediaUrls[0] : undefined),
1139
+ MediaUrls: mediaPayload.MediaUrls ||
1140
+ (unresolvedMediaUrls.length > 0 ? unresolvedMediaUrls : undefined),
1141
+ MediaType: mediaPayload.MediaType || parsedMediaTypes[0],
1142
+ MediaTypes: mediaPayload.MediaTypes ||
1143
+ (parsedMediaTypes.length > 0 ? parsedMediaTypes : undefined),
786
1144
  CommandAuthorized: commandAuthorized,
787
1145
  });
1146
+ rememberSiderSessionBinding({
1147
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1148
+ account,
1149
+ sessionId,
1150
+ });
788
1151
  const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
789
1152
  agentId: route.agentId,
790
1153
  });
@@ -813,11 +1176,19 @@ async function handleInboundRealtimeMessage(params) {
813
1176
  commandAuthorized,
814
1177
  textLength: rawText.length,
815
1178
  mediaCount: mediaUrls.length,
1179
+ mediaDownloadedCount: mediaPayload.MediaPaths?.length ?? 0,
1180
+ mediaDownloadFailedCount: unresolvedMediaUrls.length,
816
1181
  });
817
1182
  const streamState = {
818
1183
  active: false,
819
1184
  streamId: undefined,
820
1185
  seq: 0,
1186
+ blockDeltaCount: 0,
1187
+ partialDeltaCount: 0,
1188
+ partialSnapshot: "",
1189
+ accumulatedBlockText: "",
1190
+ persistedFinalText: false,
1191
+ streamEventQueue: Promise.resolve(),
821
1192
  };
822
1193
  const typingCallbacks = createTypingCallbacks({
823
1194
  start: async () => {
@@ -871,6 +1242,7 @@ async function handleInboundRealtimeMessage(params) {
871
1242
  });
872
1243
  },
873
1244
  });
1245
+ let dispatchCompleted = false;
874
1246
  try {
875
1247
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
876
1248
  ctx: ctxPayload,
@@ -879,6 +1251,13 @@ async function handleInboundRealtimeMessage(params) {
879
1251
  ...prefixOptions,
880
1252
  typingCallbacks,
881
1253
  deliver: async (payload, info) => {
1254
+ logDebug("sider dispatch deliver payload", {
1255
+ accountId: account.accountId,
1256
+ sessionId,
1257
+ kind: info.kind,
1258
+ hasText: typeof payload.text === "string" && payload.text.length > 0,
1259
+ mediaCount: resolveOutboundMediaUrls(payload).length,
1260
+ });
882
1261
  await deliverReplyPayloadToSider({
883
1262
  account,
884
1263
  sessionId,
@@ -894,9 +1273,28 @@ async function handleInboundRealtimeMessage(params) {
894
1273
  },
895
1274
  },
896
1275
  replyOptions: {
1276
+ disableBlockStreaming: false,
1277
+ onPartialReply: async (payload) => {
1278
+ const snapshot = typeof payload.text === "string" ? payload.text : "";
1279
+ if (!snapshot) {
1280
+ return;
1281
+ }
1282
+ await enqueueStreamEventTask({
1283
+ streamState,
1284
+ task: async () => {
1285
+ await handleStreamingPartialSnapshot({
1286
+ account,
1287
+ sessionId,
1288
+ streamState,
1289
+ snapshot,
1290
+ });
1291
+ },
1292
+ });
1293
+ },
897
1294
  onModelSelected,
898
1295
  },
899
1296
  });
1297
+ dispatchCompleted = true;
900
1298
  logDebug("completed sider inbound dispatch", {
901
1299
  accountId: account.accountId,
902
1300
  sessionId,
@@ -904,25 +1302,39 @@ async function handleInboundRealtimeMessage(params) {
904
1302
  });
905
1303
  }
906
1304
  finally {
907
- if (streamState.active && streamState.streamId) {
908
- streamState.seq += 1;
909
- const streamDoneEvent = buildStreamingDoneEvent({
910
- sessionId,
911
- streamId: streamState.streamId,
912
- seq: streamState.seq,
913
- accountId: account.accountId,
914
- reason: "interrupted",
915
- });
916
- await sendSiderEventBestEffort({
917
- account,
918
- sessionId,
919
- event: streamDoneEvent,
920
- context: "stream.done.interrupted",
921
- });
922
- streamState.active = false;
923
- streamState.streamId = undefined;
924
- streamState.seq = 0;
925
- }
1305
+ await flushStreamEventQueue(streamState);
1306
+ await closeStreamingSessionIfActive({
1307
+ account,
1308
+ sessionId,
1309
+ streamState,
1310
+ reason: dispatchCompleted ? "final" : "interrupted",
1311
+ context: dispatchCompleted ? "stream.done.dispatch-end" : "stream.done.interrupted",
1312
+ });
1313
+ streamState.blockDeltaCount = 0;
1314
+ }
1315
+ if (!dispatchCompleted) {
1316
+ return;
1317
+ }
1318
+ if (!streamState.persistedFinalText && streamState.accumulatedBlockText.trim()) {
1319
+ const text = streamState.accumulatedBlockText;
1320
+ logDebug("no final message payload observed; persisting accumulated block text as final message", {
1321
+ accountId: account.accountId,
1322
+ sessionId,
1323
+ textLength: text.length,
1324
+ });
1325
+ await sendMessageToSider({
1326
+ account,
1327
+ sessionId,
1328
+ parts: [
1329
+ {
1330
+ type: "core.text",
1331
+ spec_version: 1,
1332
+ payload: { text },
1333
+ },
1334
+ ],
1335
+ });
1336
+ streamState.persistedFinalText = true;
1337
+ streamState.accumulatedBlockText = "";
926
1338
  }
927
1339
  }
928
1340
  async function handleInboundRealtimeEvent(params) {
@@ -1153,7 +1565,7 @@ export const siderPlugin = {
1153
1565
  conversationId: result.conversationId,
1154
1566
  };
1155
1567
  },
1156
- sendMedia: async ({ cfg, accountId, to, text, mediaUrl }) => {
1568
+ sendMedia: async ({ cfg, accountId, to, text, mediaUrl, mediaLocalRoots }) => {
1157
1569
  const account = resolveSiderAccount(cfg, accountId);
1158
1570
  if (!account.configured) {
1159
1571
  throw new Error(`sider account "${account.accountId}" is not configured: missing gatewayUrl/sessionId(or sessionKey)/relayId`);
@@ -1161,10 +1573,16 @@ export const siderPlugin = {
1161
1573
  const sessionId = resolveOutboundSessionId({ account, to });
1162
1574
  const parts = await buildSiderPartsFromReplyPayload({
1163
1575
  account,
1576
+ sessionId,
1164
1577
  payload: {
1165
1578
  text,
1166
1579
  mediaUrl,
1167
1580
  },
1581
+ mediaLocalRoots,
1582
+ logger: {
1583
+ debug: logDebug,
1584
+ warn: logWarn,
1585
+ },
1168
1586
  });
1169
1587
  if (parts.length === 0) {
1170
1588
  throw new Error("sider sendMedia requires text and/or mediaUrl");
@@ -1197,5 +1615,12 @@ export const siderPlugin = {
1197
1615
  ctx.log?.info(`[${account.accountId}] sider relay monitor stopped`);
1198
1616
  },
1199
1617
  },
1618
+ messaging: {
1619
+ normalizeTarget: normalizeSiderMessagingTarget,
1620
+ targetResolver: {
1621
+ looksLikeId: looksLikeSiderTargetId,
1622
+ hint: "session:<sessionId> (or raw <sessionId>)",
1623
+ },
1624
+ },
1200
1625
  };
1201
1626
  //# sourceMappingURL=channel.js.map