@fiodos/web-core 0.1.18 → 0.1.20

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.
@@ -61,7 +61,13 @@ async function errorFromResponse(res) {
61
61
  return new errors_1.AgentApiError('unauthorized', detail, res.status);
62
62
  }
63
63
  if (res.status === 429) {
64
- const quotaSignal = typeof body.remaining === 'number' || (detail ?? '').toLowerCase().includes('quota');
64
+ // The backend marks plan/budget 429s with error:"quota_exceeded" (rate
65
+ // limits carry error:"rate_limited"); the detail/remaining checks cover
66
+ // older backends. Getting this right matters: a quota 429 shown as "too
67
+ // many requests" sends the developer chasing rate limits that are fine.
68
+ const quotaSignal = body.error === 'quota_exceeded' ||
69
+ typeof body.remaining === 'number' ||
70
+ (detail ?? '').toLowerCase().includes('quota');
65
71
  return new errors_1.AgentApiError(quotaSignal ? 'quota_exceeded' : 'rate_limited', detail, res.status);
66
72
  }
67
73
  if (res.status === 404)
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createFiodosTelemetry = createFiodosTelemetry;
4
+ /** Bound on the technical error string shipped with action_failed events. */
5
+ const MAX_ERROR_DETAIL_CHARS = 200;
4
6
  function mapEvent(params) {
5
7
  const intent = params.intentDetected ?? null;
6
8
  const sessionId = params.sessionId ?? null;
@@ -16,6 +18,19 @@ function mapEvent(params) {
16
18
  }
17
19
  return events;
18
20
  }
21
+ // Failures are the events that make field reports diagnosable: without
22
+ // them, "the orb said it couldn't do it" leaves the developer blind. Only
23
+ // the technical error string travels (HTTP status + path, template
24
+ // param…), never any message content.
25
+ case 'action_failed':
26
+ return [
27
+ {
28
+ type: 'action_failed',
29
+ intent,
30
+ session_id: sessionId,
31
+ error: (params.errorDetail ?? '').slice(0, MAX_ERROR_DETAIL_CHARS) || null,
32
+ },
33
+ ];
19
34
  case 'action_confirmation_requested':
20
35
  return [{ type: 'confirmation_requested', intent, session_id: sessionId }];
21
36
  default:
@@ -18,14 +18,20 @@ export interface AgentTimings {
18
18
  }
19
19
  export declare const DEFAULT_AGENT_TIMINGS: AgentTimings;
20
20
  /**
21
- * Autonomous multi-step chaining. OFF by default: with `enabled: false` the orb
22
- * behaves exactly as before (one action per turn). When ON, after a successful
21
+ * Autonomous multi-step chaining. ON by default: after a successful
23
22
  * navigate/execute whose backend `task_status` is "in_progress", the orb takes
24
23
  * the next step on its own — capped at `maxSteps` and guarded by no-progress
25
24
  * detection and sensitive-confirmation pauses (which always require the human).
25
+ *
26
+ * Default ON is a product invariant, not a tuning choice: data actions
27
+ * (search, get-cart…) are INVISIBLE — without the continuation turn the orb
28
+ * runs them and then goes silent ("I'll look for products…" and nothing
29
+ * happens). Every install (embeds included, which pass no config) must get
30
+ * the full act→observe→act loop. Pass `chaining: { enabled: false }` to opt
31
+ * out and return to one-action-per-turn.
26
32
  */
