@wix/himalaya-cli 0.1308.0 → 0.1309.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 (93) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +7 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +32 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/validator.d.ts +60 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +31 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +31 -0
  6. package/dist/cli.mjs +1305 -864
  7. package/dist/preview-assets.generated.bin +0 -0
  8. package/guides/app-config.md +2 -2
  9. package/guides/component-reference.json +2 -2
  10. package/guides/component-reference.md +10 -0
  11. package/guides/getting-started.md +40 -0
  12. package/guides/himi-skill.md +40 -0
  13. package/package.json +3 -2
  14. package/templates/beauty-salon/himalaya.content.json +86 -0
  15. package/templates/beauty-salon-owner/dev/index.ts +574 -181
  16. package/templates/beauty-salon-owner/himalaya.content.json +179 -0
  17. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +136 -12
  18. package/templates/beauty-salon-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  19. package/templates/beauty-salon-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  20. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  21. package/templates/branded-lite/himalaya.content.json +254 -0
  22. package/templates/brandy/himalaya.content.json +18 -1
  23. package/templates/community-club/himalaya.content.json +68 -0
  24. package/templates/community-club-owner/dev/index.ts +574 -181
  25. package/templates/community-club-owner/himalaya.content.json +173 -0
  26. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +136 -12
  27. package/templates/community-club-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  28. package/templates/community-club-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  29. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  30. package/templates/events-meetups/himalaya.content.json +62 -0
  31. package/templates/events-meetups-owner/dev/index.ts +574 -181
  32. package/templates/events-meetups-owner/himalaya.content.json +155 -0
  33. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +136 -12
  34. package/templates/events-meetups-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  35. package/templates/events-meetups-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  36. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  37. package/templates/gym-fitness/himalaya.content.json +101 -0
  38. package/templates/gym-fitness-owner/dev/index.ts +574 -181
  39. package/templates/gym-fitness-owner/himalaya.content.json +188 -0
  40. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +136 -12
  41. package/templates/gym-fitness-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  42. package/templates/gym-fitness-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  43. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  44. package/templates/himi-chat/himalaya.content.json +8 -0
  45. package/templates/nonprofit-cause/himalaya.content.json +74 -0
  46. package/templates/nonprofit-cause-owner/dev/index.ts +574 -181
  47. package/templates/nonprofit-cause-owner/himalaya.content.json +170 -0
  48. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +136 -12
  49. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  50. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  51. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  52. package/templates/owner-lite/dev/index.ts +574 -181
  53. package/templates/owner-lite/himalaya.content.json +203 -0
  54. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +136 -12
  55. package/templates/owner-lite/tier5-src/_shared/aria-client-tools.ts +57 -9
  56. package/templates/owner-lite/tier5-src/_shared/aria-row-id.ts +14 -0
  57. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  58. package/templates/podcast-creator/himalaya.content.json +71 -0
  59. package/templates/podcast-creator-owner/dev/index.ts +574 -181
  60. package/templates/podcast-creator-owner/himalaya.content.json +173 -0
  61. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +136 -12
  62. package/templates/podcast-creator-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  63. package/templates/podcast-creator-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  64. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  65. package/templates/portfolio-creative/himalaya.content.json +80 -0
  66. package/templates/portfolio-creative-owner/dev/index.ts +574 -181
  67. package/templates/portfolio-creative-owner/himalaya.content.json +179 -0
  68. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +136 -12
  69. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  70. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  71. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  72. package/templates/restaurant/himalaya.content.json +65 -0
  73. package/templates/restaurant-owner/dev/index.ts +574 -181
  74. package/templates/restaurant-owner/himalaya.content.json +188 -0
  75. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +136 -12
  76. package/templates/restaurant-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  77. package/templates/restaurant-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  78. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  79. package/templates/retail-store/himalaya.content.json +62 -0
  80. package/templates/retail-store-owner/dev/index.ts +574 -181
  81. package/templates/retail-store-owner/himalaya.content.json +164 -0
  82. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +136 -12
  83. package/templates/retail-store-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  84. package/templates/retail-store-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  85. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
  86. package/templates/surface-showcase/himalaya.content.json +11 -0
  87. package/templates/yoga-studio/himalaya.content.json +80 -0
  88. package/templates/yoga-studio-owner/dev/index.ts +574 -181
  89. package/templates/yoga-studio-owner/himalaya.content.json +170 -0
  90. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +136 -12
  91. package/templates/yoga-studio-owner/tier5-src/_shared/aria-client-tools.ts +57 -9
  92. package/templates/yoga-studio-owner/tier5-src/_shared/aria-row-id.ts +14 -0
  93. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +3 -0
