@wix/himalaya-cli 0.1015.0 → 0.1016.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 (89) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +67 -1
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +45 -3
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +18 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +7 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +51 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +21 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +79 -6
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +23 -0
  9. package/dist/cli.mjs +1225 -645
  10. package/dist/preview-assets.generated.bin +0 -0
  11. package/guides/component-reference.json +1 -1
  12. package/guides/component-reference.md +25 -9
  13. package/guides/designing-himi-screens.md +6 -4
  14. package/guides/headless-loop.md +1 -0
  15. package/guides/sdk-reference.json +47 -5
  16. package/guides/tier-5-add-a-screen.md +40 -13
  17. package/guides/wix-api-usage.md +9 -0
  18. package/package.json +2 -2
  19. package/templates/beauty-salon/dev/index.ts +252 -7
  20. package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +5 -2
  21. package/templates/beauty-salon-owner/config.ts +1 -1
  22. package/templates/beauty-salon-owner/dev/index.ts +419 -2
  23. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +226 -9
  24. package/templates/beauty-salon-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  25. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  26. package/templates/branded-lite/dev/index.ts +487 -14
  27. package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +5 -2
  28. package/templates/community-club/dev/index.ts +196 -13
  29. package/templates/community-club-owner/config.ts +1 -1
  30. package/templates/community-club-owner/dev/index.ts +402 -2
  31. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +226 -9
  32. package/templates/community-club-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  33. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  34. package/templates/events-meetups/dev/index.ts +196 -13
  35. package/templates/events-meetups-owner/config.ts +1 -1
  36. package/templates/events-meetups-owner/dev/index.ts +348 -2
  37. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +226 -9
  38. package/templates/events-meetups-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  39. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  40. package/templates/gym-fitness/dev/index.ts +324 -12
  41. package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +5 -2
  42. package/templates/gym-fitness-owner/config.ts +1 -1
  43. package/templates/gym-fitness-owner/dev/index.ts +440 -2
  44. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +226 -9
  45. package/templates/gym-fitness-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  46. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  47. package/templates/himi-chat/dev/index.ts +2 -2
  48. package/templates/nonprofit-cause/dev/index.ts +212 -13
  49. package/templates/nonprofit-cause-owner/config.ts +1 -1
  50. package/templates/nonprofit-cause-owner/dev/index.ts +386 -2
  51. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +226 -9
  52. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  53. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  54. package/templates/owner-lite/config.ts +1 -1
  55. package/templates/owner-lite/dev/index.ts +446 -2
  56. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +226 -9
  57. package/templates/owner-lite/tier5-src/_shared/aria-client-tools.ts +234 -0
  58. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  59. package/templates/podcast-creator/dev/index.ts +206 -13
  60. package/templates/podcast-creator-owner/config.ts +1 -1
  61. package/templates/podcast-creator-owner/dev/index.ts +402 -2
  62. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +226 -9
  63. package/templates/podcast-creator-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  64. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  65. package/templates/portfolio-creative/dev/index.ts +249 -11
  66. package/templates/portfolio-creative-owner/config.ts +1 -1
  67. package/templates/portfolio-creative-owner/dev/index.ts +419 -2
  68. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +226 -9
  69. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  70. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  71. package/templates/restaurant/dev/index.ts +219 -11
  72. package/templates/restaurant-owner/config.ts +1 -1
  73. package/templates/restaurant-owner/dev/index.ts +440 -2
  74. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +226 -9
  75. package/templates/restaurant-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  76. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  77. package/templates/retail-store/dev/index.ts +159 -6
  78. package/templates/retail-store-owner/config.ts +1 -1
  79. package/templates/retail-store-owner/dev/index.ts +381 -2
  80. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +226 -9
  81. package/templates/retail-store-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  82. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
  83. package/templates/yoga-studio/dev/index.ts +284 -12
  84. package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +5 -2
  85. package/templates/yoga-studio-owner/config.ts +1 -1
  86. package/templates/yoga-studio-owner/dev/index.ts +386 -2
  87. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +226 -9
  88. package/templates/yoga-studio-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
  89. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
@@ -31,16 +31,20 @@ import {
31
31
  ariaAssistantConfig,
32
32
  conversationIdOf,
33
33
  createConversation,
34
+ functionCallsOf,
34
35
  getConversationMessages,
35
36
  getConversations,
36
37
  messageText,
37
38
  messagesOf,
39
+ sendFunctionCallResults,
38
40
  sendMessage,
39
41
  triggerAssistantMessage,
40
42
  userMessageIdOf,
41
43
  type ConversationMessage,
42
44
  type ConversationSummary,
45
+ type SendMessageResponse,
43
46
  } from "@himalaya/wix-genie-chat";
47
+ import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
44
48
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
45
49
  import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
46
50
  import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
@@ -61,6 +65,11 @@ import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMe
61
65
  * so both sides import this. */
62
66
  export const ARIA_LINK_SLOTS = 3;
63
67
 