27
33
  export interface AgentChaining {
28
- /** Master switch. Default false (retrocompatible, one action per turn). */
34
+ /** Master switch. Default true (the agent completes multi-step tasks). */
29
35
  enabled: boolean;
30
36
  /** Hard cap on steps per user request (safety). Default 6. */
31
37
  maxSteps: number;
@@ -8,7 +8,7 @@ exports.DEFAULT_AGENT_TIMINGS = {
8
8
  minTranscriptChars: 2,
9
9
  };
10
10
  exports.DEFAULT_AGENT_CHAINING = {
11
- enabled: false,
11
+ enabled: true,
12
12
  maxSteps: 6,
13
13
  dwellMs: 450,
14
14
  };
@@ -104,6 +104,12 @@ export declare class AgentController {
104
104
  private pendingConsentIntent;
105
105
  /** Parked chain state (set when a chain pauses on a sensitive action). */
106
106
  private chainState;
107
+ /**
108
+ * Intent whose showcase escort was DEFERRED mid-chain (suppressEscort):
109
+ * flushed when the chain ends so the user is walked to where the last
110
+ * action's effect is visible — never in the middle of a running chain.
111
+ */
112
+ private deferredEscortIntent;
107
113
  /** REAL live activity for the panel (replaces the old canned walk-through). */
108
114
  private liveActivity;
109
115
  private chainActive;
@@ -221,6 +227,13 @@ export declare class AgentController {
221
227
  * maxSteps, with no-progress detection and sensitive-confirmation pauses.
222
228
  */
223
229
  private runChainLoop;
230
+ /**
231
+ * Runs the showcase escort that was deferred while the chain was executing
232
+ * (see suppressEscort in applyOutcome). Skipped while a confirmation is
233
+ * pending: the chain may resume after the user answers, and the confirmed
234
+ * action escorts on its own.
235
+ */
236
+ private flushDeferredEscort;
224
237
  private handleTranscript;
225
238
  private onTranscript;
226
239
  private onSpeechError;
@@ -63,6 +63,12 @@ class AgentController {
63
63
  this.pendingConsentIntent = null;
64
64
  /** Parked chain state (set when a chain pauses on a sensitive action). */
65
65
  this.chainState = null;
66
+ /**
67
+ * Intent whose showcase escort was DEFERRED mid-chain (suppressEscort):
68
+ * flushed when the chain ends so the user is walked to where the last
69
+ * action's effect is visible — never in the middle of a running chain.
70
+ */
71
+ this.deferredEscortIntent = null;
66
72
  /** REAL live activity for the panel (replaces the old canned walk-through). */
67
73
  this.liveActivity = null;
68
74
  this.chainActive = false;
@@ -488,7 +494,11 @@ class AgentController {
488
494
  intentDetected: pending.intent,
489
495
  sessionId: this.sessionId,
490
496
  actionExecuted: { intent: pending.intent, confirmed: true },
497
+ ...(!result.success ? { errorDetail: result.error ?? null } : {}),
491
498
  });
499
+ if (!result.success) {
500
+ console.warn(`[fyodos] action "${pending.intent}" failed: ${result.error ?? 'unknown error'}`);
501
+ }
492
502
  if (result.message) {
493
503
  if (this.lastExchange)
494
504
  this.lastExchange = { ...this.lastExchange, replyText: result.message };
@@ -542,6 +552,7 @@ class AgentController {
542
552
  // Declining a sensitive action stops any chain it was part of, cleanly.
543
553
  const chainState = this.chainState;
544
554
  this.chainState = null;
555
+ this.deferredEscortIntent = null;
545
556
  if (chainState) {
546
557
  this.telemetry.logEvent({
547
558
  eventType: 'agent_chain_aborted',
@@ -661,6 +672,10 @@ class AgentController {
661
672
  let outAudio = turn.audioBase64;
662
673
  if (outReply)
663
674
  this.serverTtsUnavailable = !outAudio;
675
+ // Mid-chain: hold the showcase escort. On multi-page hosts (Shopify
676
+ // embeds) the escort is a full page load that would kill the chain
677
+ // before its continuation turn; the chain escorts once when it ends.
678
+ const escortDeferred = this.chaining.enabled && turn.taskStatus === 'in_progress';
664
679
  const decision = await (0, turnEngine_1.decideAction)({
665
680
  action: turn.action,
666
681
  manifest: this.config.manifest,
@@ -668,6 +683,7 @@ class AgentController {
668
683
  context: snapshotContext,
669
684
  messages: this.messages,
670
685
  userMessage,
686
+ suppressEscort: escortDeferred,
671
687
  });
672
688
  let canContinue = false;
673
689
  let lastStep;
@@ -710,6 +726,7 @@ class AgentController {
710
726
  intentDetected: turn.action.intent,
711
727
  sessionId: this.sessionId,
712
728
  actionExecuted: { intent: turn.action.intent, confirmed: false },
729
+ ...(!r.success && !r.recoverable ? { errorDetail: r.error ?? null } : {}),
713
730
  });
714
731
  }
715
732
  if (r.recoverable) {
@@ -732,12 +749,27 @@ class AgentController {
732
749
  outReply = this.messages.actionFailed;
733
750
  outAudio = null;
734
751
  }
752
+ if (!r.success) {
753
+ // Developer signal: the user only hears a generic failure phrase; the
754
+ // TECHNICAL cause (HTTP status + path, missing template param…) must
755
+ // be findable in the console or every field report is undebuggable.
756
+ console.warn(`[fyodos] action "${turn.action?.intent ?? '?'}" failed: ${r.error ?? 'unknown error'}`);
757
+ }
735
758
  // Chain signal: only a cleanly EXECUTED, non-recoverable step may advance,
736
759
  // and only when the backend asked to continue (task_status=in_progress).
737
760
  if (turn.action &&
738
761
  (turn.action.type === 'navigate' || turn.action.type === 'execute') &&
739
762
  r.success &&
740
763
  !r.recoverable) {
764
+ // Deferred-escort bookkeeping: remember the intent whose escort was
765
+ // held (flushed at chain end); a navigate — or an execute whose escort
766
+ // already ran inside the executor — supersedes any earlier deferral.
767
+ if (turn.action.type === 'execute') {
768
+ this.deferredEscortIntent = escortDeferred ? (turn.action.intent ?? null) : null;
769
+ }
770
+ else {
771
+ this.deferredEscortIntent = null;
772
+ }
741
773
  const intent = turn.action.intent ?? '';
742
774
  // Feed the step's RESULT DATA back to the next chain turn (bounded):
743
775
  // it is how the model reads real technical values (variant ids, line
@@ -941,8 +973,23 @@ class AgentController {
941
973
  }
942
974
  finally {
943
975
  this.setChainActive(false);
976
+ this.flushDeferredEscort();
944
977
  }
945
978
  }
979
+ /**
980
+ * Runs the showcase escort that was deferred while the chain was executing
981
+ * (see suppressEscort in applyOutcome). Skipped while a confirmation is
982
+ * pending: the chain may resume after the user answers, and the confirmed
983
+ * action escorts on its own.
984
+ */
985
+ flushDeferredEscort() {
986
+ if (this.pending != null)
987
+ return;
988
+ const intent = this.deferredEscortIntent;
989
+ this.deferredEscortIntent = null;
990
+ if (intent)
991
+ this.executor.escortForIntent(intent);
992
+ }
946
993
  // ── Main turn ────────────────────────────────────────────────────────────────
947
994
  async handleTranscript(rawText) {
948
995
  const text = rawText.trim();
@@ -1226,6 +1273,7 @@ class AgentController {
1226
1273
  this.config.voice.stopPlayback();
1227
1274
  this.pending = null;
1228
1275
  this.chainState = null;
1276
+ this.deferredEscortIntent = null;
1229
1277
  this.confirmationMessage = null;
1230
1278
  this.confirmationVoiceHint = null;
1231
1279
  this.liveActivity = null;
@@ -52,6 +52,12 @@ export interface DecideActionParams {
52
52
  context: AgentScreenContext;
53
53
  messages: AgentMessages;
54
54
  userMessage: string;
55
+ /**
56
+ * Skip the post-action showcase escort (mid-chain steps): on multi-page
57
+ * hosts the escort is a full page load that would kill the running chain.
58
+ * The orchestrator escorts once at the END of the chain.
59
+ */
60
+ suppressEscort?: boolean;
55
61
  }
56
62
  /**
57
63
  * Decides what to do with a backend-proposed action. Sensitive actions are
@@ -31,7 +31,7 @@ function findManifestAction(manifest, intent) {
31
31
  * never executed here — they return `needs-confirmation`.
32
32
  */
33
33
  async function decideAction(params) {
34
- const { action, manifest, executor, context, messages, userMessage } = params;
34
+ const { action, manifest, executor, context, messages, userMessage, suppressEscort } = params;
35
35
  if (!action || action.type === 'none') {
36
36
  return { kind: 'noop' };
37
37
  }
@@ -43,7 +43,7 @@ async function decideAction(params) {
43
43
  const manifestAction = findManifestAction(manifest, action.intent);
44
44
  const needsConfirmation = manifestAction?.requireConfirmation === true;
45
45
  if (!needsConfirmation) {
46
- const result = await executor.execute(action, context);
46
+ const result = await executor.execute(action, context, { suppressEscort });
47
47
  return { kind: 'executed', result };
48
48
  }
49
49
  // Precheck (context + idempotency) BEFORE asking: applying an already-applied
@@ -27,7 +27,7 @@ import { createScreenContextStore } from '../context/screenContextStore';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics';
28
28
  import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
29
29
  declare const api: {
30
- readonly version: "0.1.18";
30
+ readonly version: "0.1.20";
31
31
  readonly createFiodosAgent: typeof createFiodosAgent;
32
32
  readonly mountOrb: typeof mountOrb;
33
33
  readonly AgentController: typeof AgentController;
@@ -57,6 +57,8 @@ const CSS = `
57
57
  .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:22px;}
58
58
  .fyodos-bubble-user{flex:1;min-width:0;font-size:13px;line-height:18px;opacity:.75;}
59
59
  .fyodos-bubble-reply{font-size:15px;line-height:21px;white-space:pre-wrap;}
60
+ .fyodos-bubble-greeting{font-size:15px;line-height:21px;white-space:pre-wrap;animation:fyodos-fade-in .18s ease-out;}
61
+ .fyodos-bubble--expanded .fyodos-bubble-greeting{font-size:17px;line-height:25px;}
60
62
  .fyodos-bubble-busy{display:flex;gap:5px;padding:8px 14px;align-items:center;}
61
63
  .fyodos-bubble-busy span{width:6px;height:6px;border-radius:50%;opacity:.5;animation:fyodos-blink 1s infinite;}
62
64
  .fyodos-bubble-busy span:nth-child(2){animation-delay:.18s;}
@@ -210,13 +212,9 @@ function mountOrb(controller, options = {}) {
210
212
  haptics.trigger('orbTap');
211
213
  controller.primeAudio();
212
214
  if (enableTextInput) {
213
- const prompt = resolveCurrentBubblePrompt();
214
- openTextPanel(prompt
215
- ? (0, core_1.bubblePromptToAffirmative)(prompt, {
216
- locale: currentBubbleLocale(),
217
- messages: controller.messages,
218
- })
219
- : undefined);
215
+ // The bubble's question moves INTO the panel as an assistant greeting
216
+ // (openTextPanel shows it) — never pre-typed into the user's input.
217
+ openTextPanel();
220
218
  return;
221
219
  }
222
220
  userTriedVoice = true;
@@ -458,7 +456,30 @@ function mountOrb(controller, options = {}) {
458
456
  loaderRow.style.display = 'none';
459
457
  const exchangeList = document.createElement('div');
460
458
  exchangeList.className = 'fyodos-bubble-exchanges';
461
- exchangeScroll.append(loaderRow, exchangeList);
459
+ // Screen-aware GREETING: the thought bubble's question, shown INSIDE the
460
+ // panel as an assistant message when it opens (tapping the bubble or opening
461
+ // the chat traditionally). Ephemeral by design: it never enters the
462
+ // conversation history and disappears the moment the user sends a message.
463
+ const greetingEl = document.createElement('div');
464
+ greetingEl.className = 'fyodos-bubble-greeting';
465
+ greetingEl.style.display = 'none';
466
+ exchangeScroll.append(loaderRow, exchangeList, greetingEl);
467
+ function showPanelGreeting() {
468
+ const prompt = resolveCurrentBubblePrompt();
469
+ if (!prompt) {
470
+ greetingEl.style.display = 'none';
471
+ return;
472
+ }
473
+ const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
474
+ greetingEl.textContent = prompt;
475
+ greetingEl.style.color = t.titleColor;
476
+ greetingEl.style.display = '';
477
+ exchangeScroll.scrollTop = exchangeScroll.scrollHeight;
478
+ }
479
+ function hidePanelGreeting() {
480
+ if (greetingEl.style.display !== 'none')
481
+ greetingEl.style.display = 'none';
482
+ }
462
483
  // Signature of the rendered list so state emits that do not change the
463
484
  // transcript (volume, phase…) never re-render it nor fight the user's scroll.
464
485
  let renderedSignature = '';
@@ -752,6 +773,8 @@ function mountOrb(controller, options = {}) {
752
773
  else {
753
774
  bubbleDictation.stop();
754
775
  syncMicButton();
776
+ // Ephemeral greeting never survives a close (recomputed on next open).
777
+ hidePanelGreeting();
755
778
  // Collapse the paged history back to the initial page for the next open.
756
779
  controller.resetBubbleWindow();
757
780
  // Closing the panel returns the orb to idle (also clears mic fallback).
@@ -781,6 +804,9 @@ function mountOrb(controller, options = {}) {
781
804
  syncMicButton();
782
805
  haptics.trigger('send');
783
806
  textInput.value = '';
807
+ // The greeting is a welcome, not part of the conversation: the user's
808
+ // first real message replaces it.
809
+ hidePanelGreeting();
784
810
  // Silent turn: the reply is SHOWN in the bubble (not spoken), and the bubble
785
811
  // STAYS OPEN. This is the unified, reference-matching behaviour.
786
812
  void controller.submitTextTurn(value);
@@ -793,6 +819,11 @@ function mountOrb(controller, options = {}) {
793
819
  const draft = explicitSeed || cancelled || '';
794
820
  textInput.value = draft;
795
821
  setBubble(true);
822
+ // Welcome the user with this screen's bubble question as an assistant
823
+ // message (both open paths: bubble tap AND traditional open). It vanishes
824
+ // with the user's first message — never enters the history.
825
+ if (!draft)
826
+ showPanelGreeting();
796
827
  // Only an explicit seed may auto-send; a restored cancelled message must
797
828
  // wait for the user to edit/resend it.
798
829
  if (autoSend && explicitSeed) {
@@ -1156,6 +1187,9 @@ function mountOrb(controller, options = {}) {
1156
1187
  syncActivityStep(rowBusy, state);
1157
1188
  // Text bubble: show recent exchanges + busy state.
1158
1189
  if (bubbleOpen) {
1190
+ // A turn in flight (typed OR voice) replaces the ephemeral greeting.
1191
+ if (state.isBusy)
1192
+ hidePanelGreeting();
1159
1193
  loaderRow.style.display = state.loadingOlderExchanges ? 'flex' : 'none';
1160
1194
  renderBubbleExchanges(state.recentExchanges);
1161
1195
  exchangeScroll.style.display = 'flex';
@@ -29,12 +29,29 @@ function createBubbleDictation(locale) {
29
29
  let listening = false;
30
30
  let baseText = '';
31
31
  const stop = () => {
32
- recognition?.stop();
32
+ const rec = recognition;
33
+ if (!rec)
34
+ return;
35
+ // DISCARD-ON-STOP: recognition engines fire one last consolidated
36
+ // `onresult` AFTER stop() is requested. If the user hits send while still
37
+ // dictating, that late result would rewrite the input we just cleared and
38
+ // the message would reappear (duplicate-send bug). Once a stop is
39
+ // requested, nothing may ever write to the input again.
40
+ rec.onresult = null;
41
+ recognition = null;
42
+ listening = false;
43
+ try {
44
+ if (typeof rec.abort === 'function')
45
+ rec.abort();
46
+ else
47
+ rec.stop();
48
+ }
49
+ catch {
50
+ /* already stopped */
51
+ }
33
52
  };
34
53
  const dispose = () => {
35
54
  stop();
36
- recognition = null;
37
- listening = false;
38
55
  };
39
56
  return {
40
57
  isSupported: () => Ctor != null,
@@ -72,11 +89,18 @@ function createBubbleDictation(locale) {
72
89
  : dictated;
73
90
  setValue(combined);
74
91
  };
92
+ // Identity-guarded teardown: a session stopped via stop() may fire its
93
+ // onend AFTER the user already started a NEW dictation — it must never
94
+ // tear down the new session.
75
95
  rec.onend = () => {
96
+ if (recognition !== rec)
97
+ return;
76
98
  listening = false;
77
99
  recognition = null;
78
100
  };
79
101
  rec.onerror = () => {
102
+ if (recognition !== rec)
103
+ return;
80
104
  listening = false;
81
105
  recognition = null;
82
106
  };
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.18";
8
+ export declare const SDK_VERSION = "0.1.20";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -8,5 +8,5 @@ exports.SDK_PACKAGE = exports.SDK_VERSION = void 0;
8
8
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
9
9
  * by hand — change package.json `version` and re-run the sync/release script.
10
10
  */
11
- exports.SDK_VERSION = '0.1.18';
11
+ exports.SDK_VERSION = '0.1.20';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';