@@ -1,5 +1,193 @@
1
1
  {
2
2
  "name": "__HIMI_APP_NAME__",
3
+ "screens": {
4
+ "himi_owner_lite_account": {
5
+ "displayName": "Account"
6
+ },
7
+ "himi_owner_lite_analytics": {
8
+ "displayName": "Analytics Overview"
9
+ },
10
+ "himi_owner_lite_analytics_measure": {
11
+ "displayName": "Analytics Metric Details"
12
+ },
13
+ "himi_owner_lite_analytics_report": {
14
+ "displayName": "Build a report"
15
+ },
16
+ "himi_owner_lite_appointments": {
17
+ "displayName": "Appointments"
18
+ },
19
+ "himi_owner_lite_aria": {
20
+ "displayName": "Aria"
21
+ },
22
+ "himi_owner_lite_aria_history": {
23
+ "displayName": "Aria History"
24
+ },
25
+ "himi_owner_lite_ask": {
26
+ "displayName": "Ask the gym"
27
+ },
28
+ "himi_owner_lite_booking": {
29
+ "displayName": "Booking"
30
+ },
31
+ "himi_owner_lite_brief": {
32
+ "displayName": "Floor brief"
33
+ },
34
+ "himi_owner_lite_drawer": {
35
+ "displayName": "Menu"
36
+ },
37
+ "himi_owner_lite_event": {
38
+ "displayName": "Challenge"
39
+ },
40
+ "himi_owner_lite_event_form": {
41
+ "displayName": "Event Form"
42
+ },
43
+ "himi_owner_lite_events": {
44
+ "displayName": "Events"
45
+ },
46
+ "himi_owner_lite_home": {
47
+ "displayName": "Business Dashboard"
48
+ },
49
+ "himi_owner_lite_manage": {
50
+ "displayName": "Gym"
51
+ },
52
+ "himi_owner_lite_module_badges": {
53
+ "displayName": "Module Badges"
54
+ },
55
+ "himi_owner_lite_module_contacts": {
56
+ "displayName": "Module Contacts"
57
+ },
58
+ "himi_owner_lite_module_coupons": {
59
+ "displayName": "Module Coupons"
60
+ },
61
+ "himi_owner_lite_module_donationCampaigns": {
62
+ "displayName": "Module Donation Campaigns"
63
+ },
64
+ "himi_owner_lite_module_forms": {
65
+ "displayName": "Module Forms"
66
+ },
67
+ "himi_owner_lite_module_giftCards": {
68
+ "displayName": "Module Gift Cards"
69
+ },
70
+ "himi_owner_lite_module_groups": {
71
+ "displayName": "Module Groups"
72
+ },
73
+ "himi_owner_lite_module_invoices": {
74
+ "displayName": "Module Invoices"
75
+ },
76
+ "himi_owner_lite_module_members": {
77
+ "displayName": "Module Members"
78
+ },
79
+ "himi_owner_lite_module_plans": {
80
+ "displayName": "Module Plans"
81
+ },
82
+ "himi_owner_lite_module_portfolio": {
83
+ "displayName": "Module Portfolio"
84
+ },
85
+ "himi_owner_lite_module_programs": {
86
+ "displayName": "Module Programs"
87
+ },
88
+ "himi_owner_lite_module_reservations": {
89
+ "displayName": "Module Reservations"
90
+ },
91
+ "himi_owner_lite_module_reviews": {
92
+ "displayName": "Module Reviews"
93
+ },
94
+ "himi_owner_lite_module_subscriptions": {
95
+ "displayName": "Module Subscriptions"
96
+ },
97
+ "himi_owner_lite_order": {
98
+ "displayName": "Order"
99
+ },
100
+ "himi_owner_lite_orders": {
101
+ "displayName": "Orders"
102
+ },
103
+ "himi_owner_lite_overview": {
104
+ "displayName": "Overview"
105
+ },
106
+ "himi_owner_lite_post": {
107
+ "displayName": "Post"
108
+ },
109
+ "himi_owner_lite_post_form": {
110
+ "displayName": "Post Form"
111
+ },
112
+ "himi_owner_lite_post_new": {
113
+ "displayName": "Post New"
114
+ },
115
+ "himi_owner_lite_posts": {
116
+ "displayName": "Posts"
117
+ },
118
+ "himi_owner_lite_product": {
119
+ "displayName": "Pro shop item"
120
+ },
121
+ "himi_owner_lite_product_form": {
122
+ "displayName": "Product Form"
123
+ },
124
+ "himi_owner_lite_product_new": {
125
+ "displayName": "Product New"
126
+ },
127
+ "himi_owner_lite_products": {
128
+ "displayName": "Products"
129
+ },
130
+ "himi_owner_lite_record_badges": {
131
+ "displayName": "Record Badges"
132
+ },
133
+ "himi_owner_lite_record_contacts": {
134
+ "displayName": "Record Contacts"
135
+ },
136
+ "himi_owner_lite_record_coupons": {
137
+ "displayName": "Record Coupons"
138
+ },
139
+ "himi_owner_lite_record_donationCampaigns": {
140
+ "displayName": "Record Donation Campaigns"
141
+ },
142
+ "himi_owner_lite_record_forms": {
143
+ "displayName": "Record Forms"
144
+ },
145
+ "himi_owner_lite_record_giftCards": {
146
+ "displayName": "Record Gift Cards"
147
+ },
148
+ "himi_owner_lite_record_groups": {
149
+ "displayName": "Record Groups"
150
+ },
151
+ "himi_owner_lite_record_invoices": {
152
+ "displayName": "Record Invoices"
153
+ },
154
+ "himi_owner_lite_record_members": {
155
+ "displayName": "Record Members"
156
+ },
157
+ "himi_owner_lite_record_plans": {
158
+ "displayName": "Record Plans"
159
+ },
160
+ "himi_owner_lite_record_portfolio": {
161
+ "displayName": "Record Portfolio"
162
+ },
163
+ "himi_owner_lite_record_programs": {
164
+ "displayName": "Record Programs"
165
+ },
166
+ "himi_owner_lite_record_reservations": {
167
+ "displayName": "Record Reservations"
168
+ },
169
+ "himi_owner_lite_record_reviews": {
170
+ "displayName": "Record Reviews"
171
+ },
172
+ "himi_owner_lite_record_subscriptions": {
173
+ "displayName": "Record Subscriptions"
174
+ },
175
+ "himi_owner_lite_search": {
176
+ "displayName": "Search records"
177
+ },
178
+ "himi_owner_lite_service": {
179
+ "displayName": "Class"
180
+ },
181
+ "himi_owner_lite_service_form": {
182
+ "displayName": "Service Form"
183
+ },
184
+ "himi_owner_lite_services": {
185
+ "displayName": "Services"
186
+ },
187
+ "himi_owner_lite_sites": {
188
+ "displayName": "Sites"
189
+ }
190
+ },
3
191
  "icon": "icon.png",
4
192
  "target": "prod",
5
193
  "shell": "owner-lite"
@@ -69,7 +69,7 @@ import {
69
69
  type AriaAttachmentChip,
70
70
  type AriaAttachmentOutcome,
71
71
  } from "./aria-attachments.js";
72
- import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
72
+ import { runClientToolExchange, type AriaClientTools, type ConsentMemory } from "./aria-client-tools.js";
73
73
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
74
74
  import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
75
75
  import {
@@ -90,6 +90,8 @@ import { MENTION_PAGE_SIZE, allowlisted, emptyMentionModel, mentionView,
90
90
  type MentionItemRow, type MentionLabels, type MentionModel, type MentionProvider, type MentionView } from "./aria-mentions.js";
91
91
  import { isTransportFailure, ownerTransportFailureText } from "./owner-load-error.js";
92
92
  import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMemo } from "./aria-hot-path.js";
93
+ import { ariaMessageRowId } from "./aria-row-id.js";
94
+ export { ariaMessageRowId } from "./aria-row-id.js";
93
95
  import { createLiveReply } from "./aria-stream.js";
94
96
  import { calendarDay, formatClock, formatDateMedium, formatMonthDay } from "./engine-intl.js";
95
97
 
@@ -281,6 +283,35 @@ export interface AriaChatState {
281
283
  attachments: AriaAttachmentChip[];
282
284
  /** Collapses the composer's attachment strip while it is empty. */
283
285
  attachmentsHidden: boolean;
286
+ /** Transcript scroll — the transcript scroll policy's SEND TOKEN (`transcriptScroll.sendPath`).
287
+ * A new value is a send: the host pins `scrollAnchor` near the top and re-arms
288
+ * following. Empty on the empty state, so a fresh chat rests at the top. */
289
+ scrollSend: string;
290
+ /** Transcript scroll — the component id of the turn to pin (`transcriptScroll.anchorPath`):
291
+ * the newest USER row while a pin is wanted, else empty. Derived in `gated`,
292
+ * because the row changes identity mid-turn — the optimistic local id is replaced
293
+ * by the server's — and the host must follow it without reading a new send. */
294
+ scrollAnchor: string;
295
+ /** The last send named a turn to pin (a question), as opposed to "show the latest"
296
+ * (a resumed conversation). */
297
+ _pinActive: boolean;
298
+ /** The character. The idle hero sits on the empty state, beside the prompt
299
+ * cards and gone with them; on the status line she THINKS while a turn is out with
300
+ * no reply text yet, and TALKS while the reply is being written. Derived in
301
+ * `gated`, in the `hiddenPath` polarity. */
302
+ heroHidden: boolean;
303
+ charThinkingHidden: boolean;
304
+ charTalkingHidden: boolean;
305
+ /** The status row holds the character AND a Spacer, so unlike a bare
306
+ * `collapseWhenEmpty` text it never collapses on its own — with no status and no
307
+ * character it would still cost the screen a stack gap. True when it has nothing
308
+ * to show. */
309
+ statusHidden: boolean;
310
+ /** Collapses the 8pt gap above the prompt cards while they show — the empty state,
311
+ * which is tight on a phone — and keeps it through every reply turn, where the
312
+ * (empty) prompt list's gap is part of the spacing under a reply. Always
313
+ * `!heroHidden`. */
314
+ promptsGapHidden: boolean;
284
315
  /** The quick replies the service suggested after the LATEST turn — short
285
316
  * answers the owner can send with one tap. They arrive only on the realtime
286
317
  * stream, a moment after the reply finishes, so a screen with no stream never
@@ -523,6 +554,11 @@ export interface AriaChatOptions {
523
554
  greeting: Resolvable<string>;
524
555
  prompts: Resolvable<AriaPromptChip[]>;
525
556
  labels?: AriaChatLabels;
557
+ /** The screen this machine paints. Supplying it names the pinned turn for the
558
+ * transcript scroll (`scrollAnchor` = `ariaMessageRowId(screenId, id)`,
559
+ * the id the screen builder stamps on every message row). Omitted, nothing is
560
+ * pinned: the send token alone still makes the host follow the reply. */
561
+ screenId?: string;
526
562
  origin?: string;
527
563
  assistantId?: string;
528
564
  /** Namespace for the local recent-conversation list. Partitioned by site when
@@ -986,6 +1022,39 @@ function lastReply(s: { messages: AriaBubble[] }): AriaBubble | undefined {
986
1022
  return last && last.notUser && last.id !== GREETING_ID ? last : undefined;
987
1023
  }
988
1024
 
1025
+ /** The next send token: `t1`, `t2`, … A counter carried IN the state rather than
1026
+ * in the worker, so a worker rebuilt over persisted state never re-issues the
1027
+ * token the host last saw — an equal token is "no send", by design. */
1028
+ export function nextScrollToken(prev: string): string {
1029
+ const n = /^t(\d+)$/.exec(prev)?.[1];
1030
+ return `t${(n ? Number(n) : 0) + 1}`;
1031
+ }
1032
+
1033
+ /** No conversation to follow: the empty state rests at the top. */
1034
+ const SCROLL_RESET = { scrollSend: "", _pinActive: false } as const;
1035
+
1036
+ /** A conversation put on screen whole (resumed from History, or rehydrated): open
1037
+ * at its latest turn with nothing pinned. A greeting alone is the empty state. */
1038
+ function openedAtLatest(s: { scrollSend: string }, messages: AriaBubble[]): { scrollSend: string; _pinActive: boolean } {
1039
+ return messages.some((b) => b.role === "USER")
1040
+ ? { scrollSend: nextScrollToken(s.scrollSend), _pinActive: false }
1041
+ : SCROLL_RESET;
1042
+ }
1043
+
1044
+ /** The newest owner turn, or undefined. */
1045
+ function newestUserBubble(messages: AriaBubble[]): AriaBubble | undefined {
1046
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "USER") return messages[i];
1047
+ return undefined;
1048
+ }
1049
+
1050
+ /** Has the reply to the newest owner turn started to appear? An assistant row with
1051
+ * words AFTER that turn — while the turn is still out, that is the agent writing. */
1052
+ function replyStarted(messages: AriaBubble[]): boolean {
1053
+ if (!messages.some((b) => b.role === "USER")) return false;
1054
+ const last = messages[messages.length - 1];
1055
+ return !!last && last.role !== "USER" && last.id !== GREETING_ID && !!String(last.content ?? "").trim();
1056
+ }
1057
+
989
1058
  /** Chips are visible only before the owner has said anything. */
990
1059
  function promptsFor(msgs: AriaBubble[], prompts: AriaPromptChip[]): AriaPromptChip[] {
991
1060
  return msgs.some((b) => b.role === "USER") ? [] : prompts;
@@ -1620,6 +1689,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1620
1689
  listening: false, micHidden: !o.dictation, listeningHidden: true,
1621
1690
  widget: NO_WIDGET, mention: mentionViewNow,
1622
1691
  attachments: [], attachmentsHidden: true,
1692
+ scrollSend: "", scrollAnchor: "", _pinActive: false,
1693
+ heroHidden: true, charThinkingHidden: true, charTalkingHidden: true, statusHidden: true, promptsGapHidden: false,
1623
1694
  quickReplies: [], quickRepliesHidden: true, _realtime: "off",
1624
1695
  };
1625
1696
 
@@ -2020,6 +2091,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2020
2091
  return { confirm: (ask) => t.confirm({ message: consentBody(ask.message) }), userAgent: t.userAgent };
2021
2092
  };
