@wix/himalaya-cli 0.987.0 → 0.988.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 (24) hide show
  1. package/dist/cli.mjs +2 -1
  2. package/package.json +2 -2
  3. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +138 -44
  4. package/templates/beauty-salon-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  5. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +138 -44
  6. package/templates/community-club-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  7. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +138 -44
  8. package/templates/events-meetups-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  9. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +138 -44
  10. package/templates/gym-fitness-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  11. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +138 -44
  12. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  13. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +138 -44
  14. package/templates/owner-lite/tier5-src/_shared/aria-hot-path.ts +167 -0
  15. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +138 -44
  16. package/templates/podcast-creator-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  17. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +138 -44
  18. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  19. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +138 -44
  20. package/templates/restaurant-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  21. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +138 -44
  22. package/templates/retail-store-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
  23. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +138 -44
  24. package/templates/yoga-studio-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
@@ -46,6 +46,7 @@ import { isWebUrl } from "./aria-widget-model.js";
46
46
  import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
47
47
  from "./aria-widget-view.js";
48
48
  import { substituteSelection } from "./aria-widget-model.js";
49
+ import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMemo } from "./aria-hot-path.js";
49
50
 
50
51
  // ---------------------------------------------------------------------------
51
52
  // Shapes
@@ -119,6 +120,9 @@ export interface AriaChatState {
119
120
  * question twice found the FIRST copy already in the transcript, dropped the
120
121
  * second bubble off the screen, and declared the turn finished. */
121
122
  _pendingAfter: number;
123
+ /** The bubble id of `_pending` while the server has not echoed it — minted
124
+ * once per send (`mintLocalId`) so every tick re-appends the SAME row. */
125
+ _pendingId: string;
122
126
  /** Server ASSISTANT-turn count at that same moment. The turn is over when the
123
127
  * server holds more than this — read from the transcript ALONE. Gating it on
124
128
  * the user's message having been echoed back instead meant a service that
@@ -573,33 +577,63 @@ export function plainBubble(id: string, role: string, who: string, content: stri
573
577
  *
574
578
  * The widgets themselves are dropped here; rendering them is #2605 onward. A
575
579
  * reply that was nothing but a widget therefore has empty prose and is filtered
576
- * out below, which is deliberate: an empty bubble is a visible defect. */
577
- export function toBubbles(msgs: ConversationMessage[], assistantName: string, youName = "You"): AriaBubble[] {
578
- return msgs
579
- .map((m, i) => {
580
- const raw = messageText(m);
581
- const role = m.role || "ASSISTANT";
582
- // Only assistant turns carry envelopes; a user's own text is literal, and
583
- // scanning it would let an owner who typed `<<…>>` erase their own message.
584
- // HIDDEN spans go first: the service marks text as transcript-only that
585
- // way, and an aborted turn consists of nothing else. Then envelopes, then
586
- // the markdown stripper — each stage handing on only what a reader should
587
- // actually see.
588
- // A row the service is still writing is scanned as STREAMING: an
589
- // envelope whose `>>` has not arrived yet is held back rather than
590
- // painted as raw JSON until the row finishes.
591
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
592
- return {
593
- id: m.id || `m${i}`,
594
- role,
595
- who: role === "USER" ? youName : assistantName,
596
- content: role === "USER" ? prose : stripMarkdown(prose),
597
- notUser: role !== "USER",
598
- notAria: role === "USER",
599
- ...linkFieldsOf(prose, role),
600
- };
601
- })
602
- .filter((b) => b.content && b.content.trim());
580
+ * out below, which is deliberate: an empty bubble is a visible defect.
581
+ *
582
+ * Pass a `memo` on a path that re-reads the transcript (every poll does): a
583
+ * message whose id, text and writing state are unchanged since the last read
584
+ * reuses the bubble built then instead of running the stages above again — see
585
+ * `aria-hot-path.ts` rule 1. Without one, every bubble is built fresh; the two
586
+ * are equivalent. */
587
+ export function toBubbles(
588
+ msgs: ConversationMessage[], assistantName: string, youName = "You", memo?: BubbleMemo,
589
+ ): AriaBubble[] {
590
+ const ids = transcriptKeys(msgs);
591
+ const out: AriaBubble[] = [];
592
+ try {
593
+ for (let i = 0; i < msgs.length; i++) {
594
+ const m = msgs[i];
595
+ const role = m.role || "ASSISTANT", raw = messageText(m), id = ids[i];
596
+ const who = role === "USER" ? youName : assistantName;
597
+ // Part of the memo key, not just an input: a row that FINISHES writing
598
+ // can keep exactly the same text and still render differently (a held
599
+ // back envelope is released), so text alone would serve the stale bubble.
600
+ const writing = role !== "USER" && stillWriting(m);
601
+ const build = () => bubbleOf(id, role, who, raw, writing);
602
+ const b = memo ? memo.row(id, raw, `${role}\u0000${who}\u0000${writing ? "w" : ""}`, build) : build();
603
+ if (b) out.push(b);
604
+ }
605
+ } finally {
606
+ memo?.sweep();
607
+ }
608
+ return out;
609
+ }
610
+
611
+ /** Bubbles are built from one message's raw text; `null` is "paints nothing". */
612
+ export type BubbleMemo = RowMemo<AriaBubble | null>;
613
+ export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
614
+
615
+ function bubbleOf(id: string, role: string, who: string, raw: string, writing: boolean): AriaBubble | null {
616
+ // Only assistant turns carry envelopes; a user's own text is literal, and
617
+ // scanning it would let an owner who typed `<<…>>` erase their own message.
618
+ // HIDDEN spans go first: the service marks text as transcript-only that
619
+ // way, and an aborted turn consists of nothing else. Then envelopes, then
620
+ // the markdown stripper — each stage handing on only what a reader should
621
+ // actually see.
622
+ // A row the service is still writing is scanned as STREAMING: an
623
+ // envelope whose `>>` has not arrived yet is held back rather than
624
+ // painted as raw JSON until the row finishes.
625
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !writing).parts);
626
+ const content = role === "USER" ? prose : stripMarkdown(prose);
627
+ if (!content || !content.trim()) return null;
628
+ return {
629
+ id,
630
+ role,
631
+ who,
632
+ content,
633
+ notUser: role !== "USER",
634
+ notAria: role === "USER",
635
+ ...linkFieldsOf(prose, role),
636
+ };
603
637
  }
