@fiodos/web-core 0.1.17 → 0.1.19

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 (36) hide show
  1. package/dist/cjs/api/anonUserId.d.ts +19 -0
  2. package/dist/cjs/api/anonUserId.js +63 -0
  3. package/dist/cjs/api/backendClient.js +7 -1
  4. package/dist/cjs/api/clientBootstrap.d.ts +1 -0
  5. package/dist/cjs/api/clientBootstrap.js +15 -4
  6. package/dist/cjs/config/types.d.ts +9 -3
  7. package/dist/cjs/config/types.js +1 -1
  8. package/dist/cjs/controller/AgentController.d.ts +13 -0
  9. package/dist/cjs/controller/AgentController.js +37 -0
  10. package/dist/cjs/core/turnEngine.d.ts +6 -0
  11. package/dist/cjs/core/turnEngine.js +2 -2
  12. package/dist/cjs/dropin/createFiodosAgent.js +6 -2
  13. package/dist/cjs/embed/global.d.ts +1 -1
  14. package/dist/cjs/embed/global.js +4 -0
  15. package/dist/cjs/speech/bubbleDictation.js +27 -3
  16. package/dist/cjs/version.d.ts +1 -1
  17. package/dist/cjs/version.js +1 -1
  18. package/dist/embed/fiodos-embed.js +4 -4
  19. package/dist/esm/api/anonUserId.d.ts +19 -0
  20. package/dist/esm/api/anonUserId.js +59 -0
  21. package/dist/esm/api/backendClient.js +7 -1
  22. package/dist/esm/api/clientBootstrap.d.ts +1 -0
  23. package/dist/esm/api/clientBootstrap.js +14 -4
  24. package/dist/esm/config/types.d.ts +9 -3
  25. package/dist/esm/config/types.js +1 -1
  26. package/dist/esm/controller/AgentController.d.ts +13 -0
  27. package/dist/esm/controller/AgentController.js +37 -0
  28. package/dist/esm/core/turnEngine.d.ts +6 -0
  29. package/dist/esm/core/turnEngine.js +2 -2
  30. package/dist/esm/dropin/createFiodosAgent.js +6 -2
  31. package/dist/esm/embed/global.d.ts +1 -1
  32. package/dist/esm/embed/global.js +4 -0
  33. package/dist/esm/speech/bubbleDictation.js +27 -3
  34. package/dist/esm/version.d.ts +1 -1
  35. package/dist/esm/version.js +1 -1
  36. package/package.json +2 -2
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stable per-browser ANONYMOUS caller id, sent as `x-user-id` when the host
3
+ * app provides no getUserId (public storefronts, marketing sites…).
4
+ *
5
+ * Why: the backend's fairness rate-limit buckets anonymous callers by source
6
+ * IP. Behind shopper NATs / proxies that means MANY real visitors share one
7
+ * bucket and an active session can starve itself into 429 ("Too many
8
+ * requests"). A random per-browser id gives each visitor their own fairness
9
+ * bucket without identifying anyone: it is random, local, never derived from
10
+ * personal data, and never used for conversation identity (that stays 'anon').
11
+ */
12
+ /** Returns the stable anonymous caller id for this browser (creates it once). */
13
+ export declare function getAnonCallerId(): string;
14
+ /**
15
+ * Wraps the host's getUserId so anonymous visitors still send a stable
16
+ * per-browser caller id for rate-limit fairness. The host's real user id
17
+ * always wins when present.
18
+ */
19
+ export declare function withAnonCallerFallback(getUserId?: () => string | null): () => string | null;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ /**
3
+ * Stable per-browser ANONYMOUS caller id, sent as `x-user-id` when the host
4
+ * app provides no getUserId (public storefronts, marketing sites…).
5
+ *
6
+ * Why: the backend's fairness rate-limit buckets anonymous callers by source
7
+ * IP. Behind shopper NATs / proxies that means MANY real visitors share one
8
+ * bucket and an active session can starve itself into 429 ("Too many
9
+ * requests"). A random per-browser id gives each visitor their own fairness
10
+ * bucket without identifying anyone: it is random, local, never derived from
11
+ * personal data, and never used for conversation identity (that stays 'anon').
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.getAnonCallerId = getAnonCallerId;
15
+ exports.withAnonCallerFallback = withAnonCallerFallback;
16
+ const STORAGE_KEY = 'fyodos:anon-caller:v1';
17
+ let inMemoryId = null;
18
+ function randomId() {
19
+ try {
20
+ const c = globalThis.crypto;
21
+ if (c?.randomUUID)
22
+ return `anon-${c.randomUUID()}`;
23
+ }
24
+ catch {
25
+ /* fall through */
26
+ }
27
+ return `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
28
+ }
29
+ /** Returns the stable anonymous caller id for this browser (creates it once). */
30
+ function getAnonCallerId() {
31
+ if (inMemoryId)
32
+ return inMemoryId;
33
+ try {
34
+ const stored = window.localStorage.getItem(STORAGE_KEY);
35
+ if (stored) {
36
+ inMemoryId = stored;
37
+ return stored;
38
+ }
39
+ const fresh = randomId();
40
+ window.localStorage.setItem(STORAGE_KEY, fresh);
41
+ inMemoryId = fresh;
42
+ return fresh;
43
+ }
44
+ catch {
45
+ // Private mode / storage blocked: keep a per-page-load id (still better
46
+ // than sharing the IP bucket with every other visitor).
47
+ inMemoryId = inMemoryId ?? randomId();
48
+ return inMemoryId;
49
+ }
50
+ }
51
+ /**
52
+ * Wraps the host's getUserId so anonymous visitors still send a stable
53
+ * per-browser caller id for rate-limit fairness. The host's real user id
54
+ * always wins when present.
55
+ */
56
+ function withAnonCallerFallback(getUserId) {
57
+ return () => {
58
+ const real = getUserId?.();
59
+ if (real)
60
+ return real;
61
+ return getAnonCallerId();
62
+ };
63
+ }
@@ -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)
@@ -33,6 +33,7 @@ export declare function fetchClientManifest(opts: {
33
33
  baseUrl: string;
34
34
  apiKey: string;
35
35
  }): Promise<ManifestBootstrapResult>;
36
+ export declare function markEmbedDistribution(): void;
36
37
  /**
37
38
  * Best-effort heartbeat proving the orb booted. Never throws and never blocks
38
39
  * the orb — failures are swallowed (the dashboard simply won't flip to ✓).
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_WEB_API_URL = void 0;
4
4
  exports.fetchClientManifest = fetchClientManifest;
5
+ exports.markEmbedDistribution = markEmbedDistribution;
5
6
  exports.sendOrbSeen = sendOrbSeen;
6
7
  const version_1 = require("../version");
7
8
  /**
@@ -42,11 +43,21 @@ async function fetchClientManifest(opts) {
42
43
  }
43
44
  return { status: 'ready', manifest: data.manifest };
44
45
  }
46
+ /**
47
+ * Set by the embed bundle entrypoint (dist/embed/fiodos-embed.js). Embeds are
48
+ * loaded from a CDN `@latest` URL (Shopify themes, Dynamics…), so they update
49
+ * THEMSELVES — the heartbeat marks them so the dashboard never shows an
50
+ * "update available / npm install" notice the developer cannot act on.
51
+ */
52
+ let embedDistribution = false;
53
+ function markEmbedDistribution() {
54
+ embedDistribution = true;
55
+ }
45
56
  function detectPlatform() {
46
- if (typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)) {
47
- return 'web-mobile';
48
- }
49
- return 'web';
57
+ const mobile = typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
58
+ if (embedDistribution)
59
+ return mobile ? 'web-embed-mobile' : 'web-embed';
60
+ return mobile ? 'web-mobile' : 'web';
50
61
  }
51
62
  /**
52
63
  * Best-effort heartbeat proving the orb booted. Never throws and never blocks
@@ -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;
@@ -542,6 +548,7 @@ class AgentController {
542
548
  // Declining a sensitive action stops any chain it was part of, cleanly.
543
549
  const chainState = this.chainState;
544
550
  this.chainState = null;
551
+ this.deferredEscortIntent = null;
545
552
  if (chainState) {
546
553
  this.telemetry.logEvent({
547
554
  eventType: 'agent_chain_aborted',
@@ -661,6 +668,10 @@ class AgentController {
661
668
  let outAudio = turn.audioBase64;
662
669
  if (outReply)
663
670
  this.serverTtsUnavailable = !outAudio;
671
+ // Mid-chain: hold the showcase escort. On multi-page hosts (Shopify
672
+ // embeds) the escort is a full page load that would kill the chain
673
+ // before its continuation turn; the chain escorts once when it ends.
674
+ const escortDeferred = this.chaining.enabled && turn.taskStatus === 'in_progress';
664
675
  const decision = await (0, turnEngine_1.decideAction)({
665
676
  action: turn.action,
666
677
  manifest: this.config.manifest,
@@ -668,6 +679,7 @@ class AgentController {
668
679
  context: snapshotContext,
669
680
  messages: this.messages,
670
681
  userMessage,
682
+ suppressEscort: escortDeferred,
671
683
  });
672
684
  let canContinue = false;
673
685
  let lastStep;
@@ -738,6 +750,15 @@ class AgentController {
738
750
  (turn.action.type === 'navigate' || turn.action.type === 'execute') &&
739
751
  r.success &&
740
752
  !r.recoverable) {
753
+ // Deferred-escort bookkeeping: remember the intent whose escort was
754
+ // held (flushed at chain end); a navigate — or an execute whose escort
755
+ // already ran inside the executor — supersedes any earlier deferral.
756
+ if (turn.action.type === 'execute') {
757
+ this.deferredEscortIntent = escortDeferred ? (turn.action.intent ?? null) : null;
758
+ }
759
+ else {
760
+ this.deferredEscortIntent = null;
761
+ }
741
762
  const intent = turn.action.intent ?? '';
742
763
  // Feed the step's RESULT DATA back to the next chain turn (bounded):
743
764
  // it is how the model reads real technical values (variant ids, line
@@ -941,8 +962,23 @@ class AgentController {
941
962
  }
942
963
  finally {
943
964
  this.setChainActive(false);
965
+ this.flushDeferredEscort();
944
966
  }
945
967
  }
968
+ /**
969
+ * Runs the showcase escort that was deferred while the chain was executing
970
+ * (see suppressEscort in applyOutcome). Skipped while a confirmation is
971
+ * pending: the chain may resume after the user answers, and the confirmed
972
+ * action escorts on its own.
973
+ */
974
+ flushDeferredEscort() {
975
+ if (this.pending != null)
976
+ return;
977
+ const intent = this.deferredEscortIntent;
978
+ this.deferredEscortIntent = null;
979
+ if (intent)
980
+ this.executor.escortForIntent(intent);
981
+ }
946
982
  // ── Main turn ────────────────────────────────────────────────────────────────
947
983
  async handleTranscript(rawText) {
948
984
  const text = rawText.trim();
@@ -1226,6 +1262,7 @@ class AgentController {
1226
1262
  this.config.voice.stopPlayback();
1227
1263
  this.pending = null;
1228
1264
  this.chainState = null;
1265
+ this.deferredEscortIntent = null;
1229
1266
  this.confirmationMessage = null;
1230
1267
  this.confirmationVoiceHint = null;
1231
1268
  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
@@ -24,6 +24,7 @@ const AgentController_1 = require("../controller/AgentController");
24
24
  const mountOrb_1 = require("../orb/mountOrb");
25
25
  const devErrorBadge_1 = require("../orb/devErrorBadge");
26
26
  const clientBootstrap_1 = require("../api/clientBootstrap");
27
+ const anonUserId_1 = require("../api/anonUserId");
27
28
  function defaultNavigate(route) {
28
29
  if (typeof window !== 'undefined' && window.location)
29
30
  window.location.assign(route);
@@ -63,15 +64,18 @@ function assemble(options, manifest, baseUrl) {
63
64
  const fallback = browserLocale();
64
65
  const locale = resolveAgentLocale(options);
65
66
  const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
67
+ // Rate-limit fairness: anonymous visitors still send a stable per-browser
68
+ // caller id (never used for conversation identity — that stays 'anon').
69
+ const callerId = (0, anonUserId_1.withAnonCallerFallback)(options.getUserId);
66
70
  const backend = (0, backendClient_1.createFiodosBackendClient)({
67
71
  baseUrl,
68
72
  apiKey: options.apiKey,
69
- getUserId: options.getUserId,
73
+ getUserId: callerId,
70
74
  });
71
75
  const telemetry = (0, backendTelemetry_1.createFiodosTelemetry)({
72
76
  baseUrl,
73
77
  apiKey: options.apiKey,
74
- getUserId: options.getUserId,
78
+ getUserId: callerId,
75
79
  });
76
80
  const config = {
77
81
  manifest,
@@ -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.17";
30
+ readonly version: "0.1.19";
31
31
  readonly createFiodosAgent: typeof createFiodosAgent;
32
32
  readonly mountOrb: typeof mountOrb;
33
33
  readonly AgentController: typeof AgentController;
@@ -28,7 +28,11 @@ const AgentController_1 = require("../controller/AgentController");
28
28
  const screenContextStore_1 = require("../context/screenContextStore");
29
29
  const dynamics_1 = require("./dynamics");
30
30
  const version_1 = require("../version");
31
+ const clientBootstrap_1 = require("../api/clientBootstrap");
31
32
  const core_1 = require("@fiodos/core");
33
+ // This bundle is served from a CDN `@latest` URL and updates itself: mark the
34
+ // heartbeat so the dashboard never shows an npm update command for it.
35
+ (0, clientBootstrap_1.markEmbedDistribution)();
32
36
  const api = {
33
37
  version: version_1.SDK_VERSION,
34
38
  createFiodosAgent: createFiodosAgent_1.createFiodosAgent,
@@ -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.17";
8
+ export declare const SDK_VERSION = "0.1.19";
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.17';
11
+ exports.SDK_VERSION = '0.1.19';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';