2022
2093
 
2094
+ /** The owner's consent approvals, by conversation: the native Aria app's
2095
+ * `ConsentPolicy`, scoped the way the native app scopes it.
2096
+ *
2097
+ * - Per CONVERSATION and per `consentKey`. A key approved in one conversation
2098
+ * is asked again in another, and a new chat is a new conversation.
2099
+ * - Per BUSINESS too. The native policy lives in a chat session that is rebuilt
2100
+ * for every identity × business, so the site is part of the key here: an
2101
+ * approval never carries across a site switch.
2102
+ * - In memory only. Nothing is written to storage, nothing expires, and there
2103
+ * is nothing to revoke it with. It is gone when the session that holds it
2104
+ * goes: for native that is sign-out, a business switch or the app quitting,
2105
+ * and here it is the worker being torn down.
2106
+ *
2107
+ * Only approvals are recorded, and only after an explicit Confirm
2108
+ * (`answerClientTool`). */
2109
+ const consentApprovals = new Map<string, Set<string>>();
2110
+ const consentMemoryFor = (site: string, convId: string): ConsentMemory => {
2111
+ const scope = `${site}\n${convId}`;
2112
+ return {
2113
+ approved: (key) => consentApprovals.get(scope)?.has(key) ?? false,
2114
+ remember: (key) => {
2115
+ const keys = consentApprovals.get(scope) ?? new Set<string>();
2116
+ keys.add(key);
2117
+ consentApprovals.set(scope, keys);
2118
+ },
2119
+ };
2120
+ };
2121
+
2023
2122
  /** Answer the client tools a send's response asked for, WITHOUT holding the
2024
2123
  * send open.
2025
2124
  *
@@ -2033,7 +2132,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2033
2132
  * the composer — can be reached while the owner is being asked. The epoch
2034
2133
  * check still guards the result: if the turn has moved on regardless, the
2035
2134
  * answer is not sent (`runClientToolExchange`, "abandoned"). */