604
638
 
605
639
  /** The reply the Copy / Regenerate row acts on: the LAST bubble, when it is an
@@ -648,11 +682,14 @@ export function argText(arg: AriaActionArg): string {
648
682
  * when we sent. Until then it is appended so the owner's message does not
649
683
  * vanish mid-turn. */
650
684
  export function reconcile(
651
- server: AriaBubble[], pending: string, pendingAfter: number, youName = "You",
685
+ server: AriaBubble[], pending: string, pendingAfter: number, youName = "You", pendingId = "",
652
686
  ): { messages: AriaBubble[]; pending: string } {
653
687
  if (!pending) return { messages: server, pending: "" };
654
688
  if (countRole(server, "USER") > pendingAfter) return { messages: server, pending: "" };
655
- return { messages: [...server, plainBubble(`pending-${server.length}`, "USER", youName, pending)], pending };
689
+ // `pendingId` is empty only for state persisted before ids were minted; a
690
+ // fixed fallback still keeps that one row stable across ticks.
691
+ const id = pendingId || `${LOCAL_ID_PREFIX}pending`;
692
+ return { messages: [...server, plainBubble(id, "USER", youName, pending)], pending };
656
693
  }
657
694
 
658
695
  /** How many turns of one role the transcript holds. Counted over BUBBLES rather
@@ -1029,7 +1066,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1029
1066
  const initialState: AriaChatState = {
1030
1067
  status: "loading", statusText: "", error: "", input: "", copyNote: "", replyActionsHidden: true,
1031
1068
  messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
1032
- _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
1069
+ _pendingAfter: 0, _pendingId: "", _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
1033
1070
  _abortId: "", _turnId: "", stopHidden: true, ok: false,
1034
1071
  widget: NO_WIDGET,
1035
1072
  };
@@ -1047,6 +1084,17 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1047
1084
  * in-flight conversation. The latch is cleared once settled, so a failed
1048
1085
  * create is retried rather than remembered. */