68
+ /** The longest consent question put in front of the owner. It is model output
69
+ * shown in a modal, so an agent that writes an essay must not be able to push
70
+ * the buttons off a small screen. */
71
+ export const CONSENT_MAX_CHARS = 600;
72
+
64
73
  /** One rendered message. Every bubble carries ALL link slots — a `$item.` path
65
74
  * that is missing on some rows does not substitute, so the fields are always
66
75
  * present and `linkNHidden` collapses the unused ones. */
@@ -232,6 +241,10 @@ export interface AriaChatLabels {
232
241
  * all — it carries no Wix app binding to sign the chat-service call with.
233
242
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
234
243
  unavailable?: Resolvable<string>;
244
+ /** Shown when a client-tool exchange broke down (see `clientTools`) and the
245
+ * turn cannot finish. The underlying reason is a protocol fault the owner can
246
+ * do nothing about, so it is not what they are shown. */
247
+ toolFailed?: Resolvable<string>;
235
248
  }
236
249
 
237
250
  export interface AriaChatOptions {
@@ -248,6 +261,15 @@ export interface AriaChatOptions {
248
261
  * it renders an ASYNC_TOOL's opening message and never advances it, which is
249
262
  * strictly better than showing a spinner that can never stop. */
250
263
  asyncJobState?: (jobId: string) => Promise<string>;
264
+ /** Run the client tools the agent asks for — above all, ask the OWNER before
265
+ * it acts on their account (`aria-client-tools.ts` says why that is a security
266
+ * boundary and what "asking" must and must not mean).
267
+ *
268
+ * Optional, and omitting it is safe: every call is then answered "not found",
269
+ * which unblocks the agent without anyone having agreed to anything. What is
270
+ * NOT safe is leaving a call unanswered — the agent waits for the result, so
271
+ * the turn would never end. */
272
+ clientTools?: AriaClientTools;
251
273
  greeting: Resolvable<string>;
252
274
  prompts: Resolvable<AriaPromptChip[]>;
253
275
  labels?: AriaChatLabels;
@@ -588,6 +610,16 @@ export function toBubbles(
588
610
  return out;
589
611
  }
590
612
 
613
+ /** What of an assistant row a READER should see: HIDDEN notes out first (the
614
+ * service's own, transcript-only), then widget envelopes, leaving the prose.
615
+ * One function because two readers need it — the bubble and the consent
616
+ * question — and a cleaning stage added to one and not the other is raw markup
617
+ * in front of the owner. `closed` is false for a row still being written, whose
618
+ * trailing unterminated envelope must stay hidden. */
619
+ export function assistantProse(raw: string, closed = true): string {
620
+ return proseOfParts(scanEnvelopes(stripHidden(raw), 0, closed).parts);
621
+ }
622
+
591
623
  /** Bubbles are built from one message's raw text; `null` is "paints nothing". */
592
624
  export type BubbleMemo = RowMemo<AriaBubble | null>;
593
625
  export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
@@ -602,7 +634,7 @@ function bubbleOf(id: string, role: string, who: string, raw: string, writing: b
602
634
  // A row the service is still writing is scanned as STREAMING: an
603
635
  // envelope whose `>>` has not arrived yet is held back rather than
604
636
  // painted as raw JSON until the row finishes.
605
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !writing).parts);
637
+ const prose = role === "USER" ? raw : assistantProse(raw, !writing);
606
638
  const content = role === "USER" ? prose : stripMarkdown(prose);
607
639
  if (!content || !content.trim()) return null;
608
640
  return {
@@ -1037,6 +1069,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1037
1069
  const unavailableText = () => res(
1038
1070
  o.labels?.unavailable ?? `${assistant()} can't reach your Wix account from here.`,
1039
1071
  );
1072
+ const toolFailedText = () => res(o.labels?.toolFailed ?? `${assistant()} couldn't finish that. Try again.`);
1040
1073
  const promptList = () => res(o.prompts);
1041
1074
  const siteOf = o.siteId ?? (() => "");
1042
1075
  const resumeOf = o.resumeConversationId ?? inputConversationId;
@@ -1181,6 +1214,164 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1181
1214
  let draft = "";
1182
1215
  let draftSeq = 0;
1183
1216
 
1217
+ /** The client-tool exchange the current turn is in the middle of, if any.
1218
+ *
1219
+ * While it runs, the agent is WAITING ON US — for the owner's consent, say —
1220
+ * so the turn is still in flight whatever the transcript shows, and nothing
1221
+ * may read it as over: not a note the agent wrote before asking, not a count
1222
+ * that happens to have moved. `tick` and `busyLoop` both hold the turn open on
1223
+ * this.
1224
+ *
1225
+ * A latch rather than state for the reason `inFlightSend` is one: the exchange
1226
+ * outlives the action that started it (see `startToolExchange`), so no state
1227
+ * that action returned can describe it. Keyed by conversation and turn epoch,
1228
+ * so it only ever holds the turn it belongs to. */
1229
+ let toolExchange: { convId: string; seq: number; done: Promise<void> } | null = null;
1230
+ /** The reply the owner is owed once an exchange has been answered: the
1231
+ * assistant row the last results post opened. Until it is written, an
1232
+ * earlier row — a note the agent wrote before asking — must not read as the
1233
+ * turn's answer; it predates the owner's consent. */
1234
+ let toolAnswer: { convId: string; seq: number; messageId: string } | null = null;
1235
+ /** What the transcript held just before this turn's LAST results post: the
1236
+ * assistant rows that already existed, and how many of them were finished
1237
+ * answers (`null` when that read failed). Every one of those predates the
1238
+ * owner's consent, so none can be the reply to it.
1239
+ *
1240
+ * The fallback for when the results post names no row (`toolAnswer` unset)
1241
+ * or names one the transcript does not hold. Without it the ordinary rule
1242
+ * runs, and it reads a note the agent wrote BEFORE asking as the answer:
1243
+ * typing and Stop clear while the approved operation is still running, and
1244
+ * a second turn can start on top of it. */
1245
+ let toolPosted: { convId: string; seq: number; ids: Set<string>; settled: number | null } | null = null;
1246
+ /** An exchange that FAILED, waiting for the next transcript read to end its
1247
+ * turn. Same keying: a failure is only ever reported against its own turn. */
1248
+ let toolFailure: { convId: string; seq: number; error: unknown } | null = null;
1249
+
1250
+ const exchanging = (convId: string): boolean =>
1251
+ !!toolExchange && toolExchange.convId === convId && toolExchange.seq === turnSeq;
1252
+
1253
+ /** Has the reply owed after an exchange landed? `null` when this turn had no
1254
+ * exchange, so the ordinary rule decides.
1255
+ *
1256
+ * The row the last results post opened decides when the transcript holds
1257
+ * it. Otherwise — no row named, or the service stored the reply under
1258
+ * another id — the answer is a finished assistant row that was NOT there
1259
+ * before the post (`toolPosted`), never one that was. */
1260
+ const toolReplied = (convId: string, msgs: ConversationMessage[]): boolean | null => {
1261
+ const a = toolAnswer;
1262
+ if (a && a.convId === convId && a.seq === turnSeq) {
1263
+ const row = msgs.find((m) => m.id === a.messageId);
1264
+ if (row) return !!answerText(row) && !stillWriting(row);
1265
+ }
1266
+ const p = toolPosted;
1267
+ if (!p || p.convId !== convId || p.seq !== turnSeq) return null;
1268
+ let unkeyed = false;
1269
+ for (const m of msgs) {
1270
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
1271
+ if (!m.id) { unkeyed = true; continue; }
1272
+ if (!p.ids.has(m.id) && answerText(m) && !stillWriting(m)) return true;
1273
+ }
1274
+ // Rows without an id cannot be told apart, so only a count can say one is
1275
+ // new — and only when the count before the post was read.
1276
+ return unkeyed && p.settled !== null ? countSettledAnswers(msgs) > p.settled : false;
1277
+ };
1278
+
1279
+ /** The assistant rows in the transcript right now — `toolPosted`'s input.
1280
+ * Never rejects: a failed read is reported as `null`. */
1281
+ const assistantRowsNow = async (convId: string): Promise<{ ids: string[]; settled: number } | null> => {
1282
+ try {
1283
+ const raw = messagesOf(await getConversationMessages(convId));
1284
+ const ids = raw.filter((m) => (m.role || "ASSISTANT") === "ASSISTANT" && m.id).map((m) => m.id as string);
1285
+ return { ids, settled: countSettledAnswers(raw) };
1286
+ } catch {
1287
+ return null;
1288
+ }
1289
+ };
1290
+
1291
+ /** The failure of THIS conversation's current turn, consumed on read. */
1292
+ const takeToolFailure = (convId: string): unknown => {
1293
+ const f = toolFailure;
1294
+ if (!f || f.convId !== convId || f.seq !== turnSeq) return null;
1295
+ toolFailure = null;
1296
+ return f.error ?? new Error("client tools failed");
1297
+ };
1298
+
1299
+ /** How a broken exchange reads on screen. The Wix-unreachable refusals keep
1300
+ * their own neutral sentence; anything else is a protocol fault the owner can
1301
+ * do nothing about, so they get a plain one rather than its text. */
1302
+ const toolFailureSlots = (e: unknown): { error: string; statusText: string } =>
1303
+ isWixAccountUnreachable(e) ? failureSlots(e, unavailableText) : { error: toolFailedText(), statusText: "" };
1304
+
1305
+ /** The agent's consent question as the owner should read it: its envelopes,
1306
+ * HIDDEN notes and markdown gone — the same stages a bubble goes through — and
1307
+ * capped, because it is model output and lands in a modal. */
1308
+ const consentBody = (raw: string): string => {
1309
+ const prose = stripMarkdown(assistantProse(raw)).trim();
1310
+ // Cut by code POINT, not UTF-16 unit, so an emoji at the boundary is never
1311
+ // split into a lone surrogate.
1312
+ const chars = Array.from(prose);
1313
+ return chars.length > CONSENT_MAX_CHARS ? `${chars.slice(0, CONSENT_MAX_CHARS - 1).join("").trimEnd()}…` : prose;
1314
+ };
1315
+
1316
+ /** The host's tools, with the question cleaned on its way to the owner. */
1317
+ const toolHost = (): AriaClientTools | undefined => {
1318
+ const t = o.clientTools;
1319
+ if (!t) return undefined;
1320
+ return { confirm: (ask) => t.confirm({ message: consentBody(ask.message) }), userAgent: t.userAgent };
1321
+ };
1322
+
1323
+ /** Answer the client tools a send's response asked for, WITHOUT holding the
1324
+ * send open.
1325
+ *
1326
+ * Detached on purpose. On the `_poll` path the owner's own bubble and the
1327
+ * typing line paint when `send` RETURNS — so awaiting a consent inside it would
1328
+ * put the question on screen over a chat that does not yet show what the owner
1329
+ * asked. Instead the send commits, the ladder runs, and the exchange holds the
1330
+ * turn open (`exchanging`) until it is done.
1331
+ *
1332
+ * The consent presenter is modal, so nothing on the Aria screen — Stop, ➕,
1333
+ * the composer — can be reached while the owner is being asked. The epoch
1334
+ * check still guards the result: if the turn has moved on regardless, the
1335
+ * answer is not sent (`runClientToolExchange`, "abandoned"). */
1336
+ function startToolExchange(convId: string, first: SendMessageResponse): void {
1337
+ if (!functionCallsOf(first).length) return;
1338
+ const seq = turnSeq;
1339
+ const current = () => turnSeq === seq;
1340
+ const mine: { convId: string; seq: number; done: Promise<void> } = { convId, seq, done: Promise.resolve() };
1341
+ toolExchange = mine;
1342
+ // Rows the service handed back while it was still ASKING are notes written
1343
+ // before the owner answered. Kept even when the pre-post read fails, which
1344
+ // is when they are the only record of what predates the result.
1345
+ const asked = new Set<string>();
1346
+ const noteRows = (res: SendMessageResponse) => {
1347
+ for (const r of res.assistantMessages ?? []) if (r.id) asked.add(r.id);
1348
+ };
1349
+ noteRows(first);
1350
+ mine.done = runClientToolExchange(first, {
1351
+ host: toolHost(),
1352
+ callsOf: functionCallsOf,
1353
+ beforeSend: async () => {
1354
+ const now = await assistantRowsNow(convId);
1355
+ if (!current()) return;
1356
+ toolPosted = { convId, seq, ids: new Set([...asked, ...(now?.ids ?? [])]), settled: now ? now.settled : null };
1357
+ },
1358
+ send: async (results) => {
1359
+ const res = await sendFunctionCallResults(convId, results, { origin });
1360
+ if (functionCallsOf(res).length) noteRows(res);
1361
+ return res;
1362
+ },
1363
+ stillCurrent: current,
1364
+ onFinal: (res) => {
1365
+ const rows = res.assistantMessages ?? [];
1366
+ const id = rows[rows.length - 1]?.id;
1367
+ if (id && current()) toolAnswer = { convId, seq, messageId: id };
1368
+ },
1369
+ }).then(
1370
+ () => undefined,
1371
+ (error: unknown) => { if (current()) toolFailure = { convId, seq, error }; },
1372
+ ).then(() => { if (toolExchange === mine) toolExchange = null; });
1373
+ }
1374
+
1184
1375
  /** Raise the fence for one abort of `seq`; returns the lowering half. */
1185
1376
  const fenceEpoch = (seq: number): (() => void) => {
1186
1377
  abortingEpochs.set(seq, (abortingEpochs.get(seq) ?? 0) + 1);
@@ -1301,11 +1492,20 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1301
1492
  const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
1302
1493
  if (bubbles.length) server = bubbles;
1303
1494
  seen = raw;
1495
+ const failed = takeToolFailure(convId);
1496
+ if (failed) {
1497
+ return { ...s, status: "ready", _convId: convId, messages: server, input: "", _pending: "", _abortId: "", ...observed(raw, s.widget), ok: false, ...toolFailureSlots(failed) };
1498
+ }
1499
+ // Not over while the agent is waiting on a client tool from us — and this
1500
+ // loop has no clock of its own, so re-reading meanwhile would spend its
1501
+ // rounds in a burst. Wait for the exchange, then read again.
1502
+ const pendingExchange = exchanging(convId) ? toolExchange : null;
1503
+ if (pendingExchange) { await pendingExchange.done; n--; continue; }
1304
1504
  // The turn is over when the SERVER holds an assistant answer — which a
1305
1505
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1306
1506
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1307
1507
  // stalled `tick`.
1308
- if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1508
+ if (toolReplied(convId, raw) ?? repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1309
1509
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1310
1510
  }
1311
1511
  }
@@ -1452,13 +1652,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1452
1652
  // The retry path needs no send to learn it: holding that id is what
1453
1653
  // `stalledTurn` IS. So a turn recovered from a failed trigger is just as
1454
1654
  // stoppable as one sent cleanly.
1655
+ //
1656
+ // The response is kept, not just its id: it is also where the agent asks
1657
+ // for client tools (`startToolExchange`), on the retry path as much as on
1658
+ // a clean send.
1455
1659
  let abortId: string;
1660
+ let answer: SendMessageResponse;
1456
1661
  if (stalled) {
1457
1662
  retriedId = stalled.userMessageId;
1458
1663
  abortId = stalled.userMessageId;
1459
- await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
1664
+ answer = await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
1460
1665
  } else {
1461
- abortId = userMessageIdOf(await sendMessage(convId, content, { origin }));
1666
+ answer = await sendMessage(convId, content, { origin });
1667
+ abortId = userMessageIdOf(answer);
1462
1668
  }
1463
1669
  // A SECOND boundary bump, and the load-bearing one. The bump at entry only
1464
1670
  // retires a tick that was already parked when the owner tapped Send. A tick
@@ -1513,6 +1719,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1513
1719
  // the answer to this one.
1514
1720
  const sent: AriaChatState = { ...marked, _abortId: abortId, _turnId: abortId };
1515
1721
  inFlightSend = null;
1722
+ // After the second bump, so the exchange is keyed to THIS turn's epoch.
1723
+ startToolExchange(convId, answer);
1516
1724
  if (o.poll) {
1517
1725
  // Return NOW. The host owns the clock from here; `tick` merges the
1518
1726
  // reply in when it lands.
@@ -1935,22 +2143,31 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1935
2143
  // Counted over RAW messages, so a widget-only answer still ends the turn
1936
2144
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1937
2145
  // Exact when the service named the turn, counted when it did not.
1938
- const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
2146
+ //
2147
+ // …and never while the agent is waiting on a client tool from us: it has
2148
+ // asked, say, for the owner's consent and will write nothing more until it
2149
+ // has the answer, so whatever the transcript shows the turn is still ours
2150
+ // to finish. An exchange that FAILED ends it instead, with a line saying so.
2151
+ const failed = takeToolFailure(s._convId);
2152
+ const replied = !failed && !exchanging(s._convId)
2153
+ && (toolReplied(s._convId, raw) ?? repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter);
2154
+ const ended = replied || !!failed;
1939
2155
  const next: AriaChatState = {
1940
2156
  ...s,
1941
- status: replied ? "ready" : s.status,
2157
+ status: ended ? "ready" : s.status,
1942
2158
  messages: painted,
1943
- _pending: pending,
2159
+ _pending: failed ? "" : pending,
1944
2160
  ...observed(raw, s.widget),
1945
2161
  prompts: promptsFor(painted, promptList()),
1946
- statusText: replied ? "" : s.statusText,
2162
+ statusText: ended ? "" : s.statusText,
2163
+ ...(failed ? { _abortId: "", ...toolFailureSlots(failed) } : {}),
1947
2164
  };
1948
2165
  // The async job rides this same tick — see `withJobProgress`. Done
1949
2166
  // AFTER the transcript rebuild, so a widget that has just been retired
1950
2167
  // by a new answer is never polled for.
1951
2168
  const withJob = { ...next, widget: await withJobProgress(next.widget) };
1952
2169
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
1953
- return replied && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
2170
+ return ended && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
1954
2171
  } catch {
1955
2172
  // A read that THREW is exactly as stale as one that resolved: if a turn
1956
2173
  // boundary moved on while it was in flight, returning `s` merges the
@@ -0,0 +1,234 @@
1
+ // Client tools for the Wix "Aria" assistant: the calls the agent makes on the
2
+ // CLIENT, and the consent gate in front of the one that matters.
3
+ //
4
+ // The chat-service can answer a turn with `functionCalls` instead of an assistant
5
+ // message. The agent is then blocked: it writes nothing more until the client
6
+ // sends a result back (`sendFunctionCallResults`), and a client that ignores the
7
+ // call leaves "Aria is typing…" on screen for good. So every call gets an answer
8
+ // — even one this client cannot run, which is answered "not found" in the exact
9
+ // words the service already reads from the other two clients.
10
+ //
11
+ // Two built-ins exist, both taken from the native Aria app and the React Native
12
+ // dashboard assistant, which agree on them to the letter:
13
+ //
14
+ // - `consentWithMessage` — "may I do this?". The agent asks BEFORE acting on the
15
+ // owner's account (sending a campaign, changing a product), and the owner's
16
+ // answer goes back as `userConsent`. THIS IS A SECURITY BOUNDARY, not a
17
+ // feature: the text of the question is model output, and the model's input
18
+ // includes content the owner does not control (a review, an enquiry, a product
19
+ // description) — so a prompt-injected agent can ask for anything. The answer
20
+ // is the only thing standing between that and the account. Hence:
21
+ // * approval needs an explicit tap. Anything else — a dismissal, a host with
22
+ // nothing to ask with, a presenter that threw — is NOT consent; the first
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.
29
+ // * the BUTTONS are ours. The agent may suggest `approveText`/`denyText`, and
30
+ // they are ignored: an approve button labelled "Cancel" is how a model that
31
+ // cannot forge the tap gets the owner to make it.
32
+ // - `getClientSideKnowledge` — "what are you running on?". Read-only; answered
33
+ // with a user-agent string and the call's own arguments echoed back.
34
+ //
35
+ // This file is copied verbatim into every generated starter app, so it explains
36
+ // itself without pointing at anything only this repo has.
37
+
38
+ // Types only: this module has no runtime dependency, so it can be unit-tested
39
+ // without a bundler. The one decoder it needs is handed in (`callsOf`).
40
+ import type { FunctionCall, FunctionCallResult, SendMessageResponse } from "@himalaya/wix-genie-chat";
41
+
42
+ /** A service that keeps asking is a loop, not a conversation. Ten is the cap
43
+ * both native clients use: ten results sent, and an eleventh call fails the
44
+ * turn rather than being answered. */
45
+ export const CLIENT_TOOL_MAX_ROUNDS = 10;
46
+
47
+ export const CONSENT_TOOL = "consentWithMessage";
48
+ export const KNOWLEDGE_TOOL = "getClientSideKnowledge";
49
+
50
+ /** A function call with its arguments decoded. */
51
+ export interface ClientToolCall {
52
+ id?: string;
53
+ name?: string;
54
+ /** Which tool this is: `toolSequence.currentStep` when the call is a step of a
55
+ * sequence, otherwise its name. */
56
+ toolId: string;
57
+ /** Every argument EXCEPT `toolSequence`, which is routing, not payload. */
58
+ params: Record<string, unknown>;
59
+ }
60
+
61
+ /** What the host must provide to run the built-ins. Omit it and every call is
62
+ * answered "not found" — honest, and it unblocks the agent. */
63
+ export interface AriaClientTools {
64
+ /** Ask the owner whether the agent may go ahead, and resolve with the answer.
65
+ *
66
+ * Resolve `true` ONLY for an explicit approval. A dismissal is a refusal. A
67
+ * rejection (nothing to present with, a presenter already busy) is reported to
68
+ * the agent as a failure — it is never read as consent.
69
+ *
70
+ * `message` is the agent's question, already reduced to plain text. It can be
71
+ * empty; the host supplies its own fallback. */
72
+ confirm: (ask: { message: string }) => Promise<boolean>;
73
+ /** Reported by `getClientSideKnowledge`. */
74
+ userAgent?: () => string;
75
+ }
76
+
77
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
78
+ return !!v && typeof v === "object" && !Array.isArray(v);
79
+ }
80
+
81
+ /** Decode a call's `arguments`, or `null` when they are not a JSON object.
82
+ *
83
+ * `arguments` is a JSON-ENCODED string on the wire. An object is accepted too,
84
+ * in case a gateway ever decodes it on the way through; anything else — an
85
+ * array, a number, malformed text — is not a call this client can run, and the
86
+ * caller answers it as a failure rather than guessing. */
87
+ export function parseClientToolCall(call: FunctionCall): ClientToolCall | null {
88
+ // The service's array is not ours to trust element by element: a `null` in it
89
+ // must be answered, not thrown on.
90
+ if (!call || typeof call !== "object" || Array.isArray(call)) return null;
91
+ const raw = (call as { arguments?: unknown }).arguments;
92
+ let args: unknown;
93
+ if (raw === undefined || raw === null || raw === "") args = {};
94
+ else if (typeof raw === "string") {
95
+ try { args = JSON.parse(raw); } catch { return null; }
96
+ } else args = raw;
97
+ if (!isPlainObject(args)) return null;
98
+ const { toolSequence, ...params } = args;
99
+ let sequence: unknown = toolSequence;
100
+ if (typeof sequence === "string") {
101
+ try { sequence = JSON.parse(sequence); } catch { sequence = undefined; }
102
+ }
103
+ const step = isPlainObject(sequence) && typeof sequence.currentStep === "string" ? sequence.currentStep : "";
104
+ return { id: call.id, name: call.name, toolId: step || call.name || "", params };
105
+ }
106
+
107
+ /** `{id, name}` copied from the call, omitting what the call did not carry. */
108
+ function addressOf(call: { id?: string; name?: string }): FunctionCallResult {
109
+ return {
110
+ ...(call.id === undefined ? {} : { id: call.id }),
111
+ ...(call.name === undefined ? {} : { name: call.name }),
112
+ };
113
+ }
114
+
115
+ /** The literal text the service already reads from the other clients. */
116
+ export function notFoundResult(call: ClientToolCall): FunctionCallResult {
117
+ return { ...addressOf(call), success: false, content: `tool '${call.name || call.toolId}' not found and cannot be executed` };
118
+ }
119
+
120
+ export function failedResult(call: { id?: string; name?: string }, message: string): FunctionCallResult {
121
+ return { ...addressOf(call), success: false, content: `tool '${call.name ?? ""}' execution failed: ${message}` };
122
+ }
123
+
124
+ /** The owner's answer. A refusal is still a SUCCESSFUL tool run — the tool's job
125
+ * 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 }) };
128
+ }
129
+
130
+ export function knowledgeResult(call: ClientToolCall, userAgent: string): FunctionCallResult {
131
+ return { ...addressOf(call), success: true, content: "", widgetResponse: JSON.stringify({ ...call.params, userAgent }) };
132
+ }
133
+
134
+ /** Answer ONE call. Never rejects: every outcome, including a host that threw,
135
+ * becomes a result the agent can read, because an unanswered call is a turn
136
+ * that never ends. */
137
+ export async function answerClientTool(call: FunctionCall, host: AriaClientTools | undefined): Promise<FunctionCallResult> {
138
+ const parsed = parseClientToolCall(call);
139
+ if (!parsed) return failedResult(call && typeof call === "object" ? call : {}, "arguments are not a JSON object");
140
+ if (!host) return notFoundResult(parsed);
141
+ if (parsed.toolId === CONSENT_TOOL) {
142
+ const message = typeof parsed.params.message === "string" ? parsed.params.message : "";
143
+ let approved: boolean;
144
+ try {
145
+ // Strictly `true`. A host that resolves with something truthy but not
146
+ // `true` — a `{confirmed:false}` object passed straight through, the string
147
+ // "false" — has not reported an approval, and reading it as one would be
148
+ // the single worst bug this file could have.
149
+ approved = (await host.confirm({ message })) === true;
150
+ } catch (e) {
151
+ return failedResult(parsed, `the owner could not be asked (${String((e as Error)?.message ?? e)})`);
152
+ }
153
+ return consentResult(parsed, approved);
154
+ }
155
+ if (parsed.toolId === KNOWLEDGE_TOOL) {
156
+ let userAgent = "";
157
+ try { userAgent = host.userAgent?.() ?? ""; } catch { /* report none rather than leave the call unanswered */ }
158
+ return knowledgeResult(parsed, userAgent);
159
+ }
160
+ return notFoundResult(parsed);
161
+ }
162
+
163
+ /** Tagged, not subclassed: worker bundles are built for a target without `class`. */
164
+ export interface ClientToolError extends Error { kind: "client-tools" }
165
+
166
+ function clientToolError(message: string): ClientToolError {
167
+ const e = new Error(message) as ClientToolError;
168
+ e.name = "ClientToolError";
169
+ e.kind = "client-tools";
170
+ return e;
171
+ }
172
+
173
+ export type ClientToolOutcome =
174
+ /** The response asked for nothing. */
175
+ | "none"
176
+ /** Every call was answered, and the last response asked for nothing more. */
177
+ | "answered"
178
+ /** The turn moved on while a call was being answered, so its result was never
179
+ * sent. An approval given for a turn the owner has since left must not reach
180
+ * the agent, which would act on it in a conversation nobody is watching. */
181
+ | "abandoned";
182
+
183
+ export interface ClientToolExchange {
184
+ host?: AriaClientTools;
185
+ /** The calls a response is waiting on — `functionCallsOf` from the chat client. */
186
+ callsOf: (res: SendMessageResponse) => FunctionCall[];
187
+ /** Send one round of results; resolves with the service's next response. */
188
+ send: (results: FunctionCallResult[]) => Promise<SendMessageResponse>;
189
+ /** Is the turn these calls belong to still the current one? Asked after each
190
+ * answer and before its result is sent. */
191
+ stillCurrent: () => boolean;
192
+ maxRounds?: number;
193
+ /** Handed the response that ended the exchange (the one asking for nothing
194
+ * more). Its assistant row is the reply the owner is now owed. */
195
+ onFinal?: (res: SendMessageResponse) => void;
196
+ /** Run after a call is answered and before its result is sent — the last
197
+ * moment at which everything in the transcript provably predates the result.
198
+ * Must not reject. The turn is asked about again once it resolves, so a turn
199
+ * that moved on while it ran is abandoned rather than answered. */
200
+ beforeSend?: () => Promise<void>;
201
+ }
202
+
203
+ /** Drive the whole exchange that `first` opens, one call at a time.
204
+ *
205
+ * Rejects — failing the turn — on the two responses the other clients also
206
+ * refuse: more than one call at once (the contract is one, and there is no
207
+ * telling in which order an agent meant several to be asked), and a loop that
208
+ * exceeds `maxRounds`. A failed send rejects too. */
209
+ export async function runClientToolExchange(first: SendMessageResponse, x: ClientToolExchange): Promise<ClientToolOutcome> {
210
+ const max = x.maxRounds ?? CLIENT_TOOL_MAX_ROUNDS;
211
+ let current = first;
212
+ let rounds = 0;
213
+ for (;;) {
214
+ const calls = x.callsOf(current);
215
+ if (!calls.length) {
216
+ if (rounds) x.onFinal?.(current);
217
+ return rounds ? "answered" : "none";
218
+ }
219
+ // Asked BEFORE presenting anything, as well as after: between two rounds the
220
+ // modal is down and Stop is reachable, and a second question must not be put
221
+ // to the owner for a turn they have already stopped or left.
222
+ if (!x.stillCurrent()) return "abandoned";
223
+ if (calls.length > 1) throw clientToolError(`the assistant asked for ${calls.length} client tools at once`);
224
+ 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);
226
+ if (!x.stillCurrent()) return "abandoned";
227
+ if (x.beforeSend) {
228
+ await x.beforeSend();
229
+ if (!x.stillCurrent()) return "abandoned";
230
+ }
231
+ current = await x.send([result]);
232
+ rounds += 1;
233
+ }
234
+ }
@@ -20,10 +20,14 @@
20
20
  // targets are baked into the descriptor from the app contract, so a vertical
21
21
  // this site has not installed can never be navigated to.
22
22
  // 4. Copy as configuration: every user-facing string goes through copyParam.
23
+ // 5. Consent. When the agent asks before acting on the owner's account, the
24
+ // question is put to the owner in a real modal (`@himalaya/ui` alert) and
25
+ // their tap is the answer — see `confirmWithOwner`.
23
26
 
24
27
  import { defineService } from "@himalaya/state";
25
28
  import { open } from "@himalaya/open-url";
26
29
  import { writeText } from "@himalaya/clipboard";
30
+ import { alert } from "@himalaya/ui";
27
31
  import {
28
32
  activeSiteId,
29
33
  createAriaChat,
@@ -132,6 +136,40 @@ export function screenForUrl(url: string, allowed: Set<string>): string {
132
136
  return best;
133
137
  }
134
138
 
139
+ /** Put the agent's consent question to the owner, and return their answer.
140
+ *
141
+ * A real MODAL, not a card in the chat: it has to cover the screen, because a
142
+ * card leaves the composer, Stop and ➕ live beside a question the agent is
143
+ * blocked on, and an answer tapped on a card that has scrolled away is how an
144
+ * owner approves something they never read. `@himalaya/ui` presents the same
145
+ * token-themed alert the platform uses everywhere, on iOS, Android and web.
146
+ *
147
+ * The body is the agent's words (already plain text, and capped, by the core).
148
+ * The title and both buttons are OURS: the agent may suggest button labels and
149
+ * they are ignored, because a model that cannot tap Confirm can still try to
150
+ * get it labelled "Cancel".
151
+ *
152
+ * `confirmed` is only true for a tap on Confirm. A dismissal answers false — a
153
+ * refusal. A host with no presenter REJECTS (`no_ui_host`), which the core
154
+ * reports to the agent as "could not ask" — never as a yes. */
155
+ async function confirmWithOwner(ask: { message: string }): Promise<boolean> {
156
+ const { confirmed } = await alert({
157
+ title: copyParam("ownerAria.consent.title", "Confirmation required"),
158
+ message: ask.message || copyParam("ownerAria.consent.body", "Approve to continue"),
159
+ confirmLabel: copyParam("ownerAria.consent.approve", "Confirm"),
160
+ cancelLabel: copyParam("ownerAria.consent.deny", "Deny"),
161
+ });
162
+ return confirmed === true;
163
+ }
164
+
165
+ /** What `getClientSideKnowledge` reports. The browser's own string where there
166
+ * is one (the web host); the native worker runtimes have no `navigator`, and
167
+ * the app's name is then the honest answer rather than an invented UA. */
168
+ function userAgent(): string {
169
+ const nav = (globalThis as { navigator?: { userAgent?: unknown } }).navigator;
170
+ return typeof nav?.userAgent === "string" && nav.userAgent ? nav.userAgent : "himi-owner-lite";
171
+ }
172
+
135
173
  const core = createAriaChat({
136
174
  // Mount the generic card for an unrecognised widget kind. This screen is
137
175
  // listed in `workerSubtreeScreens`; a screen that is not would have the node
@@ -161,7 +199,9 @@ const core = createAriaChat({
161
199
  // English. Says no "sign in": the owner already is, and the missing piece is
162
200
  // the app's binding, so there is no action for them to take.
163
201
  unavailable: () => copyParam("ownerAria.unavailable", "Aria can't reach your Wix account from here."),
202
+ toolFailed: () => copyParam("ownerAria.toolFailed", "Aria couldn't finish that. Try again."),
164
203
  },
204
+ clientTools: { confirm: confirmWithOwner, userAgent },
165
205
  historyKey: "ol.aria.history.v1",
166
206
  // No local title cache in THIS app. __HIMI_APP_NAME__ is multi-account and
167
207
  // multi-site: `himalaya.storage` is app-wide, survives the account-scoped
@@ -1263,7 +1263,7 @@ const SCREENS = runtimeSetting({
1263
1263
  "package": "himalaya",
1264
1264
  "type": "Button",
1265
1265
  "props": {
1266
- "title": "",
1266
+ "title": "Send",
1267
1267
  "iconOnly": true,
1268
1268
  "leadingIcon": "arrow_up",
1269
1269
  "style": "primary",
@@ -1285,7 +1285,7 @@ const SCREENS = runtimeSetting({
1285
1285
  "package": "himalaya",
1286
1286
  "type": "Button",
1287
1287
  "props": {
1288
- "title": "",
1288
+ "title": "Stop",
1289
1289
  "iconOnly": true,
1290
1290
  "leadingIcon": "stop",
1291
1291
  "style": "primary",