2036
- function startToolExchange(convId: string, first: SendMessageResponse): void {
2135
+ function startToolExchange(convId: string, site: string, first: SendMessageResponse): void {
2037
2136
  if (!functionCallsOf(first).length) return;
2038
2137
  const seq = turnSeq;
2039
2138
  const current = () => turnSeq === seq;
@@ -2049,6 +2148,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2049
2148
  noteRows(first);
2050
2149
  mine.done = runClientToolExchange(first, {
2051
2150
  host: toolHost(),
2151
+ memory: consentMemoryFor(site, convId),
2052
2152
  callsOf: functionCallsOf,
2053
2153
  beforeSend: async () => {
2054
2154
  const now = await assistantRowsNow(convId);
@@ -2507,6 +2607,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2507
2607
  // reason a scrollback widget keeps its prose and loses its controls.
2508
2608
  widget: NO_WIDGET,
2509
2609
  ...retireDictation(),
2610
+ // Transcript scroll — a send: the host pins this question near the top and lets the
2611
+ // reply grow into the space beneath it.
2612
+ scrollSend: nextScrollToken(s.scrollSend), _pinActive: true,
2510
2613
  };
2511
2614
  inFlightSend = content;
2512
2615
  // Held outside the try so a FAILED send still reports the conversation it
@@ -2672,7 +2775,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2672
2775
  const sent: AriaChatState = { ...marked, _abortId: abortId, _turnId: abortId };
2673
2776
  inFlightSend = null;
2674
2777
  // After the second bump, so the exchange is keyed to THIS turn's epoch.
2675
- startToolExchange(convId, answer);
2778
+ startToolExchange(convId, site, answer);
2676
2779
  if (o.poll) {
2677
2780
  // Return NOW. The host owns the clock from here; `tick` merges the
2678
2781
  // reply in when it lands.
@@ -2799,12 +2902,28 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2799
2902
  const listening = s.listening === true;
2800
2903
  const micHidden = !o.dictation || dictationAvailable === false || listening;
2801
2904
  const listeningHidden = !listening;
2802
- // And the transcript list: hidden while empty, so it costs its stack no gap.
2905
+ // The transcript scroll + the character, derived here for the reason the gates above
2906
+ // are: one exit every action passes through, so no reducer can leave a stale
2907
+ // anchor pointing at a row that has since been replaced, or a character still
2908
+ // thinking after the reply landed.
2909
+ const pinRow = s._pinActive && o.screenId ? newestUserBubble(s.messages) : undefined;
2910
+ const scrollAnchor = pinRow ? ariaMessageRowId(o.screenId!, pinRow.id) : "";
2911
+ const inFlight = turnInFlight(s);
2912
+ const talking = inFlight && replyStarted(s.messages);
2913
+ const charThinkingHidden = !(inFlight && !talking);
2914
+ const charTalkingHidden = !talking;
2915
+ const heroHidden = !(s.prompts && s.prompts.length > 0);
2916
+ const promptsGapHidden = !heroHidden;
2803
2917
  const messagesHidden = s.messages.length === 0;
2918
+ const statusHidden = !s.statusText && charThinkingHidden && charTalkingHidden;
2804
2919
  const actioned = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
2805
- && s.micHidden === micHidden && s.listeningHidden === listeningHidden && s.messagesHidden === messagesHidden
2920
+ && s.micHidden === micHidden && s.listeningHidden === listeningHidden
2921
+ && s.scrollAnchor === scrollAnchor && s.charThinkingHidden === charThinkingHidden
2922
+ && s.charTalkingHidden === charTalkingHidden && s.heroHidden === heroHidden && s.messagesHidden === messagesHidden
2923
+ && s.statusHidden === statusHidden && s.promptsGapHidden === promptsGapHidden
2806
2924
  ? s
2807
- : { ...s, stopHidden: hidden, replyActionsHidden, micHidden, listeningHidden, messagesHidden };
2925
+ : { ...s, stopHidden: hidden, replyActionsHidden, micHidden, listeningHidden,
2926
+ scrollAnchor, charThinkingHidden, charTalkingHidden, heroHidden, messagesHidden, statusHidden, promptsGapHidden };
2808
2927
  // The attachment projection rides the same exit, for the same reason: one
2809
2928
  // place that every action's result passes through, so no reducer can hand
2810
2929
  // back a stale copy of the chips. Seeded first, so the very first action of
@@ -3013,7 +3132,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3013
3132
  // shown at once; a note carried over would say "Copied" beside an
3014
3133
  // answer from a different conversation that nobody copied.
3015
3134
  return leaving(await withWidgetItems({ ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
3016
- messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) }));
3135
+ messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()),
3136
+ // Transcript scroll — open a resumed conversation at its latest turn, pinning nothing.
3137
+ ...openedAtLatest(s, resumed) }));
3017
3138
  } catch { /* expired or unreachable — fall through to a fresh one */ }