1049
1086
  let creating: { site: string; promise: Promise<string> } | null = null;
1087
+
1088
+ /** Bubbles built on the previous transcript read, reused while their message
1089
+ * is unchanged — every poll re-reads the WHOLE transcript, and re-deriving
1090
+ * every bubble on each one is the cost `aria-hot-path.ts` rule 1 removes.
1091
+ * Bounded by the latest read: rows it did not contain are forgotten. */
1092
+ const bubbleMemo = createBubbleMemo();
1093
+
1094
+ /** Last number `mintLocalId` handed out. Not state: `mintLocalId` skips every
1095
+ * id already on screen, so a cold start resetting it cannot collide. */
1096
+ let localSeq = 0;
1097
+
1050
1098
  function createOnce(site: string): Promise<string> {
1051
1099
  if (creating && creating.site === site) return creating.promise;
1052
1100
  const promise = createConversation(assistantConfig, origin)
@@ -1270,7 +1318,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1270
1318
  for (let n = 0; n < BUSY_LOOP_ROUNDS; n++) {
1271
1319
  const gm = await getConversationMessages(convId);
1272
1320
  const raw = messagesOf(gm);
1273
- const bubbles = toBubbles(raw, assistant(), you());
1321
+ const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
1274
1322
  if (bubbles.length) server = bubbles;
1275
1323
  seen = raw;
1276
1324
  // The turn is over when the SERVER holds an assistant answer — which a
@@ -1305,7 +1353,27 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1305
1353
  turnSeq += 1;
1306
1354
  // Optimistic first: the owner's message and the typing line are part of the
1307
1355
  // state this action RETURNS, so on the `_poll` path they paint immediately.
1308
- const optimistic: AriaBubble[] = [...s.messages, plainBubble(`u${s.messages.length}`, "USER", you(), content)];
1356
+ // Its id is the LOCAL SEND ID, minted here and carried as `_pendingId`, the
1357
+ // one `reconcile` re-appends it under on every tick, so the row keeps one
1358
+ // identity until the server's copy lands.
1359
+ //
1360
+ // A local bubble already on screen with these exact words is this message's
1361
+ // FAILED attempt (an identical PENDING one is refused as a duplicate), so a
1362
+ // retry takes over its id and replaces it rather than painting the same
1363
+ // words twice. Matched on the words themselves — never on a hash of them.
1364
+ const failedCopy = s.messages.find((b) =>
1365
+ b.role === "USER" && b.id.startsWith(LOCAL_ID_PREFIX) && b.content === content);
1366
+ let localId: string;
1367
+ if (failedCopy) {
1368
+ localId = failedCopy.id;
1369
+ } else {
1370
+ const minted = mintLocalId(localSeq, (id) => s.messages.some((b) => b.id === id));
1371
+ localSeq = minted.seq;
1372
+ localId = minted.id;
1373
+ }
1374
+ const optimistic: AriaBubble[] = [
1375
+ ...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content),
1376
+ ];
1309
1377
  const base: AriaChatState = {
1310
1378
  // `copyNote: ""` — a Copy confirmation belongs to the moment, and a new
1311
1379
  // turn ends it. Left in place it would sit under the next reply saying
@@ -1316,7 +1384,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1316
1384
  // on a stale turn id would look for an answer that already exists. Both
1317
1385
  // are replaced the moment this send knows its own id.
1318
1386
  _abortId: "", _turnId: "",
1319
- statusText: typingText(), messages: optimistic, _pending: content,
1387
+ statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId,
1320
1388
  // Mark the turn against the LAST COUNTS WE SAW, not against our own
1321
1389
  // message list — the greeting is a local bubble the server never had, so
1322
1390
  // counting `messages` would mark the turn one assistant reply ahead and
@@ -1508,15 +1576,33 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1508
1576
  // this send was working on and is still the one we captured; otherwise it
1509
1577
  // belongs to another turn and must survive.
1510
1578
  : (supersedes && stalledTurn === latch ? null : stalledTurn);
1579
+ // A FOLLOW-UP failing does not end the turn before it. Asking again while
1580
+ // an answer is still being written is supported (`isDuplicateSend`), so
1581
+ // the earlier turn can be in flight right now — and marking the screen
1582
+ // `ready` here threw it away: its Stop went, and `tick` (which only slows
1583
+ // the ladder on a turn it saw END) never slowed it, so the fast rate ran
1584
+ // for as long as the screen stayed open (aria-hot-path.ts rule 4). Only in
1585
+ // the same conversation: a turn in another one is not this screen's.
1586
+ const keepsEarlier = turnInFlight(s) && convId === s._convId;
1587
+ const earlier: Partial<AriaChatState> = keepsEarlier
1588
+ ? { status: s.status, _pending: s._pending, _pendingId: s._pendingId, _pendingAfter: s._pendingAfter,
1589
+ _replyAfter: s._replyAfter, _abortId: s._abortId, _turnId: s._turnId }
1590
+ : { status: "ready", _pending: "" };
1591
+ const slots = failureSlots(e, unavailableText);
1511
1592
  return {
1512
- ...base, status: "ready", _pending: "",
1593
+ ...base, ...earlier,
1513
1594
  // `_siteId` moves with the id or neither does: a conversation remembered
1514
1595
  // without the site it belongs to reads as another business's on the next
1515
1596
  // send, and gets replaced instead of retried.
1516
1597
  _convId: convId, _siteId: convId === s._convId ? s._siteId : site,
1517
1598
  // Sets BOTH error and statusText, so it must come after the `statusText: ""`
1518
1599
  // this object used to carry — otherwise the literal would blank the sentence.
1519
- ...failureSlots(e, unavailableText),
1600
+ ...slots,
1601
+ // …except that a kept turn keeps its own status line. A real failure
1602
+ // puts its sentence in `error` and blanks `statusText`, which here would
1603
+ // leave Stop on screen beside no "Aria is typing…" until the answer
1604
+ // landed. A boundary refusal's neutral sentence still wins.
1605
+ ...(keepsEarlier ? { statusText: slots.statusText || s.statusText } : {}),
1520
1606
  };
1521
1607
  }
1522
1608
  }
