@wix/himalaya-cli 0.889.0 → 0.891.0

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 (28) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +59 -4
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-comments.mjs +20 -7
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +42 -2
  4. package/dist/cli.mjs +63 -17
  5. package/guides/sdk-reference.json +29 -5
  6. package/package.json +2 -2
  7. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +197 -32
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  9. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +197 -32
  10. package/templates/community-club-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  11. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +197 -32
  12. package/templates/events-meetups-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  13. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +197 -32
  14. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  15. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +197 -32
  16. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  17. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +197 -32
  18. package/templates/owner-lite/tier5-src/_shared/aria-widgets.ts +181 -0
  19. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +197 -32
  20. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  21. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +197 -32
  22. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  23. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +197 -32
  24. package/templates/restaurant-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  25. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +197 -32
  26. package/templates/retail-store-owner/tier5-src/_shared/aria-widgets.ts +181 -0
  27. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +197 -32
  28. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widgets.ts +181 -0
@@ -34,9 +34,11 @@ import {
34
34
  messageText,
35
35
  messagesOf,
36
36
  sendMessage,
37
+ triggerAssistantMessage,
37
38
  type ConversationMessage,
38
39
  type ConversationSummary,
39
40
  } from "@himalaya/wix-genie-chat";
41
+ import { proseOfParts, scanEnvelopes } from "./aria-widgets.js";
40
42
 
41
43
  // ---------------------------------------------------------------------------
42
44
  // Shapes
@@ -420,20 +422,35 @@ export function plainBubble(id: string, role: string, who: string, content: stri
420
422
  }
421
423
 
422
424
  /** Server transcript → bubbles. USER text is literal; ASSISTANT replies are
423
- * markdown, so they are normalized and their links lifted into slots. */
425
+ * markdown, so they are normalized and their links lifted into slots.
426
+ *
427
+ * ORDER IS LOAD-BEARING. An assistant reply may carry inline widget envelopes
428
+ * (`<<{"widget":…}>>`), and they are scanned out FIRST, because `stripMarkdown`
429
+ * applied to a payload eats the underscores inside its JSON keys — which is how
430
+ * `SINGLE_SELECT_WIDGET` used to reach the screen as `SINGLESELECTWIDGET`, both
431
+ * corrupted and rendered as raw text. Everything downstream — the stripper, the
432
+ * link scan — sees the PROSE only, so a markdown link that exists inside a
433
+ * payload never becomes one of the bubble's three link rows either.
434
+ *
435
+ * The widgets themselves are dropped here; rendering them is #2605 onward. A
436
+ * reply that was nothing but a widget therefore has empty prose and is filtered
437
+ * out below, which is deliberate: an empty bubble is a visible defect. */
424
438
  export function toBubbles(msgs: ConversationMessage[], assistantName: string, youName = "You"): AriaBubble[] {
425
439
  return msgs
426
440
  .map((m, i) => {
427
441
  const raw = messageText(m);
428
442
  const role = m.role || "ASSISTANT";
443
+ // Only assistant turns carry envelopes; a user's own text is literal, and
444
+ // scanning it would let an owner who typed `<<…>>` erase their own message.
445
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(raw, 0, true).parts);
429
446
  return {
430
447
  id: m.id || `m${i}`,
431
448
  role,
432
449
  who: role === "USER" ? youName : assistantName,
433
- content: role === "USER" ? raw : stripMarkdown(raw),
450
+ content: role === "USER" ? prose : stripMarkdown(prose),
434
451
  notUser: role !== "USER",
435
452
  notAria: role === "USER",
436
- ...linkFieldsOf(raw, role),
453
+ ...linkFieldsOf(prose, role),
437
454
  };
438
455
  })
439
456
  .filter((b) => b.content && b.content.trim());
@@ -491,6 +508,34 @@ export function countRole(bubbles: AriaBubble[], role: string): number {
491
508
  return n;
492
509
  }
493
510
 