3018
3139
  }
3019
3140
  // Opening a screen runs `load` more than once — the bundle self-kicks at
@@ -3042,15 +3163,18 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3042
3163
  // here, and a chip pack that localizes beside a greeting that does not
3043
3164
  // is the visible half of the same bug.
3044
3165
  const messages = isGreetingOnly(s.messages) ? greetingOnly() : withSpoken(s.messages);
3045
- return { ...s, status: "ready", messages, prompts: promptsFor(messages, promptList()) };
3166
+ // Transcript scroll — a conversation rehydrated with no send token (state persisted
3167
+ // before tokens existed) still opens at its latest turn.
3168
+ const opened = s.scrollSend || isGreetingOnly(messages) ? {} : openedAtLatest(s, messages);
3169
+ return { ...s, status: "ready", messages, prompts: promptsFor(messages, promptList()), ...opened };
3046
3170
  }
3047
3171
  try {
3048
3172
  return leaving({ ...s, status: "ready", _convId: await createOnce(site), _siteId: site, _pending: "", statusText: "",
3049
- messages: greetingOnly(), ...observed([]), prompts: promptList() });
3173
+ messages: greetingOnly(), ...observed([]), prompts: promptList(), ...SCROLL_RESET });
3050
3174
  } catch (e) {
3051
3175
  // Even if create fails, show the greeting; submit retries the create.
3052
3176
  return leaving({ ...s, status: "ready", _convId: "", _siteId: site, _pending: "", ...failureSlots(e, unavailableText),
3053
- messages: greetingOnly(), ...observed([]), prompts: promptList() });
3177
+ messages: greetingOnly(), ...observed([]), prompts: promptList(), ...SCROLL_RESET });
3054
3178
  }