@@ -1680,6 +1766,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1680
1766
 
1681
1767
  load: async (s) => {
1682
1768
  const site = siteOf();
1769
+ // Every branch below that puts a DIFFERENT conversation on screen abandons
1770
+ // whatever turn was in flight — a business switch mid-turn does exactly
1771
+ // that — so it re-arms the ladder slow, as `newChat` does. Otherwise the
1772
+ // fast rate the send armed is never slowed: `tick` only slows on a turn it
1773
+ // saw end (aria-hot-path.ts rule 4).
1774
+ const wasMidTurn = !!s._pending || s.status === "sending";
1775
+ const leaving = (next: AriaChatState): AriaChatState =>
1776
+ wasMidTurn && o.poll ? cadence(next, o.poll.idleMs, null) : next;
1683
1777
  // Resume: the history screen routes here with a conversationId. Only
1684
1778
  // honour it for the site it belongs to — a stale id from another business
1685
1779
  // must not be re-opened.
@@ -1697,13 +1791,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1697
1791
  try {
1698
1792
  const gm = await getConversationMessages(resumeId);
1699
1793
  const raw = messagesOf(gm);
1700
- const bubbles = toBubbles(raw, assistant(), you());
1794
+ const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
1701
1795
  const resumed = bubbles.length ? bubbles : greetingOnly();
1702
1796
  // `copyNote: ""` — a resumed transcript ends in a reply, so the row is
1703
1797
  // shown at once; a note carried over would say "Copied" beside an
1704
1798
  // answer from a different conversation that nobody copied.
1705
- return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
1706
- messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) };
1799
+ return leaving({ ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
1800
+ messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) });
1707
1801
  } catch { /* expired or unreachable — fall through to a fresh one */ }