511
+ /** How many turns of one role the SERVER holds — the mark the turn is judged
512
+ * against. Counted over RAW messages, gated on the raw text being non-empty.
513
+ *
514
+ * Both halves are load-bearing, and they pull in opposite directions:
515
+ *
516
+ * - The emptiness gate is why this is not a plain `.length`. The service
517
+ * creates an assistant row before it has written anything into it, and an
518
+ * empty placeholder must not read as the answer that ended the turn. That is
519
+ * the reason `countRole` counted bubbles in the first place.
520
+ * - Counting RAW messages rather than bubbles is what this function adds, and
521
+ * it is a bug fix rather than a tidy-up. An assistant reply that is nothing
522
+ * but a widget envelope (`<<{"widget":…}>>`) is invisible to a bubble count:
523
+ * `toBubbles` lifts the envelope out, the prose left behind is empty, and the
524
+ * bubble is dropped so no blank row paints. `tick` then never saw the
525
+ * assistant turn arrive, so the screen sat in `sending` at the FAST cadence
526
+ * indefinitely — one request a second, forever, with the typing line stuck
527
+ * on. A widget-only reply is a real answer; it just has nothing to say in
528
+ * prose, and its raw text is non-empty, which is exactly what separates it
529
+ * from the placeholder above. */
530
+ export function countTurns(msgs: ConversationMessage[], role: string): number {
531
+ let n = 0;
532
+ for (const m of msgs) {
533
+ if ((m.role || "ASSISTANT") !== role) continue;
534
+ if (messageText(m).trim()) n++;
535
+ }
536
+ return n;
537
+ }
538
+
494
539
  // ---------------------------------------------------------------------------
495
540
  // Local recent-conversation list (himalaya.storage)
496
541
  // ---------------------------------------------------------------------------
@@ -637,14 +682,30 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
637
682
  * passed in — the only place a concurrent tap is visible is here. */
638
683
  let inFlightSend: string | null = null;
639
684
 
685
+ /** A turn the server STORED but never got asked to answer.
686
+ *
687
+ * Sending is two calls: persist, then trigger. When the second fails, the
688
+ * owner's message is already on the server — so the obvious recovery, tapping
689
+ * Send again, posts a SECOND copy of it and can have the agent answer both.
690
+ * Remembering the stored id turns that retry into a trigger of the turn that
691
+ * already exists.
692
+ *
693
+ * A latch rather than state, for the same reason `inFlightSend` is one: it is
694
+ * a transient recovery hint about one failed attempt, not something that
695
+ * should survive a reload or be persisted with the transcript. Keyed by
696
+ * conversation AND content so it can only ever re-trigger the exact turn it
697
+ * was recorded for. */
698
+ let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
699
+
640
700
  /** Refuse a send that is already on its way. State half + latch half. */
641
701
  const duplicate = (s: AriaChatState, content: string): boolean =>
642
702
  isDuplicateSend(s, content) || inFlightSend === content;
643
703
 
644
- /** Fold a freshly read server transcript into the observed-count marks. */
645
- const observed = (bubbles: AriaBubble[]) => ({
646
- _srvUsers: countRole(bubbles, "USER"),
647
- _srvAssistants: countRole(bubbles, "ASSISTANT"),
704
+ /** Fold a freshly read server transcript into the observed-count marks.
705
+ * Takes the RAW messages, not the bubbles — see `countTurns`. */
706
+ const observed = (msgs: ConversationMessage[]) => ({
707
+ _srvUsers: countTurns(msgs, "USER"),
708
+ _srvAssistants: countTurns(msgs, "ASSISTANT"),
648
709
  });
649
710
 
650
711
  const greetingOnly = (): AriaBubble[] => [plainBubble(GREETING_ID, "ASSISTANT", assistant(), res(o.greeting))];
@@ -692,16 +753,22 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
692
753
  * deliberately. */
693
754
  async function busyLoop(s: AriaChatState, convId: string, optimistic: AriaBubble[]): Promise<AriaChatState> {
694
755
  let server: AriaBubble[] = optimistic;
756
+ let seen: ConversationMessage[] = [];
695
757
  for (let n = 0; n < BUSY_LOOP_ROUNDS; n++) {
696
758
  const gm = await getConversationMessages(convId);
697
- const bubbles = toBubbles(messagesOf(gm), assistant(), you());
759
+ const raw = messagesOf(gm);
760
+ const bubbles = toBubbles(raw, assistant(), you());
698
761
  if (bubbles.length) server = bubbles;
699
- const last = bubbles[bubbles.length - 1];
700
- if (last && last.role === "ASSISTANT") {
701
- return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(bubbles), ok: false };
762
+ seen = raw;
763
+ // The turn is over when the SERVER holds an assistant answer — which a
764
+ // widget-only reply is, even though it leaves no bubble to inspect. Asking
765
+ // the last BUBBLE stalled this loop on exactly that reply, the same way it
766
+ // stalled `tick`.
767
+ if (countTurns(raw, "ASSISTANT") > s._replyAfter) {
768
+ return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
702
769
  }
703
770
  }
704
- return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(server), ok: false };
771
+ return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen), ok: false };
705
772
  }