3055
3179
  },
3056
3180
 
@@ -3142,11 +3266,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3142
3266
  // previous one, which reads as ➕ having done nothing.
3143
3267
  turnSeq += 1;
3144
3268
  return slow({ ...s, ...retired, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
3145
- _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
3269
+ _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false, ...SCROLL_RESET });
3146
3270
  } catch (e) {
3147
3271
  turnSeq += 1; // see above — a failed create commits state too
3148
3272
  return slow({ ...s, ...retired, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
3149
- _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
3273
+ _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false, ...SCROLL_RESET });
3150
3274
  }
3151
3275
  },
3152
3276
 
@@ -21,11 +21,16 @@
21
21
  // * approval needs an explicit tap. Anything else — a dismissal, a host with
22
22
  // nothing to ask with, a presenter that threw — is NOT consent; the first
23
23
  // is answered as a refusal and the others as a failure, never as a yes.
24
- // * NO remembered consent. The native app can auto-approve a `consentKey`
25
- // it has seen approved in the same conversation. That is a convenience,
26
- // and one that turns a single yes into a standing grant the agent can
27
- // re-use on a different action under the same key. It has to be argued for
28
- // on its own; it is not inherited here.
24
+ // * an approval is remembered exactly as the native Aria app remembers it,
25
+ // and no further: per conversation, keyed by the call's `consentKey`,
26
+ // approvals only (a refusal is asked again), held in memory only, with no
27
+ // expiry, and gone with the session that holds it. A call without a
28
+ // `consentKey` — or with an empty or blank one, where the native clients
29
+ // are laxer — is never remembered. A remembered approval is answered
30
+ // without asking and says so on the wire (`rememberedConsent: true`), so
31
+ // the agent can tell a fresh tap from a standing one. The memory itself
32
+ // is the caller's (`ConsentMemory`): this file reads and writes it, but
33
+ // what counts as "the same conversation" is the caller's to say.
29
34
  // * the BUTTONS are ours. The agent may suggest `approveText`/`denyText`, and