1708
1802
  }
1709
1803
  // Opening a screen runs `load` more than once — the bundle self-kicks at
@@ -1735,12 +1829,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1735
1829
  return { ...s, status: "ready", messages, prompts: promptsFor(messages, promptList()) };
1736
1830
  }
1737
1831
  try {
1738
- return { ...s, status: "ready", _convId: await createOnce(site), _siteId: site, _pending: "", statusText: "",
1739
- messages: greetingOnly(), ...observed([]), prompts: promptList() };
1832
+ return leaving({ ...s, status: "ready", _convId: await createOnce(site), _siteId: site, _pending: "", statusText: "",
1833
+ messages: greetingOnly(), ...observed([]), prompts: promptList() });
1740
1834
  } catch (e) {
1741
1835
  // Even if create fails, show the greeting; submit retries the create.
1742
- return { ...s, status: "ready", _convId: "", _siteId: site, _pending: "", ...failureSlots(e, unavailableText),
1743
- messages: greetingOnly(), ...observed([]), prompts: promptList() };
1836
+ return leaving({ ...s, status: "ready", _convId: "", _siteId: site, _pending: "", ...failureSlots(e, unavailableText),
1837
+ messages: greetingOnly(), ...observed([]), prompts: promptList() });
1744
1838
  }
1745
1839
  },
1746
1840
 
@@ -1842,14 +1936,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1842
1936
  // `stop` holds a snapshot predating this read and will overwrite
1843
1937
  // whatever we write. A tick of a NEWER epoch is not stale, not fenced.
1844
1938
  if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
1845
- const server = toBubbles(raw, assistant(), you());
1939
+ const server = toBubbles(raw, assistant(), you(), bubbleMemo);
1846
1940
  // A transcript that renders to nothing is not necessarily an empty one:
1847
1941
  // a reply made entirely of widgets leaves no bubble behind. So the bail
1848
1942
  // is on the RAW transcript — bailing on the bubbles is what let such a
1849
1943
  // reply go unnoticed and pinned the ladder fast forever. Safe to return
1850
1944
  // `s` here: the guard above proved this tick is still current.
1851
1945
  if (!raw.length) return s;
1852
- const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
1946
+ const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId);
1853
1947
  // …but still never PAINT nothing over something. `messages` is only
1854
1948
  // adopted when it has content: a transcript that renders empty leaves
1855
1949
  // the screen as it was (the greeting, or the last good turn) while the