706
773
 
707
774
  /** Send + wait, by whichever transport this consumer configured. */
@@ -727,13 +794,47 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
727
794
  // it never received.
728
795
  let convId = s._convId;
729
796
  let site = s._siteId;
797
+ // The stored id this attempt is RE-triggering, if it is one. Declared out
798
+ // here because the catch needs it: `triggerAssistantMessage` rejects with a
799
+ // plain transport error and no `storedUserMessageId` — only `sendMessage`
800
+ // attaches that, because only `sendMessage` is the call that stored the turn.
801
+ // Without this, a retry that failed AGAIN cleared the latch and the attempt
802
+ // after it fell back to a full send, duplicating the very turn the latch
803
+ // exists to protect.
804
+ let retriedId = "";
730
805
  try {
731
806
  const ensured = await ensureConversation(s);
732
807
  convId = ensured.convId;
733
808
  site = ensured.site;
734
809
  const created = ensured.created;
735
810
  if (convId) remember(s, site, convId, content);
736
- await sendMessage(convId, content);
811
+ // A retry of a turn the server already stored triggers THAT turn rather
812
+ // than posting the same words twice — see `stalledTurn`. Anything else is
813
+ // an ordinary two-call send.
814
+ //
815
+ // The SAME origin the conversation was created under (line above and
816
+ // `createConversation`). Sending without it defaulted the turn to
817
+ // `ARIA_ORIGIN`, so a consumer that configured its own origin created the
818
+ // conversation under one name and posted into it under another.
819
+ const stalled = stalledTurn && stalledTurn.convId === convId && stalledTurn.content === content
820
+ ? stalledTurn
821
+ : null;
822
+ if (stalled) {
823
+ retriedId = stalled.userMessageId;
824
+ await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
825
+ } else {
826
+ await sendMessage(convId, content, { origin });
827
+ }
828
+ // Delivered — whatever was stalled is answered or superseded.
829
+ //
830
+ // `retriedId` is cleared with it, and that is not redundant: the legacy
831
+ // `busyLoop` consumer keeps reading the transcript AFTER this point, still
832
+ // inside this `try`. A failure there is a failure to READ, not to deliver
833
+ // — the turn has been triggered — so leaving `retriedId` set let the catch
834
+ // below rebuild a latch for a turn already being answered, and the next
835
+ // identical submission triggered it a second time.
836
+ stalledTurn = null;
837
+ retriedId = "";
737
838
  // A conversation minted just now starts empty, so the marks taken above
738
839
  // from the PREVIOUS conversation's counts would be ahead of it and its
739
840
  // first reply would never read as new — the same stranded "typing…" this
@@ -752,6 +853,18 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
752
853
  return busyLoop({ ...sent, _convId: convId, _siteId: site }, convId, optimistic);
753
854
  } catch (e) {
754
855
  inFlightSend = null;
856
+ // A send that failed AFTER the turn was stored hands back the id it stored
857
+ // under. Remember it, so the owner tapping Send again triggers that turn
858
+ // instead of posting their message a second time. Any other failure clears
859
+ // the latch: nothing is stored, so a plain retry is the right recovery and
860
+ // a stale id would re-trigger a turn the owner has moved on from.
861
+ // Two ways this attempt can leave a turn stored and unanswered: a SEND
862
+ // whose trigger failed (the id arrives on the error), or a RE-TRIGGER of
863
+ // an already-stored turn that failed again (no id on the error, because
864
+ // nothing new was stored — the one we were retrying is still the answer).
865
+ const storedId = (e as { storedUserMessageId?: unknown })?.storedUserMessageId;
866
+ const keptId = typeof storedId === "string" && storedId ? storedId : retriedId;
867
+ stalledTurn = convId && keptId ? { convId, content, userMessageId: keptId } : null;
755
868
  return {
756
869
  ...base, status: "ready", _pending: "",
757
870
  // `_siteId` moves with the id or neither does: a conversation remembered
@@ -786,10 +899,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
786
899
  if (resumeId) {
787
900
  try {
788
901
  const gm = await getConversationMessages(resumeId);
789
- const bubbles = toBubbles(messagesOf(gm), assistant(), you());
902
+ const raw = messagesOf(gm);
903
+ const bubbles = toBubbles(raw, assistant(), you());
790
904
  const resumed = bubbles.length ? bubbles : greetingOnly();
791
905
  return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "",
792
- messages: resumed, ...observed(bubbles), prompts: promptsFor(resumed, promptList()) };
906
+ messages: resumed, ...observed(raw), prompts: promptsFor(resumed, promptList()) };
793
907
  } catch { /* expired or unreachable — fall through to a fresh one */ }
794
908
  }