30
35
  // they are ignored: an approve button labelled "Cancel" is how a model that
31
36
  // cannot forge the tap gets the owner to make it.
@@ -58,6 +63,26 @@ export interface ClientToolCall {
58
63
  params: Record<string, unknown>;
59
64
  }
60
65
 
66
+ /** Approvals already given, by `consentKey`, in ONE conversation: the native
67
+ * app's `ConsentPolicy`. The caller hands in the memory of the conversation the
68
+ * call belongs to; this module never decides the scope. */
69
+ export interface ConsentMemory {
70
+ /** Was `consentKey` approved earlier in this conversation? */
71
+ approved: (consentKey: string) => boolean;
72
+ /** Record an approval. Called only after an explicit Confirm. */
73
+ remember: (consentKey: string) => void;
74
+ }
75
+
76
+ /** The key a consent call can be remembered under, or `undefined` when it has
77
+ * none. Both native clients accept any string here, the empty one included
78
+ * (`params["consentKey"] as? String`). We do not: an empty or blank key names
79
+ * no action at all, and remembering it would let one approval wave through
80
+ * every later keyless-in-all-but-name request in the conversation. */
81
+ export function consentKeyOf(call: ClientToolCall): string | undefined {
82
+ const k = call.params.consentKey;
83
+ return typeof k === "string" && k.trim() !== "" ? k : undefined;
84
+ }
85
+
61
86
  /** What the host must provide to run the built-ins. Omit it and every call is
62
87
  * answered "not found" — honest, and it unblocks the agent. */