@@ -0,0 +1,167 @@
1
+ // The Aria transcript's hot path — the rules it is written to, and the two
2
+ // primitives that make them hold.
3
+ //
4
+ // Adapted from `native-apps/PERFORMANCE.md` in wix-private/aria-native-apps, which
5
+ // states eleven rules, each "a class of shipped regression". Most of them govern
6
+ // 30 Hz streaming, which this app does not do yet (#2612), and adopting all eleven
7
+ // for a POLLED chat would be cargo-culting. These four apply now (#2623):
8
+ //
9
+ // 1. NEVER RE-DERIVE THE WHOLE TRANSCRIPT PER TICK. Every poll re-reads the full
10
+ // transcript, and turning a message into a bubble is the expensive step —
11
+ // the envelope scan, the markdown stripper (a dozen regex passes) and the
12
+ // link scan. So a bubble is built once per (identity, content) and reused
13
+ // while its message is unchanged: a poll that brings nothing new builds
14
+ // nothing. `createRowMemo`.
15
+ // Measured per poll over an unchanged transcript of mixed-markdown replies
16
+ // (bubbles + both turn counts), median, QuickJS — the no-JIT floor a device
17
+ // worker realm runs at:
18
+ // messages 20 100 500
19
+ // before 3 ms 16 ms 79 ms
20
+ // after 0 ms 1 ms 6 ms (≈5 ms of it is `countTurns`)
21
+ // Node's JIT shows the same ratio: 2.4 ms → 0.21 ms at 500.
22
+ //
23
+ // 2. A PER-TICK IDENTITY IS A CACHE FLUSH. The memo is keyed by the MESSAGE's
24
+ // identity, never by anything minted per read. A message that is still
25
+ // growing — the streaming case — keeps its id, so it overwrites its own slot
26
+ // in place and every settled row stays built. The memo's bound is the
27
+ // transcript itself (rows the latest read did not contain are dropped), so
28
+ // there is no LRU for a churning key to evict settled entries out of.
29
+ //
30
+ // 3. TRANSCRIPT IDENTITY IS THE SERVER MESSAGE ID — or the LOCAL SEND ID until
31
+ // the server acknowledges the message. Never an array index: an index names
32
+ // a POSITION, and the moment anything before it is dropped (an empty
33
+ // placeholder, a hidden-only row, a windowed read) the same index names a
34
+ // different message. The bubble id is also the row's stable test id
35
+ // (`<screen>.msg_<id>`, invariant 27). `transcriptKeys`, `mintLocalId`.
36
+ //
37
+ // 4. EVERY REPEATING CLOCK HAS AN OWNER AND STOPS ON EVERY EXIT PATH. The only
38
+ // clock here is the reply ladder, `_poll` id `aria-turn`, and its owner is
39
+ // the HOST's PollScheduler — a worker has no timers, and `PollDirective` has
40
+ // no stop verb. So, stated explicitly rather than implied:
41
+ // - one id, ever: no second `_poll` is armed (async-job progress rides the
42
+ // same ladder for exactly this reason — see `readJobState`);
43
+ // - every path that ENDS or ABANDONS a turn re-arms it at the IDLE rate
44
+ // (reply, Stop, ➕ mid-turn, a load that leaves the turn behind),
45
+ // because slowing is the only lever a worker holds;
46
+ // - a path that does NOT end the turn must not pretend to. A follow-up
47
+ // whose send failed used to discard the turn still in flight — marking
48
+ // the screen idle while the first answer was still being written — so
49
+ // `tick` never saw the turn end and the fast rate ran for as long as
50
+ // the screen stayed open (30 reads a minute instead of 4);
51
+ // - the host stops it on background, pooling and teardown (poll-parity).
52
+ // What it cannot do yet is stop while the screen is visible and idle: that
53
+ // needs a stop verb on the directive across every host arm.
54
+ //
55
+ // Rules for streaming (tail-only parsing, publish coalescing, frame-clock
56
+ // drivers) and the native app's device budgets (cold start, frame time, hangs)
57
+ // are deliberately NOT adopted here: nothing in this repo measures them for
58
+ // Aria, and a budget nobody measures is decoration. Each rule above is pinned by
59
+ // a test instead — `core/dev-server/tests/aria-hot-path.test.ts`.
60
+ //
61
+ // Dependency-free on purpose: the tests import this file directly.
62
+
63
+ /** Prefix of every id minted for a bubble the server has not echoed yet. */
64
+ export const LOCAL_ID_PREFIX = "local-";
65
+
66
+ /** Mint the id for the bubble of a message the owner just sent.
67
+ *
68
+ * MINTED once per send and then carried in state (`_pendingId`), so the
69
+ * optimistic bubble `send` paints and the one `reconcile` re-appends on every
70
+ * tick are the SAME row until the server's copy replaces it. It used to be
71
+ * `u<list length>` in one and `pending-<server length>` in the other — two ids
72
+ * for one message, both positions.
73
+ *
74
+ * Deliberately NOT derived from the text. A 32-bit hash of it collides —
75
+ * different words, one id — and then sending one message silently replaced
76
+ * another's failed bubble and took over its identity. A retry of the SAME send
77
+ * reuses its failed copy's id instead (see `send`).
78
+ *
79
+ * `taken` is every id on screen, so a counter that a cold start reset can never
80
+ * mint an id a persisted bubble still carries. */
81
+ export function mintLocalId(seq: number, taken: (id: string) => boolean): { id: string; seq: number } {
82
+ let n = seq;
83
+ let id: string;
84
+ do { id = `${LOCAL_ID_PREFIX}${++n}`; } while (taken(id));
85
+ return { id, seq: n };
86
+ }
87
+
88
+ /** One transcript row, as far as identity is concerned. */
89
+ export interface IdentityRow {
90
+ id?: unknown;
91
+ }
92
+
93
+ /** A usable server id: a non-empty string, or a number (kept as its string). */
94
+ function serverIdOf(id: unknown): string {
95
+ if (typeof id === "string") return id;
96
+ if (typeof id === "number" && Number.isFinite(id)) return String(id);
97
+ return "";
98
+ }
99
+
100
+ /** A stable key per row: the server id when there is one.
101
+ *
102
+ * A row with no id is keyed RELATIVE TO THE NEAREST SERVER ID BEFORE IT —
103
+ * `after-<id>-<k>` for the k-th id-less row since that anchor, `head-<k>` before
104
+ * any. Never by its content, and never by its absolute position:
105
+ * - content would re-key a row that is still being written on every read, the
106
+ * exact case where identity matters most;
107
+ * - an absolute index re-keys every later row the moment anything before it
108
+ * is dropped (a placeholder, a hidden-only row, a windowed read).
109
+ * What remains unstable is narrow and stated: dropping an id-less row between
110
+ * the same anchor and this one shifts `k`, and the `head-` rows have no anchor
111
+ * to hold them. The service names its rows; this is the fallback. */
112
+ export function transcriptKeys(rows: ReadonlyArray<IdentityRow>): string[] {
113
+ let anchor = "";
114
+ let k = 0;
115
+ return rows.map((r) => {
116
+ const id = serverIdOf(r.id);
117
+ if (id) { anchor = id; k = 0; return id; }
118
+ return anchor ? `after-${anchor}-${k++}` : `head-${k++}`;
119
+ });
120
+ }
121
+
122
+ /** Per-row derivation memo for a transcript that is re-read wholesale.
123
+ *
124
+ * Use: call `row` once per message of one read, then `sweep`. A row is rebuilt
125
+ * only when its key is new or its `input` / `variant` changed.
126
+ *
127
+ * BOUND: the rows of the latest completed read — `sweep` forgets every key that
128
+ * read did not touch. So the memo never outgrows the transcript on screen, and
129
+ * a conversation switch drops the previous one's rows at the first read.
130
+ *
131
+ * Values are SHARED between reads. Callers must treat them as immutable, which
132
+ * the transcript already is: every reducer here replaces `messages`, none
133
+ * mutates a bubble in place. */
134
+ export interface RowMemo<V> {
135
+ row(key: string, input: string, variant: string, build: () => V): V;
136
+ sweep(): void;
137
+ /** How many times `build` ran since creation — what a count test pins. */
138
+ readonly builds: number;
139
+ /** Rows currently held. */
140
+ readonly size: number;
141
+ }
142
+
143
+ export function createRowMemo<V>(): RowMemo<V> {
144
+ interface Entry { input: string; variant: string; value: V }
145
+ let live = new Map<string, Entry>();
146
+ let next = new Map<string, Entry>();
147
+ let builds = 0;
148
+ return {
149
+ row(key, input, variant, build) {
150
+ const e = next.get(key) ?? live.get(key);
151
+ if (e && e.input === input && e.variant === variant) {
152
+ next.set(key, e);
153
+ return e.value;
154
+ }
155
+ builds++;
156
+ const value = build();
157
+ next.set(key, { input, variant, value });
158
+ return value;
159
+ },
160
+ sweep() {
161
+ live = next;
162
+ next = new Map();
163
+ },
164
+ get builds() { return builds; },
165
+ get size() { return live.size; },
166
+ };
167
+ }