795
909
  // Opening a screen runs `load` more than once — the bundle self-kicks at
@@ -884,20 +998,32 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
884
998
  const wasFast = s.status === "sending";
885
999
  try {
886
1000
  const gm = await getConversationMessages(s._convId);
887
- const server = toBubbles(messagesOf(gm), assistant(), you());
888
- if (!server.length) return s;
1001
+ const raw = messagesOf(gm);
1002
+ const server = toBubbles(raw, assistant(), you());
1003
+ // A transcript that renders to nothing is not necessarily an empty one:
1004
+ // a reply made entirely of widgets leaves no bubble behind. So the bail
1005
+ // is on the RAW transcript — bailing on the bubbles is what let such a
1006
+ // reply go unnoticed and pinned the ladder fast forever.
1007
+ if (!raw.length) return s;
889
1008
  const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
1009
+ // …but still never PAINT nothing over something. `messages` is only
1010
+ // adopted when it has content: a transcript that renders empty leaves
1011
+ // the screen as it was (the greeting, or the last good turn) while the
1012
+ // counts below still register the answer.
1013
+ const painted = messages.length ? messages : s.messages;
890
1014
  // The turn is over when the server holds an assistant turn it did not
891
1015
  // hold when we sent. Deliberately independent of `pending`: whether our
892
1016
  // own words came back is a rendering detail, not the answer arriving.
893
- const replied = countRole(server, "ASSISTANT") > s._replyAfter;
1017
+ // Counted over RAW messages, so a widget-only answer still ends the turn
1018
+ // instead of pinning the ladder to its fast rung forever (`countTurns`).
1019
+ const replied = countTurns(raw, "ASSISTANT") > s._replyAfter;
894
1020
  const next: AriaChatState = {
895
1021
  ...s,
896
1022
  status: replied ? "ready" : s.status,
897
- messages,
1023
+ messages: painted,
898
1024
  _pending: pending,
899
- ...observed(server),
900
- prompts: promptsFor(messages, promptList()),
1025
+ ...observed(raw),
1026
+ prompts: promptsFor(painted, promptList()),
901
1027
  statusText: replied ? "" : s.statusText,
902
1028
  };
903
1029
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
@@ -932,6 +1058,14 @@ export interface AriaHistoryState {
932
1058
  items: AriaHistoryRow[];
933
1059
  /** Non-empty ONLY when the list is empty (collapses otherwise). */
934
1060
  emptyText: string;
1061
+ /** The business the rows in `items` belong to. A kept list is only safe to
1062
+ * keep while this still matches, so it is state rather than a closure — the
1063
+ * chat service tracks its own `_siteId` for the same reason. */
1064
+ _siteId: string;
1065
+ /** The last load could not REACH the server, as opposed to reaching it and
1066
+ * being told there are none. The two are different answers and the list
1067
+ * treats them differently; see `load`. */
1068
+ failed: boolean;
935
1069
  }
936
1070
 
937
1071
  /** Worker-emitted strings for the history list. Thunks for the same reason the
@@ -1033,28 +1167,59 @@ export function createAriaHistory(o: AriaHistoryOptions) {
1033
1167
  last30: res(o.labels?.last30 ?? STOCK_BUCKETS.last30),
1034
1168
  older: res(o.labels?.older ?? STOCK_BUCKETS.older),
1035
1169
  });
1036
- const initialState: AriaHistoryState = { status: "loading", items: [], emptyText: "" };
1170
+ const initialState: AriaHistoryState = {
1171
+ status: "loading", items: [], emptyText: "", _siteId: "", failed: false,
1172
+ };
1037
1173
  return {
1038
1174
  initialState,
1039
1175
  load: async (s: AriaHistoryState): Promise<AriaHistoryState> => {
1040
1176
  const now = Date.now();
1041
- let entries: Array<{ id: string; title: string; ms: number }> = [];
1177
+ const site = siteOf();
1178
+ // `null` means the server was never reached. Distinct from `[]`, which is
1179
+ // the server answering that this business has no conversations — the whole
1180
+ // fix below turns on telling those two apart.
1181
+ let fetched: Array<{ id: string; title: string; ms: number }> | null = null;
1042
1182
  try {
1043
1183
  const conversations = await getConversations(origin);
1044
- entries = fromServer(conversations.conversations ?? [], untitled());
1184
+ fetched = fromServer(conversations.conversations ?? [], untitled());
1045
1185
  } catch {
1046
- // server unavailable — fall through to the local fallback below
1186
+ fetched = null;
1047
1187
  }
1048
- // The server list is authoritative; the local recent list only covers the
1049
- // case where it is empty or unreachable. It is site-partitioned for the
1050
- // same reason the server list is site-scoped — but a metaSite is not an
1051
- // account, so an app where two people can sign in on one device turns this
1052
- // off entirely rather than risk showing one owner the other's titles.
1188
+
1189
+ // A FAILED fetch must not blank a list that is already on screen. An app
1190
+ // that sets `localHistory: false` has the server list as its ONLY source,
1191
+ // so rebuilding from an empty `entries` turns a network blip into
1192
+ // "No past conversations yet." over a list the owner was just reading.
1193
+ //
1194
+ // Kept ONLY while the rows still belong to the same business. That clause
1195
+ // is the point, not a detail — without it this becomes a way to show one
1196
+ // owner the previous business's conversation titles, which is precisely
1197
+ // the leak `localHistory: false` is set to avoid.
1198
+ if (fetched === null && s.items.length && s._siteId === site) {
1199
+ return { ...s, status: "ready", failed: true };
1200
+ }
1201
+
1202
+ // A SUCCESSFUL empty answer is authoritative and DOES clear. The tempting
1203
+ // shortcut is to keep the last good list on ANY empty result, failure or
1204
+ // not — do not. This list is site-scoped, so right after a business switch
1205
+ // the new business truthfully answers "none", and keeping the old rows
1206
+ // would carry the previous business's titles across. A stale-looking list
1207
+ // is not worth a leak.
1208
+ let entries = fetched ?? [];
1209
+ // The local recent list still covers empty-or-unreachable, and is
1210
+ // site-partitioned for the same reason the server list is.
1053
1211
  if (!entries.length && o.localHistory !== false) {
1054
- entries = fromLocal(readHistory(scopedKey(o.historyKey, siteOf())));
1212
+ entries = fromLocal(readHistory(scopedKey(o.historyKey, site)));
1055
1213
  }
1056
1214
  const items = bucketRows(entries, now, buckets());
1057
- return { ...s, status: "ready", items, emptyText: items.length ? "" : emptyText() };
1215
+ return {
1216
+ ...s,
1217
+ status: "ready",
1218
+ items,
1219
+ emptyText: items.length ? "" : emptyText(),
1220
+ _siteId: site,
1221
+ failed: fetched === null,
1222
+ };
1058
1223
  },
1059
1224
  };
1060
1225
  }
@@ -0,0 +1,181 @@
1
+ // Inline widget envelopes in Aria's assistant text.
2
+ //
3
+ // The Wix Genie chat-service embeds interactive widgets inside the message body:
4
+ //
5
+ // Here are two templates.\n\n<<{"widget":"SINGLE_SELECT_WIDGET","data":{…}}>>\n\nWhich?
6
+ //
7
+ // The RN Aria app pulls them out with `/<<(.*?)>>/gs`; the standalone native app
8
+ // scans incrementally. We do the latter, for the reason in `scanEnvelopes` below.
9
+ //
10
+ // WHY THIS IS A MODULE AND NOT A REGEX AT THE CALL SITE
11
+ //
12
+ // Order. `stripMarkdown` must never see a payload: its italic rules (`__x__` -> `x`,
13
+ // `_x_` -> `x`) run across the whole string, so a payload that passes through it
14
+ // comes back with the underscores eaten — `SINGLE_SELECT_WIDGET` arrives as
15
+ // `SINGLESELECTWIDGET`, and every later parser reads a corrupted key. Scanning
16
+ // before stripping, and stripping only the text parts, is the fix.
17
+ //
18
+ // This module is deliberately DEPENDENCY-FREE so it can be imported by the worker
19
+ // bundle and unit-tested without booting anything.
20
+
21
+ /** One widget lifted out of the text, with its payload untouched. */
22
+ export interface WidgetPayload {
23
+ /** The `widget` field, or `""` when the payload could not be read. */
24
+ name: string;
25
+ /** The `data` object re-serialized, or null when absent. Kept as a STRING so the
26
+ * scanner never becomes the thing that decides a payload's shape — that is the
27
+ * normalizer's job, and it can fail without taking the chat down with it. */
28
+ dataJson: string | null;
29
+ /** The `config` object re-serialized, or null when absent. */
30
+ configJson: string | null;
31
+ /** Exactly what sat between `<<` and `>>`. Preserved verbatim for diagnosis. */
32
+ raw: string;
33
+ }
34
+
35
+ export type WidgetPart =
36
+ | { kind: "text"; text: string }
37
+ | { kind: "widget"; payload: WidgetPayload };
38
+
39
+ export interface ScanResult {
40
+ parts: WidgetPart[];
41
+ /** Where a subsequent scan of the same message should start. Equal to the text
42
+ * length once the turn is complete; otherwise it may point back at an unclosed
43
+ * `<<` (or a bare trailing `<`) that must be re-read when more text arrives. */
44
+ resumeOffset: number;
45
+ }
46
+
47
+ const OPEN = "<<";
48
+ const CLOSE = ">>";
49
+
50
+ /** Read the payload between the delimiters. Never throws: an unreadable payload
51
+ * still gets lifted out of the prose, with an empty name for the normalizer to
52
+ * turn into UNKNOWN. Showing raw JSON to an owner is the failure being avoided. */
53
+ function payloadOf(raw: string): WidgetPayload {
54
+ let name = "";
55
+ let dataJson: string | null = null;
56
+ let configJson: string | null = null;
57
+ try {
58
+ const parsed = JSON.parse(raw) as unknown;
59
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
60
+ const obj = parsed as Record<string, unknown>;
61
+ if (typeof obj.widget === "string") name = obj.widget;
62
+ if (obj.data !== undefined) dataJson = JSON.stringify(obj.data);
63
+ if (obj.config !== undefined) configJson = JSON.stringify(obj.config);
64
+ }
65
+ } catch {
66
+ // Deliberately silent: `name` stays "" and the caller degrades to UNKNOWN.
67
+ }
68
+ return { name, dataJson, configJson, raw };
69
+ }
70
+
71
+ /**
72
+ * Index of the `>>` that actually TERMINATES the envelope opened at `start`, or
73
+ * -1 if there is none yet.
74
+ *
75
+ * Not `indexOf`, because the envelope body is JSON and JSON strings may contain
76
+ * anything — including `>>`. A perfectly valid payload such as
77
+ * `{"widget":"CHART","config":{"title":"Q3 >> Q4"}}` was cut at the inner `>>`:
78
+ * `payloadOf` then got truncated, unparseable JSON (degrading the widget to
79
+ * UNKNOWN) AND the remainder — `Q4"}}` — was emitted as prose, printing a
80
+ * fragment of the payload into the chat: the very thing this module exists to
81
+ * prevent.
82
+ *
83
+ * So the scan tracks whether it is inside a JSON string, honouring backslash
84
+ * escapes. Outside a string, the first `>>` wins as before.
85
+ */
86
+ function closeOf(text: string, start: number): number {
87
+ let inStr = false;
88
+ for (let i = start; i < text.length; i++) {
89
+ const c = text[i];
90
+ if (inStr) {
91
+ // A backslash escapes the next character, `\"` and `\\` included — so
92
+ // skipping one character here is what keeps `"a\\"` from reading as an
93
+ // unterminated string.
94
+ if (c === "\\") { i++; continue; }
95
+ if (c === '"') inStr = false;
96
+ continue;
97
+ }
98
+ if (c === '"') { inStr = true; continue; }
99
+ if (c === ">" && text[i + 1] === ">") return i;
100
+ }
101
+ // No string-aware terminator. That is EITHER a genuinely unclosed envelope, or
102
+ // a payload whose JSON is malformed — an unterminated string swallows the rest
103
+ // of the message, `>>` included. Fall back to the naive scan for the second
104
+ // case: a broken envelope still belongs OUT of the prose, degraded to an
105
+ // UNKNOWN widget, rather than printed into the chat as raw JSON. When the text
106
+ // really holds no `>>`, this returns -1 too and the caller's unclosed handling
107
+ // takes over unchanged.
108
+ return text.indexOf(CLOSE, start);
109
+ }
110
+
111
+ /**
112
+ * Split `text` into prose and widget parts, starting at `from`.
113
+ *
114
+ * `completed` says whether the assistant's turn has finished. It is the only thing
115
+ * that decides what to do with an unterminated `<<`:
116
+ *
117
+ * - mid-turn — HOLD IT BACK and point `resumeOffset` at the `<<`. A half-arrived
118
+ * envelope must not flash as literal JSON and then vanish when it closes.
119
+ * - complete — it never closed, so it was never an envelope. Emit it as ordinary
120
+ * text rather than swallowing content the assistant actually sent.
121
+ *
122
+ * A bare `<` at the very end is held back mid-turn for the same reason: it may be
123
+ * the first half of `<<`.
124
+ *
125
+ * We poll rather than stream today, so every real call is `(text, 0, true)`. The
126
+ * resumable shape is kept because retrofitting it later is the expensive part, and
127
+ * because the held-back case is invisible under polling and obvious under streaming.
128
+ */
129
+ export function scanEnvelopes(text: string, from: number, completed: boolean): ScanResult {
130
+ const parts: WidgetPart[] = [];
131
+ const pushText = (s: string): void => {
132
+ if (s) parts.push({ kind: "text", text: s });
133
+ };
134
+
135
+ let cursor = Math.min(Math.max(from, 0), text.length);
136
+
137
+ for (;;) {
138
+ const open = text.indexOf(OPEN, cursor);
139
+ if (open < 0) break;
140
+
141
+ const close = closeOf(text, open + OPEN.length);
142
+ if (close < 0) {
143
+ if (completed) {
144
+ // Never closed and never will. It is text.
145
+ pushText(text.slice(cursor));
146
+ return { parts, resumeOffset: text.length };
147
+ }
148
+ // Still arriving: show what precedes it, and re-read from the `<<` next time.
149
+ pushText(text.slice(cursor, open));
150
+ return { parts, resumeOffset: open };
151
+ }
152
+
153
+ pushText(text.slice(cursor, open));
154
+ parts.push({ kind: "widget", payload: payloadOf(text.slice(open + OPEN.length, close)) });
155
+ cursor = close + CLOSE.length;
156
+ }
157
+
158
+ // No further `<<`. A lone trailing `<` mid-turn may be the start of one.
159
+ if (!completed && text.endsWith("<")) {
160
+ const held = text.length - 1;
161
+ if (cursor < held) pushText(text.slice(cursor, held));
162
+ return { parts, resumeOffset: held };
163
+ }
164
+
165
+ pushText(text.slice(cursor));
166
+ return { parts, resumeOffset: text.length };
167
+ }
168
+
169
+ /** The prose of a scan, with every widget removed. The string that reaches a bubble. */
170
+ export function proseOfParts(parts: WidgetPart[]): string {
171
+ let out = "";
172
+ for (const p of parts) if (p.kind === "text") out += p.text;
173
+ return out;
174
+ }
175
+
176
+ /** Every widget a scan lifted out, in the order they appeared. */
177
+ export function widgetsOfParts(parts: WidgetPart[]): WidgetPayload[] {
178
+ const out: WidgetPayload[] = [];
179
+ for (const p of parts) if (p.kind === "widget") out.push(p.payload);
180
+ return out;
181
+ }