63
88
  export interface AriaClientTools {
@@ -123,8 +148,14 @@ export function failedResult(call: { id?: string; name?: string }, message: stri
123
148
 
124
149
  /** The owner's answer. A refusal is still a SUCCESSFUL tool run — the tool's job
125
150
  * was to ask, and it did; the model replies to the refusal. */
126
- export function consentResult(call: ClientToolCall, approved: boolean): FunctionCallResult {
127
- return { ...addressOf(call), success: true, widgetResponse: JSON.stringify({ ...call.params, userConsent: approved }) };
151
+ export function consentResult(call: ClientToolCall, approved: boolean, remembered = false): FunctionCallResult {
152
+ // `rememberedConsent` is written only when it is true, which is the native
153
+ // clients' exact payload: a fresh answer is byte-for-byte what it was before.
154
+ // It is OURS to set: one the agent put in the call's own arguments is dropped,
155
+ // so a fresh tap can never go out claiming to be a standing approval.
156
+ const { rememberedConsent: _agentSupplied, ...params } = call.params;
157
+ const payload = { ...params, userConsent: approved, ...(remembered ? { rememberedConsent: true } : {}) };
158
+ return { ...addressOf(call), success: true, widgetResponse: JSON.stringify(payload) };
128
159
  }
129
160
 
130
161
  export function knowledgeResult(call: ClientToolCall, userAgent: string): FunctionCallResult {
@@ -134,11 +165,21 @@ export function knowledgeResult(call: ClientToolCall, userAgent: string): Functi
134
165
  /** Answer ONE call. Never rejects: every outcome, including a host that threw,
135
166
  * becomes a result the agent can read, because an unanswered call is a turn
136
167
  * that never ends. */
137
- export async function answerClientTool(call: FunctionCall, host: AriaClientTools | undefined): Promise<FunctionCallResult> {
168
+ export async function answerClientTool(
169
+ call: FunctionCall,
170
+ host: AriaClientTools | undefined,
171
+ memory?: ConsentMemory,
172
+ ): Promise<FunctionCallResult> {
138
173
  const parsed = parseClientToolCall(call);
139
174
  if (!parsed) return failedResult(call && typeof call === "object" ? call : {}, "arguments are not a JSON object");
140
175
  if (!host) return notFoundResult(parsed);
141
176
  if (parsed.toolId === CONSENT_TOOL) {
177
+ const key = consentKeyOf(parsed);
178
+ // The memory is checked before the owner is asked. A key approved earlier in
179
+ // this conversation is answered without a second question, as the native
180
+ // `ConsentCoordinator.request` does: it checks its policy first and returns
181
+ // `remembered: true` without showing anything.
182
+ if (key !== undefined && memory?.approved(key)) return consentResult(parsed, true, true);
142
183
  const message = typeof parsed.params.message === "string" ? parsed.params.message : "";
143
184
  let approved: boolean;
144
185
  try {
@@ -150,6 +191,10 @@ export async function answerClientTool(call: FunctionCall, host: AriaClientTools
150
191
  } catch (e) {
151
192
  return failedResult(parsed, `the owner could not be asked (${String((e as Error)?.message ?? e)})`);
152
193
  }
194
+ // Recorded the moment the owner taps Confirm, as the native coordinator's
195
+ // `resolve` records it: before the result is sent, so it holds even for a
196
+ // turn that is then abandoned. A refusal is never recorded; it is asked again.
197
+ if (approved && key !== undefined) memory?.remember(key);
153
198
  return consentResult(parsed, approved);
154
199
  }
155
200
  if (parsed.toolId === KNOWLEDGE_TOOL) {
@@ -182,6 +227,9 @@ export type ClientToolOutcome =
182
227
 
183
228
  export interface ClientToolExchange {
184
229
  host?: AriaClientTools;
230
+ /** The approvals of the conversation this exchange runs in. Omit it and every
231
+ * consent is asked. */
232
+ memory?: ConsentMemory;
185
233
  /** The calls a response is waiting on — `functionCallsOf` from the chat client. */
186
234
  callsOf: (res: SendMessageResponse) => FunctionCall[];
187
235
  /** Send one round of results; resolves with the service's next response. */
@@ -222,7 +270,7 @@ export async function runClientToolExchange(first: SendMessageResponse, x: Clien
222
270
  if (!x.stillCurrent()) return "abandoned";
223
271
  if (calls.length > 1) throw clientToolError(`the assistant asked for ${calls.length} client tools at once`);
224
272
  if (rounds >= max) throw clientToolError(`the assistant asked for more than ${max} client tools in one turn`);
225
- const result = await answerClientTool(calls[0], x.host);
273
+ const result = await answerClientTool(calls[0], x.host, x.memory);
226
274
  if (!x.stillCurrent()) return "abandoned";
227
275
  if (x.beforeSend) {
228
276
  await x.beforeSend();
@@ -0,0 +1,14 @@
1
+ // The id a message row carries, written ONCE for both halves of Aria: the screen
2
+ // builder (aria-chat-screen.ts) stamps it on the row template as
3
+ // `ariaMessageRowId(screenId, "$item.id")`, and the worker (aria-chat.ts) names the row
4
+ // the screen's `transcriptScroll` should pin as `ariaMessageRowId(screenId, bubble.id)`.
5
+ // A row renamed on one side and not the other would pin nothing, silently — so neither
6
+ // side spells the format itself.
7
+ //
8
+ // Its own module, not a member of aria-chat.ts, because the screen builder runs on the
9
+ // server that builds descriptors and must not pull the whole conversation machine (and
10
+ // its SDK imports) in to read one template string.
11
+
12
+ export function ariaMessageRowId(screenId: string, bubbleId: string): string {
13
+ return `${screenId}.msg_${bubbleId}`;
14
+ }
@@ -186,6 +186,9 @@ const core = createAriaChat({
186
186
  // rejected by the descriptor validator, so omitting this is the correct
187
187
  // behaviour there and the card degrades to prose.
188
188
  subtree: { slotId: WIDGET_SUBTREE_SLOT, service: SERVICE },
189
+ // Transcript scroll: names the row the transcript scroll pins on a send. Must be the
190
+ // descriptor's screen id — the row ids are `<screenId>.msg_<bubble id>`.
191
+ screenId: "himi_owner_lite_aria",
189
192
  greeting: () => copyParam(
190
193
  "ownerAria.greeting",
191
194
  "Hi, I'm Aria, your business and web design expert.\nI'm here to help you complete tasks, answer questions, create content and guide you to useful features.",
@@ -1,5 +1,13 @@
1
1
  {
2
2
  "name": "__HIMI_APP_NAME__",
3
+ "screens": {
4
+ "himi_chat": {
5
+ "displayName": "Chat"
6
+ },
7
+ "himi_chat_settings": {
8
+ "displayName": "Settings"
9
+ }
10
+ },
3
11
  "icon": "icon.png",
4
12
  "target": "prod"
5
13
  }