@smooai/chat-widget 0.6.0 → 0.7.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.
@@ -18,6 +18,7 @@ var SmoothAgentChat = (function(exports) {
18
18
  userName: config.userName,
19
19
  userEmail: config.userEmail,
20
20
  userPhone: config.userPhone,
21
+ authContext: config.authContext,
21
22
  placeholder: config.placeholder ?? "Type a message…",
22
23
  greeting: config.greeting ?? "Hi! How can I help you today?",
23
24
  connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
@@ -26,6 +27,9 @@ var SmoothAgentChat = (function(exports) {
26
27
  requireName: config.requireName ?? false,
27
28
  requireEmail: config.requireEmail ?? false,
28
29
  requirePhone: config.requirePhone ?? false,
30
+ collectPhone: config.collectPhone ?? true,
31
+ collectConsent: config.collectConsent ?? true,
32
+ allowChatRestore: config.allowChatRestore ?? true,
29
33
  allowAnonymous: config.allowAnonymous ?? false,
30
34
  theme: {
31
35
  text: theme.text ?? "#f8fafc",
@@ -609,6 +613,451 @@ var SmoothAgentChat = (function(exports) {
609
613
  throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId);
610
614
  }
611
615
  //#endregion
616
+ //#region src/fingerprint.ts
617
+ /**
618
+ * Browser fingerprint — a stable, privacy-light identifier used by the
619
+ * smooth-operator server to correlate an anonymous visitor's sessions across
620
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
621
+ * CRM contact). It rides every `create_conversation_session` as
622
+ * `browserFingerprint` (see ADR-048).
623
+ *
624
+ * ## Why not ThumbmarkJS
625
+ *
626
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
627
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
628
+ * full build pulls in extensive device-detection tables and async
629
+ * component collection, adding tens of kilobytes (and async surface) to a
630
+ * bundle whose whole selling point is staying out of the host page's
631
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
632
+ *
633
+ * ## What this is (and the tradeoff)
634
+ *
635
+ * The correlation that actually matters — "is this the same browser as last
636
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
637
+ * field in the persisted store). That UUID is generated once and reused for
638
+ * the life of the localStorage entry, so same-browser resume is exact and
639
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
640
+ * mixed into that UUID's derivation seed on first generation — it gives the
641
+ * server-side resolver a soft signal for the (rare) cross-storage case where
642
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
643
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
644
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
645
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
646
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
647
+ * of truth for any fuzzy matching; the client just supplies a stable token.
648
+ */
649
+ /** Collect a small set of stable, non-invasive browser signals. */
650
+ function collectSignals() {
651
+ const parts = [];
652
+ try {
653
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
654
+ if (nav) {
655
+ parts.push(`ua:${nav.userAgent ?? ""}`);
656
+ parts.push(`lang:${nav.language ?? ""}`);
657
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
658
+ parts.push(`plat:${nav.platform ?? ""}`);
659
+ parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
660
+ parts.push(`dm:${nav.deviceMemory ?? ""}`);
661
+ }
662
+ if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
663
+ if (typeof Intl !== "undefined") try {
664
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
665
+ } catch {}
666
+ parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
667
+ } catch {}
668
+ return parts.join("|");
669
+ }
670
+ /**
671
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
672
+ * an unsigned hex string. Stable across runs for the same input. Not used for
673
+ * any security decision — only to derive a deterministic seed from the signal
674
+ * string above.
675
+ */
676
+ function fnv1a(input) {
677
+ let h = 2166136261;
678
+ for (let i = 0; i < input.length; i++) {
679
+ h ^= input.charCodeAt(i);
680
+ h = Math.imul(h, 16777619);
681
+ }
682
+ return (h >>> 0).toString(16).padStart(8, "0");
683
+ }
684
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
685
+ function generateUuid() {
686
+ try {
687
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
688
+ } catch {}
689
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
690
+ const r = Math.random() * 16 | 0;
691
+ return (c === "x" ? r : r & 3 | 8).toString(16);
692
+ });
693
+ }
694
+ /**
695
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
696
+ * FNV hash of the device signals so the server can recover a soft device
697
+ * signal even if it only has the token. Called ONCE per browser (the result is
698
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
699
+ */
700
+ function computeFingerprint() {
701
+ const signalHash = fnv1a(collectSignals());
702
+ return `${generateUuid()}.${signalHash}`;
703
+ }
704
+ /**
705
+ * Return the cached fingerprint if one was already computed for this browser,
706
+ * otherwise compute + persist a new one via the provided accessors. The
707
+ * persistence layer owns storage; this function owns the "compute once" policy.
708
+ */
709
+ function getOrCreateFingerprint(get, set) {
710
+ const existing = get();
711
+ if (existing) return existing;
712
+ const fp = computeFingerprint();
713
+ set(fp);
714
+ return fp;
715
+ }
716
+ //#endregion
717
+ //#region node_modules/.pnpm/zustand@5.0.14/node_modules/zustand/esm/vanilla.mjs
718
+ const createStoreImpl = (createState) => {
719
+ let state;
720
+ const listeners = /* @__PURE__ */ new Set();
721
+ const setState = (partial, replace) => {
722
+ const nextState = typeof partial === "function" ? partial(state) : partial;
723
+ if (!Object.is(nextState, state)) {
724
+ const previousState = state;
725
+ state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
726
+ listeners.forEach((listener) => listener(state, previousState));
727
+ }
728
+ };
729
+ const getState = () => state;
730
+ const getInitialState = () => initialState;
731
+ const subscribe = (listener) => {
732
+ listeners.add(listener);
733
+ return () => listeners.delete(listener);
734
+ };
735
+ const api = {
736
+ setState,
737
+ getState,
738
+ getInitialState,
739
+ subscribe
740
+ };
741
+ const initialState = state = createState(setState, getState, api);
742
+ return api;
743
+ };
744
+ const createStore = ((createState) => createState ? createStoreImpl(createState) : createStoreImpl);
745
+ //#endregion
746
+ //#region node_modules/.pnpm/zustand@5.0.14/node_modules/zustand/esm/middleware.mjs
747
+ function createJSONStorage(getStorage, options) {
748
+ let storage;
749
+ try {
750
+ storage = getStorage();
751
+ } catch (e) {
752
+ return;
753
+ }
754
+ return {
755
+ getItem: (name) => {
756
+ var _a;
757
+ const parse = (str2) => {
758
+ if (str2 === null) return null;
759
+ return JSON.parse(str2, options == null ? void 0 : options.reviver);
760
+ };
761
+ const str = (_a = storage.getItem(name)) != null ? _a : null;
762
+ if (str instanceof Promise) return str.then(parse);
763
+ return parse(str);
764
+ },
765
+ setItem: (name, newValue) => storage.setItem(name, JSON.stringify(newValue, options == null ? void 0 : options.replacer)),
766
+ removeItem: (name) => storage.removeItem(name)
767
+ };
768
+ }
769
+ const toThenable = (fn) => (input) => {
770
+ try {
771
+ const result = fn(input);
772
+ if (result instanceof Promise) return result;
773
+ return {
774
+ then(onFulfilled) {
775
+ return toThenable(onFulfilled)(result);
776
+ },
777
+ catch(_onRejected) {
778
+ return this;
779
+ }
780
+ };
781
+ } catch (e) {
782
+ return {
783
+ then(_onFulfilled) {
784
+ return this;
785
+ },
786
+ catch(onRejected) {
787
+ return toThenable(onRejected)(e);
788
+ }
789
+ };
790
+ }
791
+ };
792
+ const persistImpl = (config, baseOptions) => (set, get, api) => {
793
+ let options = {
794
+ storage: createJSONStorage(() => window.localStorage),
795
+ partialize: (state) => state,
796
+ version: 0,
797
+ merge: (persistedState, currentState) => ({
798
+ ...currentState,
799
+ ...persistedState
800
+ }),
801
+ ...baseOptions
802
+ };
803
+ let hasHydrated = false;
804
+ let hydrationVersion = 0;
805
+ const hydrationListeners = /* @__PURE__ */ new Set();
806
+ const finishHydrationListeners = /* @__PURE__ */ new Set();
807
+ let storage = options.storage;
808
+ if (!storage) return config((...args) => {
809
+ console.warn(`[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`);
810
+ set(...args);
811
+ }, get, api);
812
+ const setItem = () => {
813
+ const state = options.partialize({ ...get() });
814
+ return storage.setItem(options.name, {
815
+ state,
816
+ version: options.version
817
+ });
818
+ };
819
+ const savedSetState = api.setState;
820
+ api.setState = (state, replace) => {
821
+ savedSetState(state, replace);
822
+ return setItem();
823
+ };
824
+ const configResult = config((...args) => {
825
+ set(...args);
826
+ return setItem();
827
+ }, get, api);
828
+ api.getInitialState = () => configResult;
829
+ let stateFromStorage;
830
+ const hydrate = () => {
831
+ var _a, _b;
832
+ if (!storage) return;
833
+ const currentVersion = ++hydrationVersion;
834
+ hasHydrated = false;
835
+ hydrationListeners.forEach((cb) => {
836
+ var _a2;
837
+ return cb((_a2 = get()) != null ? _a2 : configResult);
838
+ });
839
+ const postRehydrationCallback = ((_b = options.onRehydrateStorage) == null ? void 0 : _b.call(options, (_a = get()) != null ? _a : configResult)) || void 0;
840
+ return toThenable(storage.getItem.bind(storage))(options.name).then((deserializedStorageValue) => {
841
+ if (deserializedStorageValue) if (typeof deserializedStorageValue.version === "number" && deserializedStorageValue.version !== options.version) {
842
+ if (options.migrate) {
843
+ const migration = options.migrate(deserializedStorageValue.state, deserializedStorageValue.version);
844
+ if (migration instanceof Promise) return migration.then((result) => [true, result]);
845
+ return [true, migration];
846
+ }
847
+ console.error(`State loaded from storage couldn't be migrated since no migrate function was provided`);
848
+ } else return [false, deserializedStorageValue.state];
849
+ return [false, void 0];
850
+ }).then((migrationResult) => {
851
+ var _a2;
852
+ if (currentVersion !== hydrationVersion) return;
853
+ const [migrated, migratedState] = migrationResult;
854
+ stateFromStorage = options.merge(migratedState, (_a2 = get()) != null ? _a2 : configResult);
855
+ set(stateFromStorage, true);
856
+ if (migrated) return setItem();
857
+ }).then(() => {
858
+ if (currentVersion !== hydrationVersion) return;
859
+ postRehydrationCallback?.(get(), void 0);
860
+ stateFromStorage = get();
861
+ hasHydrated = true;
862
+ finishHydrationListeners.forEach((cb) => cb(stateFromStorage));
863
+ }).catch((e) => {
864
+ if (currentVersion !== hydrationVersion) return;
865
+ postRehydrationCallback?.(void 0, e);
866
+ });
867
+ };
868
+ api.persist = {
869
+ setOptions: (newOptions) => {
870
+ options = {
871
+ ...options,
872
+ ...newOptions
873
+ };
874
+ if (newOptions.storage) storage = newOptions.storage;
875
+ },
876
+ clearStorage: () => {
877
+ storage?.removeItem(options.name);
878
+ },
879
+ getOptions: () => options,
880
+ rehydrate: () => hydrate(),
881
+ hasHydrated: () => hasHydrated,
882
+ onHydrate: (cb) => {
883
+ hydrationListeners.add(cb);
884
+ return () => {
885
+ hydrationListeners.delete(cb);
886
+ };
887
+ },
888
+ onFinishHydration: (cb) => {
889
+ finishHydrationListeners.add(cb);
890
+ return () => {
891
+ finishHydrationListeners.delete(cb);
892
+ };
893
+ }
894
+ };
895
+ if (!options.skipHydration) hydrate();
896
+ return stateFromStorage || configResult;
897
+ };
898
+ const persist = persistImpl;
899
+ const EMPTY_CONSENT = {
900
+ emailOptIn: false,
901
+ smsOptIn: false
902
+ };
903
+ function initialPersisted() {
904
+ return {
905
+ version: 1,
906
+ sessionId: null,
907
+ identity: {},
908
+ consent: { ...EMPTY_CONSENT },
909
+ verifiedEmail: null,
910
+ verifiedEmailSessionId: null,
911
+ browserFingerprint: null
912
+ };
913
+ }
914
+ /** localStorage key for an agent's persisted widget state. */
915
+ function storageKey(agentId) {
916
+ return `smoo-chat-widget:${agentId}`;
917
+ }
918
+ /**
919
+ * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
920
+ * real localStorage is unavailable.
921
+ *
922
+ * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
923
+ * zustand v5's `persist` treats a missing `storage` option by falling back to its
924
+ * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
925
+ * localStorage the guard was trying to avoid (throwing again in privacy mode).
926
+ * Handing it this no-op storage keeps the store working purely in memory and
927
+ * guarantees the fallback can't touch real localStorage.
928
+ */
929
+ function memoryStorage() {
930
+ const mem = /* @__PURE__ */ new Map();
931
+ return {
932
+ getItem: (name) => {
933
+ const raw = mem.get(name);
934
+ if (!raw) return null;
935
+ try {
936
+ return JSON.parse(raw);
937
+ } catch {
938
+ return null;
939
+ }
940
+ },
941
+ setItem: (name, value) => {
942
+ mem.set(name, JSON.stringify(value));
943
+ },
944
+ removeItem: (name) => {
945
+ mem.delete(name);
946
+ }
947
+ };
948
+ }
949
+ /**
950
+ * A `persist` storage adapter that tolerates the *absence* of localStorage
951
+ * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
952
+ * unavailable the store still works in-memory; nothing is persisted, but the
953
+ * widget never throws on boot.
954
+ */
955
+ function safeStorage() {
956
+ let ls = null;
957
+ try {
958
+ ls = typeof localStorage !== "undefined" ? localStorage : null;
959
+ if (ls) {
960
+ const probe = "__smoo_probe__";
961
+ ls.setItem(probe, "1");
962
+ ls.removeItem(probe);
963
+ }
964
+ } catch {
965
+ ls = null;
966
+ }
967
+ if (!ls) return memoryStorage();
968
+ const storage = ls;
969
+ return {
970
+ getItem: (name) => {
971
+ try {
972
+ const raw = storage.getItem(name);
973
+ return raw ? JSON.parse(raw) : null;
974
+ } catch {
975
+ return null;
976
+ }
977
+ },
978
+ setItem: (name, value) => {
979
+ try {
980
+ storage.setItem(name, JSON.stringify(value));
981
+ } catch {}
982
+ },
983
+ removeItem: (name) => {
984
+ try {
985
+ storage.removeItem(name);
986
+ } catch {}
987
+ }
988
+ };
989
+ }
990
+ /**
991
+ * Migrate a persisted blob from an older `version` to the current shape. Today
992
+ * v1 is the only version, so this just backfills any missing fields onto an
993
+ * unknown old blob; future versions add `case` branches here.
994
+ */
995
+ function migrate(persisted) {
996
+ const base = initialPersisted();
997
+ if (!persisted || typeof persisted !== "object") return base;
998
+ const p = persisted;
999
+ return {
1000
+ version: 1,
1001
+ sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
1002
+ identity: {
1003
+ name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
1004
+ email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
1005
+ phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
1006
+ },
1007
+ consent: {
1008
+ emailOptIn: p.consent?.emailOptIn === true,
1009
+ smsOptIn: p.consent?.smsOptIn === true,
1010
+ consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
1011
+ consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
1012
+ },
1013
+ verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
1014
+ verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
1015
+ browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
1016
+ };
1017
+ }
1018
+ /**
1019
+ * Create the per-agent persisted Zustand store. The `partialize` step ensures
1020
+ * only the persisted shape (never any future transient action/UI state) is
1021
+ * written to localStorage.
1022
+ */
1023
+ function createWidgetStore(agentId) {
1024
+ return createStore()(persist((set) => ({
1025
+ ...initialPersisted(),
1026
+ setSessionId: (sessionId) => set({ sessionId }),
1027
+ mergeIdentity: (identity) => set((state) => ({ identity: {
1028
+ ...state.identity,
1029
+ ...identity.name !== void 0 ? { name: identity.name } : {},
1030
+ ...identity.email !== void 0 ? { email: identity.email } : {},
1031
+ ...identity.phone !== void 0 ? { phone: identity.phone } : {}
1032
+ } })),
1033
+ setConsent: (consent) => set({ consent: { ...consent } }),
1034
+ setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
1035
+ verifiedEmail,
1036
+ verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
1037
+ })),
1038
+ setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
1039
+ clearSession: () => set({
1040
+ sessionId: null,
1041
+ verifiedEmail: null,
1042
+ verifiedEmailSessionId: null
1043
+ })
1044
+ }), {
1045
+ name: storageKey(agentId),
1046
+ version: 1,
1047
+ storage: safeStorage(),
1048
+ migrate,
1049
+ partialize: (state) => ({
1050
+ version: 1,
1051
+ sessionId: state.sessionId,
1052
+ identity: state.identity,
1053
+ consent: state.consent,
1054
+ verifiedEmail: state.verifiedEmail,
1055
+ verifiedEmailSessionId: state.verifiedEmailSessionId,
1056
+ browserFingerprint: state.browserFingerprint
1057
+ })
1058
+ }));
1059
+ }
1060
+ //#endregion
612
1061
  //#region src/conversation.ts
613
1062
  /**
614
1063
  * ConversationController — the bridge between the widget UI and the
@@ -620,13 +1069,41 @@ var SmoothAgentChat = (function(exports) {
620
1069
  * so the swap is purely at the client-library boundary.
621
1070
  *
622
1071
  * Flow:
623
- * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
1072
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
1073
+ * (or RESUMES a persisted session via `get_session`/`get_messages`).
624
1074
  * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
625
1075
  * in-progress assistant message, then the terminal
626
1076
  * `eventual_response`.
627
1077
  *
1078
+ * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
1079
+ * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
1080
+ * - identity + marketing consent threaded into the session `metadata`.
1081
+ * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
1082
+ * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
1083
+ * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
1084
+ * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
1085
+ * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
1086
+ * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
1087
+ * NOT WS frames.
1088
+ *
628
1089
  * The controller is UI-agnostic: it emits typed events and the view renders them.
629
1090
  */
1091
+ /**
1092
+ * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
1093
+ * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
1094
+ * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
1095
+ * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
1096
+ * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
1097
+ */
1098
+ function httpBaseFromWsEndpoint(endpoint) {
1099
+ try {
1100
+ const u = new URL(endpoint);
1101
+ u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
1102
+ return `${u.protocol}//${u.host}`;
1103
+ } catch {
1104
+ return null;
1105
+ }
1106
+ }
630
1107
  /** Pull the final assistant text out of an `eventual_response` data payload. */
631
1108
  function extractFinalText(response) {
632
1109
  if (!response || typeof response !== "object") return null;
@@ -667,40 +1144,89 @@ var SmoothAgentChat = (function(exports) {
667
1144
  }
668
1145
  return out;
669
1146
  }
1147
+ /** Convert a server message row into a finalized {@link ChatMessage}. */
1148
+ function wireMessageToChat(m, idx) {
1149
+ const text = typeof m.content?.text === "string" ? m.content.text : "";
1150
+ if (!text) return null;
1151
+ const role = m.direction === "outbound" ? "assistant" : "user";
1152
+ return {
1153
+ id: typeof m.id === "string" ? m.id : `hist-${idx}`,
1154
+ role,
1155
+ text,
1156
+ streaming: false
1157
+ };
1158
+ }
670
1159
  var ConversationController = class {
671
- constructor(config, events) {
1160
+ constructor(config, events, store) {
672
1161
  _defineProperty(this, "config", void 0);
673
1162
  _defineProperty(this, "events", void 0);
1163
+ _defineProperty(this, "store", void 0);
674
1164
  _defineProperty(this, "client", null);
675
1165
  _defineProperty(this, "sessionId", null);
676
1166
  _defineProperty(this, "messages", []);
677
1167
  _defineProperty(this, "status", "idle");
678
1168
  _defineProperty(this, "seq", 0);
679
- _defineProperty(this, "identity", void 0);
680
1169
  _defineProperty(this, "activeRequestId", null);
681
1170
  _defineProperty(this, "interrupt", null);
1171
+ _defineProperty(this, "identityRestore", { phase: "idle" });
1172
+ _defineProperty(this, "resumeAttempted", false);
1173
+ _defineProperty(this, "httpBase", void 0);
682
1174
  this.config = config;
683
1175
  this.events = events;
684
- this.identity = {
685
- name: config.userName,
686
- email: config.userEmail,
687
- phone: config.userPhone
688
- };
1176
+ this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
1177
+ if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
1178
+ this.store = store ?? createWidgetStore(config.agentId);
1179
+ const seed = {};
1180
+ if (config.userName) seed.name = config.userName;
1181
+ if (config.userEmail) seed.email = config.userEmail;
1182
+ if (config.userPhone) seed.phone = config.userPhone;
1183
+ if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
689
1184
  }
690
1185
  get connectionStatus() {
691
1186
  return this.status;
692
1187
  }
693
- /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
1188
+ /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
1189
+ getStore() {
1190
+ return this.store;
1191
+ }
1192
+ /** True when a persisted session pointer exists (drives the resume path). */
1193
+ hasPersistedSession() {
1194
+ return !!this.store.getState().sessionId;
1195
+ }
1196
+ /** True when persisted identity exists (lets the view skip the pre-chat form). */
1197
+ hasPersistedIdentity() {
1198
+ const id = this.store.getState().identity;
1199
+ return !!(id.name || id.email || id.phone);
1200
+ }
1201
+ /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
694
1202
  setUserInfo(info) {
695
- this.identity = {
696
- ...this.identity,
697
- ...info
698
- };
1203
+ const { name, email, phone, consent } = info;
1204
+ this.store.getState().mergeIdentity({
1205
+ name,
1206
+ email,
1207
+ phone
1208
+ });
1209
+ if (consent) {
1210
+ const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
1211
+ this.store.getState().setConsent({
1212
+ emailOptIn: consent.emailOptIn,
1213
+ smsOptIn: consent.smsOptIn,
1214
+ consentSource: "chat-widget-prechat",
1215
+ consentAt
1216
+ });
1217
+ }
699
1218
  }
700
1219
  setInterrupt(interrupt) {
701
1220
  this.interrupt = interrupt;
702
1221
  this.events.onInterrupt?.(interrupt);
703
1222
  }
1223
+ setIdentityRestore(state) {
1224
+ this.identityRestore = state;
1225
+ this.events.onIdentityRestore?.(state);
1226
+ }
1227
+ get currentIdentityRestore() {
1228
+ return this.identityRestore;
1229
+ }
704
1230
  /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
705
1231
  verifyOtp(code) {
706
1232
  if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
@@ -731,20 +1257,95 @@ var SmoothAgentChat = (function(exports) {
731
1257
  emitMessages() {
732
1258
  this.events.onMessages(this.messages.map((m) => ({ ...m })));
733
1259
  }
734
- /** Open the transport and create a conversation session. Idempotent. */
1260
+ /** Compute (once) + return the persisted browser fingerprint. */
1261
+ fingerprint() {
1262
+ const state = this.store.getState();
1263
+ return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
1264
+ }
1265
+ /**
1266
+ * Build the `metadata` payload threaded into `create_conversation_session`:
1267
+ * phone (no first-class engine field) and consent.
1268
+ *
1269
+ * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
1270
+ * OTP proof bound to the session it was verified against
1271
+ * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
1272
+ * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
1273
+ * every brand-new `create_conversation_session` would mislabel a fresh
1274
+ * visitor's session with a prior (possibly different) visitor's email on a
1275
+ * shared browser. The verified email is only used when RESUMING the exact
1276
+ * session it was proven for — see {@link verifiedEmailForSession}.
1277
+ */
1278
+ sessionMetadata() {
1279
+ const state = this.store.getState();
1280
+ const meta = {};
1281
+ if (state.identity.phone) meta.userPhone = state.identity.phone;
1282
+ const consent = state.consent;
1283
+ if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
1284
+ const c = {
1285
+ emailOptIn: consent.emailOptIn,
1286
+ smsOptIn: consent.smsOptIn,
1287
+ consentSource: consent.consentSource ?? "chat-widget-prechat"
1288
+ };
1289
+ if (consent.consentAt) c.consentAt = consent.consentAt;
1290
+ meta.consent = c;
1291
+ }
1292
+ return Object.keys(meta).length > 0 ? meta : void 0;
1293
+ }
1294
+ /**
1295
+ * The verified-email hint, but ONLY when the OTP proof is bound to the session
1296
+ * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
1297
+ * otherwise so a stale/cross-visitor proof is never threaded onto a session it
1298
+ * wasn't proven for.
1299
+ */
1300
+ verifiedEmailForSession(sessionId) {
1301
+ const state = this.store.getState();
1302
+ if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
1303
+ return null;
1304
+ }
1305
+ /** Lazily open the WS client (default transport). Idempotent within a connect. */
1306
+ async ensureClient() {
1307
+ if (this.client) return;
1308
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
1309
+ await this.client.connect();
1310
+ }
1311
+ /**
1312
+ * Open the connection and either RESUME or create a session.
1313
+ *
1314
+ * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
1315
+ * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
1316
+ * ONLY the pointer (identity/consent survive).
1317
+ * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
1318
+ * `resumable`, adopt the returned session (the wrapper has primed the
1319
+ * operator registry), reuse the sessionId, and hydrate via get_session/
1320
+ * get_messages — rather than relying on createConversationSession to resume.
1321
+ * 3. Otherwise create a fresh session.
1322
+ */
735
1323
  async connect() {
736
1324
  if (this.status === "connecting" || this.status === "ready") return;
737
1325
  this.setStatus("connecting");
738
1326
  try {
739
- this.client = new SmoothAgentClient({ url: this.config.endpoint });
740
- await this.client.connect();
741
- const session = await this.client.createConversationSession({
742
- agentId: this.config.agentId,
743
- userName: this.identity.name,
744
- userEmail: this.identity.email,
745
- ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}
746
- });
747
- this.sessionId = session.sessionId;
1327
+ await this.ensureClient();
1328
+ if (!this.resumeAttempted) {
1329
+ this.resumeAttempted = true;
1330
+ const persistedSessionId = this.store.getState().sessionId;
1331
+ if (persistedSessionId) {
1332
+ if (await this.tryResume(persistedSessionId)) {
1333
+ this.setStatus("ready");
1334
+ return;
1335
+ }
1336
+ this.store.getState().clearSession();
1337
+ } else {
1338
+ const fpSessionId = await this.resumeByFingerprint();
1339
+ if (fpSessionId) {
1340
+ if (await this.tryResume(fpSessionId)) {
1341
+ this.store.getState().setSessionId(fpSessionId);
1342
+ this.setStatus("ready");
1343
+ return;
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+ await this.createSession();
748
1349
  this.setStatus("ready");
749
1350
  } catch (err) {
750
1351
  this.setStatus("error", err instanceof Error ? err.message : String(err));
@@ -752,6 +1353,105 @@ var SmoothAgentChat = (function(exports) {
752
1353
  }
753
1354
  }
754
1355
  /**
1356
+ * Build the auth fields every `/internal/*` route shares: `agentId` (required
1357
+ * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
1358
+ * the optional pre-auth `authContext` the host page may have configured. The
1359
+ * `Origin` header is sent automatically by the browser and checked server-side.
1360
+ */
1361
+ authBody() {
1362
+ const body = { agentId: this.config.agentId };
1363
+ if (this.config.agentName) body.agentName = this.config.agentName;
1364
+ if (this.config.authContext) body.authContext = this.config.authContext;
1365
+ return body;
1366
+ }
1367
+ /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
1368
+ async postInternal(path, payload) {
1369
+ if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
1370
+ const res = await fetch(`${this.httpBase}${path}`, {
1371
+ method: "POST",
1372
+ headers: { "content-type": "application/json" },
1373
+ credentials: "omit",
1374
+ body: JSON.stringify({
1375
+ ...this.authBody(),
1376
+ ...payload
1377
+ })
1378
+ });
1379
+ let json = {};
1380
+ try {
1381
+ json = await res.json();
1382
+ } catch {
1383
+ json = {};
1384
+ }
1385
+ if (!res.ok) {
1386
+ const err = json.error;
1387
+ throw new Error(err?.message ?? `${path} failed (${res.status})`);
1388
+ }
1389
+ return json;
1390
+ }
1391
+ /**
1392
+ * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
1393
+ * the wrapper found (and primed) a recent session for this fingerprint, else
1394
+ * null. Network/route failures are swallowed → null (fall through to create).
1395
+ */
1396
+ async resumeByFingerprint() {
1397
+ try {
1398
+ const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
1399
+ if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
1400
+ } catch {}
1401
+ return null;
1402
+ }
1403
+ /** `create_conversation_session` with fingerprint + identity + consent metadata. */
1404
+ async createSession() {
1405
+ if (!this.client) throw new Error("Conversation is not connected");
1406
+ const state = this.store.getState();
1407
+ const metadata = this.sessionMetadata();
1408
+ const session = await this.client.createConversationSession({
1409
+ agentId: this.config.agentId,
1410
+ userName: state.identity.name,
1411
+ userEmail: state.identity.email,
1412
+ browserFingerprint: this.fingerprint(),
1413
+ ...metadata ? { metadata } : {}
1414
+ });
1415
+ this.sessionId = session.sessionId;
1416
+ this.store.getState().setSessionId(session.sessionId);
1417
+ }
1418
+ /**
1419
+ * Attempt to resume `sessionId`: returns true and hydrates the transcript when
1420
+ * the session is live, false when it has ended / can't be fetched.
1421
+ */
1422
+ async tryResume(sessionId) {
1423
+ if (!this.client) return false;
1424
+ let snap;
1425
+ try {
1426
+ snap = await this.client.getSession({ sessionId });
1427
+ } catch {
1428
+ return false;
1429
+ }
1430
+ if (snap.status === "ended") return false;
1431
+ this.sessionId = sessionId;
1432
+ await this.hydrateHistory(sessionId);
1433
+ return true;
1434
+ }
1435
+ /** Page recent history (newest-first), reverse to chronological, and render. */
1436
+ async hydrateHistory(sessionId) {
1437
+ if (!this.client) return;
1438
+ try {
1439
+ const page = await this.client.getMessages({
1440
+ sessionId,
1441
+ limit: 50
1442
+ });
1443
+ const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
1444
+ const hydrated = [];
1445
+ chronological.forEach((m, i) => {
1446
+ const chat = wireMessageToChat(m, i);
1447
+ if (chat) hydrated.push(chat);
1448
+ });
1449
+ this.messages.length = 0;
1450
+ this.messages.push(...hydrated);
1451
+ this.emitMessages();
1452
+ } catch {}
1453
+ }
1454
+ /**
755
1455
  * Submit a user message. Appends the user bubble immediately, then streams the
756
1456
  * assistant reply token-by-token, finalizing on `eventual_response`.
757
1457
  */
@@ -853,12 +1553,170 @@ var SmoothAgentChat = (function(exports) {
853
1553
  default: break;
854
1554
  }
855
1555
  }
1556
+ /**
1557
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
1558
+ * `email` over `channel`. The view collects the email via an explicit affordance.
1559
+ */
1560
+ async requestIdentityOtp(email, channel = "email") {
1561
+ const trimmed = email.trim();
1562
+ if (!trimmed) return;
1563
+ this.setIdentityRestore({
1564
+ phase: "requesting",
1565
+ email: trimmed,
1566
+ channel
1567
+ });
1568
+ if (!this.sessionId) try {
1569
+ await this.connect();
1570
+ } catch {}
1571
+ if (!this.sessionId) {
1572
+ this.setIdentityRestore({
1573
+ phase: "error",
1574
+ message: "No active session to verify against."
1575
+ });
1576
+ return;
1577
+ }
1578
+ try {
1579
+ const json = await this.postInternal("/internal/identity/request-otp", {
1580
+ sessionId: this.sessionId,
1581
+ email: trimmed,
1582
+ channel
1583
+ });
1584
+ const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
1585
+ this.setIdentityRestore({
1586
+ phase: "awaiting_code",
1587
+ email: trimmed,
1588
+ channel,
1589
+ maskedDestination: masked
1590
+ });
1591
+ } catch (err) {
1592
+ this.setIdentityRestore({
1593
+ phase: "error",
1594
+ message: err instanceof Error ? err.message : "Could not send a verification code."
1595
+ });
1596
+ }
1597
+ }
1598
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
1599
+ async verifyIdentityOtp(code) {
1600
+ const state = this.identityRestore;
1601
+ const trimmed = code.trim();
1602
+ if (!trimmed || state.phase !== "awaiting_code") return;
1603
+ const { email, channel } = state;
1604
+ if (!this.sessionId) {
1605
+ this.setIdentityRestore({
1606
+ phase: "error",
1607
+ message: "No active session to verify against."
1608
+ });
1609
+ return;
1610
+ }
1611
+ this.setIdentityRestore({
1612
+ phase: "verifying",
1613
+ email,
1614
+ channel
1615
+ });
1616
+ try {
1617
+ const json = await this.postInternal("/internal/identity/verify-otp", {
1618
+ sessionId: this.sessionId,
1619
+ email,
1620
+ code: trimmed
1621
+ });
1622
+ if (json.event === "otp_verified") {
1623
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
1624
+ await this.resolveIdentity(email);
1625
+ } else if (json.event === "otp_invalid") {
1626
+ const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
1627
+ this.setIdentityRestore({
1628
+ phase: "awaiting_code",
1629
+ email,
1630
+ channel,
1631
+ error: "That code was incorrect.",
1632
+ attemptsRemaining: remaining
1633
+ });
1634
+ } else this.setIdentityRestore({
1635
+ phase: "error",
1636
+ message: "Verification failed."
1637
+ });
1638
+ } catch (err) {
1639
+ this.setIdentityRestore({
1640
+ phase: "error",
1641
+ message: err instanceof Error ? err.message : "Verification failed."
1642
+ });
1643
+ }
1644
+ }
1645
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
1646
+ async resolveIdentity(email) {
1647
+ if (!this.sessionId) return;
1648
+ this.setIdentityRestore({
1649
+ phase: "resolving",
1650
+ email
1651
+ });
1652
+ try {
1653
+ const json = await this.postInternal("/internal/identity/resolve", {
1654
+ sessionId: this.sessionId,
1655
+ email
1656
+ });
1657
+ if (json.resolved !== true) {
1658
+ this.setIdentityRestore({
1659
+ phase: "resolved",
1660
+ email,
1661
+ conversations: []
1662
+ });
1663
+ return;
1664
+ }
1665
+ const raw = json.conversations;
1666
+ const conversations = Array.isArray(raw) ? raw.map((c) => {
1667
+ if (!c || typeof c !== "object") return null;
1668
+ const o = c;
1669
+ const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
1670
+ const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
1671
+ if (!sessionId) return null;
1672
+ return {
1673
+ conversationId,
1674
+ sessionId,
1675
+ lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
1676
+ preview: typeof o.preview === "string" ? o.preview : void 0
1677
+ };
1678
+ }).filter((c) => c !== null) : [];
1679
+ this.setIdentityRestore({
1680
+ phase: "resolved",
1681
+ email,
1682
+ conversations
1683
+ });
1684
+ } catch (err) {
1685
+ this.setIdentityRestore({
1686
+ phase: "error",
1687
+ message: err instanceof Error ? err.message : "Could not load your chats."
1688
+ });
1689
+ }
1690
+ }
1691
+ /**
1692
+ * Replay a chosen restorable conversation: point the live session at its
1693
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
1694
+ * the new pointer so the next `sendMessage` continues it.
1695
+ */
1696
+ async restoreConversation(sessionId) {
1697
+ if (!this.client) await this.ensureClient();
1698
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
1699
+ if (await this.tryResume(sessionId)) {
1700
+ this.store.getState().setSessionId(sessionId);
1701
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
1702
+ this.setIdentityRestore({ phase: "idle" });
1703
+ this.setStatus("ready");
1704
+ } else this.setIdentityRestore({
1705
+ phase: "error",
1706
+ message: "That conversation is no longer available."
1707
+ });
1708
+ }
1709
+ /** Dismiss the cross-device restore panel. */
1710
+ cancelIdentityRestore() {
1711
+ this.setIdentityRestore({ phase: "idle" });
1712
+ }
856
1713
  /** Tear down the underlying client. */
857
1714
  disconnect() {
858
1715
  this.client?.disconnect("widget closed");
859
1716
  this.client = null;
860
1717
  this.sessionId = null;
861
1718
  this.activeRequestId = null;
1719
+ this.resumeAttempted = false;
862
1720
  this.setInterrupt(null);
863
1721
  this.setStatus("closed");
864
1722
  }
@@ -1700,6 +2558,17 @@ var SmoothAgentChat = (function(exports) {
1700
2558
  }
1701
2559
  .pc-submit:hover { transform: translateY(-1px); }
1702
2560
  .pc-submit:active { transform: scale(.98); }
2561
+ .pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
2562
+ .pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
2563
+ .pc-consent input {
2564
+ margin-top: 2px;
2565
+ width: 16px;
2566
+ height: 16px;
2567
+ flex: 0 0 auto;
2568
+ accent-color: var(--sac-primary);
2569
+ cursor: pointer;
2570
+ }
2571
+ .pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
1703
2572
 
1704
2573
  /* ─────────────────── Starter-prompt chips ─────────────────────────── */
1705
2574
  .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
@@ -1778,6 +2647,61 @@ var SmoothAgentChat = (function(exports) {
1778
2647
  .int-row .int-btn { flex: 1; }
1779
2648
  .int-row .int-input + .int-btn { flex: 0 0 auto; }
1780
2649
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
2650
+ .int-card { position: relative; }
2651
+ .int-close {
2652
+ position: absolute;
2653
+ top: 8px;
2654
+ right: 9px;
2655
+ border: none;
2656
+ background: transparent;
2657
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
2658
+ font-size: 18px;
2659
+ line-height: 1;
2660
+ cursor: pointer;
2661
+ padding: 2px 4px;
2662
+ border-radius: 6px;
2663
+ transition: color .2s ease, background .2s ease;
2664
+ }
2665
+ .int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
2666
+
2667
+ /* ─────────────── Cross-device "Restore my chats" ──────────────────── */
2668
+ .restore-link {
2669
+ border: none;
2670
+ background: none;
2671
+ padding: 0;
2672
+ font: inherit;
2673
+ font-size: 10.5px;
2674
+ letter-spacing: .04em;
2675
+ color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
2676
+ cursor: pointer;
2677
+ text-decoration: underline;
2678
+ text-underline-offset: 2px;
2679
+ }
2680
+ .restore-link:hover { color: var(--sac-primary); }
2681
+ .restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
2682
+ .restore-item {
2683
+ display: flex;
2684
+ align-items: center;
2685
+ justify-content: space-between;
2686
+ gap: 10px;
2687
+ text-align: left;
2688
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
2689
+ background: var(--sac-bg);
2690
+ color: var(--sac-text);
2691
+ border-radius: 10px;
2692
+ padding: 9px 11px;
2693
+ font-family: inherit;
2694
+ font-size: 12.5px;
2695
+ cursor: pointer;
2696
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
2697
+ }
2698
+ .restore-item:hover {
2699
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
2700
+ background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
2701
+ transform: translateY(-1px);
2702
+ }
2703
+ .restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2704
+ .restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
1781
2705
 
1782
2706
  .hidden { display: none !important; }
1783
2707
 
@@ -1838,6 +2762,9 @@ var SmoothAgentChat = (function(exports) {
1838
2762
  _defineProperty(this, "greeting", "");
1839
2763
  _defineProperty(this, "interrupt", null);
1840
2764
  _defineProperty(this, "interruptEl", null);
2765
+ _defineProperty(this, "identityRestore", { phase: "idle" });
2766
+ _defineProperty(this, "allowChatRestore", true);
2767
+ _defineProperty(this, "gating", false);
1841
2768
  _defineProperty(this, "panelEl", null);
1842
2769
  _defineProperty(this, "launcherEl", null);
1843
2770
  _defineProperty(this, "messagesEl", null);
@@ -1884,7 +2811,7 @@ var SmoothAgentChat = (function(exports) {
1884
2811
  openChat() {
1885
2812
  this.open = true;
1886
2813
  this.syncOpenState();
1887
- this.controller?.connect().catch(() => {});
2814
+ if (!this.gating) this.controller?.connect().catch(() => {});
1888
2815
  }
1889
2816
  /** Collapse the chat panel back to the launcher. */
1890
2817
  closeChat() {
@@ -1905,6 +2832,7 @@ var SmoothAgentChat = (function(exports) {
1905
2832
  userName: this.overrides.userName,
1906
2833
  userEmail: this.overrides.userEmail,
1907
2834
  userPhone: this.overrides.userPhone,
2835
+ authContext: this.overrides.authContext,
1908
2836
  placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
1909
2837
  greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
1910
2838
  connectionErrorMessage: this.overrides.connectionErrorMessage,
@@ -1913,6 +2841,9 @@ var SmoothAgentChat = (function(exports) {
1913
2841
  requireName: this.overrides.requireName,
1914
2842
  requireEmail: this.overrides.requireEmail,
1915
2843
  requirePhone: this.overrides.requirePhone,
2844
+ collectPhone: this.overrides.collectPhone,
2845
+ collectConsent: this.overrides.collectConsent,
2846
+ allowChatRestore: this.overrides.allowChatRestore,
1916
2847
  allowAnonymous: this.overrides.allowAnonymous,
1917
2848
  theme
1918
2849
  };
@@ -1924,6 +2855,7 @@ var SmoothAgentChat = (function(exports) {
1924
2855
  return;
1925
2856
  }
1926
2857
  const resolved = resolveConfig(config);
2858
+ this.allowChatRestore = resolved.allowChatRestore;
1927
2859
  if (!this.controller) {
1928
2860
  this.controller = new ConversationController(config, {
1929
2861
  onMessages: (messages) => {
@@ -1937,9 +2869,14 @@ var SmoothAgentChat = (function(exports) {
1937
2869
  onInterrupt: (interrupt) => {
1938
2870
  this.interrupt = interrupt;
1939
2871
  this.renderInterrupt();
2872
+ },
2873
+ onIdentityRestore: (state) => {
2874
+ this.identityRestore = state;
2875
+ this.renderInterrupt();
1940
2876
  }
1941
2877
  });
1942
2878
  if (resolved.startOpen) this.open = true;
2879
+ if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) this.userInfoSatisfied = true;
1943
2880
  }
1944
2881
  const fullpage = resolved.mode === "fullpage";
1945
2882
  if (fullpage) this.open = true;
@@ -1964,7 +2901,14 @@ var SmoothAgentChat = (function(exports) {
1964
2901
  this.examplePrompts = resolved.examplePrompts;
1965
2902
  this.greeting = resolved.greeting;
1966
2903
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1967
- const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
2904
+ this.gating = gating;
2905
+ const showPhone = resolved.requirePhone || resolved.collectPhone;
2906
+ const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} /></label>`;
2907
+ const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
2908
+ const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
2909
+ ${consentBox("emailOptIn", "Email me product news and offers.")}
2910
+ ${consentBox("smsOptIn", "Text me updates by SMS. Message/data rates may apply.")}
2911
+ </div>` : "";
1968
2912
  const prechatHtml = `
1969
2913
  <div class="prechat">
1970
2914
  <div class="pc-head">
@@ -1972,12 +2916,14 @@ var SmoothAgentChat = (function(exports) {
1972
2916
  <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
1973
2917
  </div>
1974
2918
  <form class="pc-form" novalidate>
1975
- ${resolved.requireName ? field("name", "text", "Name", "name") : ""}
1976
- ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
1977
- ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""}
2919
+ ${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
2920
+ ${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
2921
+ ${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
2922
+ ${consentHtml}
1978
2923
  <button type="submit" class="pc-submit">Start chat</button>
1979
2924
  </form>
1980
2925
  </div>`;
2926
+ const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : "";
1981
2927
  const chatHtml = `
1982
2928
  <div class="messages"></div>
1983
2929
  <div class="interrupt hidden"></div>
@@ -1986,7 +2932,7 @@ var SmoothAgentChat = (function(exports) {
1986
2932
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
1987
2933
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
1988
2934
  </div>
1989
- <div class="footer">powered by <b>smooth&#8209;operator</b></div>
2935
+ <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
1990
2936
  </div>`;
1991
2937
  const container = document.createElement("div");
1992
2938
  container.innerHTML = `
@@ -2024,6 +2970,13 @@ var SmoothAgentChat = (function(exports) {
2024
2970
  ev.preventDefault();
2025
2971
  this.handlePrechatSubmit(pcForm);
2026
2972
  });
2973
+ container.querySelector(".restore-link")?.addEventListener("click", () => {
2974
+ (async () => {
2975
+ this.identityRestore = { phase: "awaiting_email" };
2976
+ this.renderInterrupt();
2977
+ await this.controller?.connect().catch(() => {});
2978
+ })();
2979
+ });
2027
2980
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
2028
2981
  this.syncOpenState();
2029
2982
  if (!gating) this.renderMessages();
@@ -2042,6 +2995,11 @@ var SmoothAgentChat = (function(exports) {
2042
2995
  el.replaceChildren();
2043
2996
  const it = this.interrupt;
2044
2997
  if (!it) {
2998
+ if (this.identityRestore.phase !== "idle") {
2999
+ el.classList.remove("hidden");
3000
+ el.appendChild(this.buildRestoreCard());
3001
+ return;
3002
+ }
2045
3003
  el.classList.add("hidden");
2046
3004
  return;
2047
3005
  }
@@ -2121,15 +3079,186 @@ var SmoothAgentChat = (function(exports) {
2121
3079
  }
2122
3080
  el.appendChild(card);
2123
3081
  }
2124
- /** Collect identity from the pre-chat form, then drop into the chat view. */
3082
+ /**
3083
+ * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
3084
+ * same overlay slot + visual language as the OTP interrupt. All server-supplied
3085
+ * strings (masked destination, conversation previews) are set via `textContent`
3086
+ * — never innerHTML — so they can't inject markup; only the static lock icon
3087
+ * uses innerHTML.
3088
+ */
3089
+ buildRestoreCard() {
3090
+ const state = this.identityRestore;
3091
+ const card = document.createElement("div");
3092
+ card.className = "int-card";
3093
+ const head = document.createElement("div");
3094
+ head.className = "int-head";
3095
+ const ico = document.createElement("span");
3096
+ ico.className = "int-ico";
3097
+ ico.innerHTML = ICON.lock;
3098
+ const title = document.createElement("span");
3099
+ title.className = "int-title";
3100
+ title.textContent = "Restore your chats";
3101
+ head.append(ico, title);
3102
+ card.appendChild(head);
3103
+ const close = document.createElement("button");
3104
+ close.className = "int-close";
3105
+ close.type = "button";
3106
+ close.setAttribute("aria-label", "Cancel");
3107
+ close.textContent = "×";
3108
+ close.addEventListener("click", () => {
3109
+ this.controller?.cancelIdentityRestore();
3110
+ this.identityRestore = { phase: "idle" };
3111
+ this.renderInterrupt();
3112
+ });
3113
+ card.appendChild(close);
3114
+ if (state.phase === "awaiting_email") {
3115
+ const desc = document.createElement("div");
3116
+ desc.className = "int-desc";
3117
+ desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
3118
+ card.appendChild(desc);
3119
+ const row = document.createElement("div");
3120
+ row.className = "int-row";
3121
+ const input = document.createElement("input");
3122
+ input.className = "int-input";
3123
+ input.type = "email";
3124
+ input.autocomplete = "email";
3125
+ input.placeholder = "you@example.com";
3126
+ const go = () => {
3127
+ const email = input.value.trim();
3128
+ if (email) this.controller?.requestIdentityOtp(email, "email");
3129
+ };
3130
+ input.addEventListener("keydown", (ev) => {
3131
+ if (ev.key === "Enter") {
3132
+ ev.preventDefault();
3133
+ go();
3134
+ }
3135
+ });
3136
+ const send = document.createElement("button");
3137
+ send.className = "int-btn primary";
3138
+ send.type = "button";
3139
+ send.textContent = "Send code";
3140
+ send.addEventListener("click", go);
3141
+ row.append(input, send);
3142
+ card.appendChild(row);
3143
+ if (state.error) {
3144
+ const err = document.createElement("div");
3145
+ err.className = "int-error";
3146
+ err.textContent = state.error;
3147
+ card.appendChild(err);
3148
+ }
3149
+ queueMicrotask(() => input.focus());
3150
+ } else if (state.phase === "requesting" || state.phase === "verifying" || state.phase === "resolving") {
3151
+ const msg = document.createElement("div");
3152
+ msg.className = "int-sent";
3153
+ msg.textContent = state.phase === "requesting" ? "Sending a code…" : state.phase === "verifying" ? "Verifying…" : "Finding your chats…";
3154
+ card.appendChild(msg);
3155
+ } else if (state.phase === "awaiting_code") {
3156
+ if (state.maskedDestination) {
3157
+ const sent = document.createElement("div");
3158
+ sent.className = "int-sent";
3159
+ sent.textContent = `Code sent to ${state.maskedDestination}.`;
3160
+ card.appendChild(sent);
3161
+ }
3162
+ const row = document.createElement("div");
3163
+ row.className = "int-row";
3164
+ const input = document.createElement("input");
3165
+ input.className = "int-input";
3166
+ input.type = "text";
3167
+ input.inputMode = "numeric";
3168
+ input.autocomplete = "one-time-code";
3169
+ input.placeholder = "Enter code";
3170
+ const submit = () => {
3171
+ const code = input.value.trim();
3172
+ if (code) this.controller?.verifyIdentityOtp(code);
3173
+ };
3174
+ input.addEventListener("keydown", (ev) => {
3175
+ if (ev.key === "Enter") {
3176
+ ev.preventDefault();
3177
+ submit();
3178
+ }
3179
+ });
3180
+ const verify = document.createElement("button");
3181
+ verify.className = "int-btn primary";
3182
+ verify.type = "button";
3183
+ verify.textContent = "Verify";
3184
+ verify.addEventListener("click", submit);
3185
+ row.append(input, verify);
3186
+ card.appendChild(row);
3187
+ if (state.error) {
3188
+ const err = document.createElement("div");
3189
+ err.className = "int-error";
3190
+ err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
3191
+ card.appendChild(err);
3192
+ }
3193
+ queueMicrotask(() => input.focus());
3194
+ } else if (state.phase === "resolved") if (state.conversations.length === 0) {
3195
+ const none = document.createElement("div");
3196
+ none.className = "int-desc";
3197
+ none.textContent = "No previous chats found for that email.";
3198
+ card.appendChild(none);
3199
+ } else {
3200
+ const pick = document.createElement("div");
3201
+ pick.className = "int-desc";
3202
+ pick.textContent = "Pick a conversation to continue:";
3203
+ card.appendChild(pick);
3204
+ const list = document.createElement("div");
3205
+ list.className = "restore-list";
3206
+ for (const conv of state.conversations) {
3207
+ const btn = document.createElement("button");
3208
+ btn.type = "button";
3209
+ btn.className = "restore-item";
3210
+ const preview = document.createElement("span");
3211
+ preview.className = "restore-preview";
3212
+ preview.textContent = conv.preview || "Conversation";
3213
+ btn.appendChild(preview);
3214
+ if (conv.lastActivityAt) {
3215
+ const when = document.createElement("span");
3216
+ when.className = "restore-when";
3217
+ when.textContent = this.formatWhen(conv.lastActivityAt);
3218
+ btn.appendChild(when);
3219
+ }
3220
+ btn.addEventListener("click", () => {
3221
+ this.controller?.restoreConversation(conv.sessionId);
3222
+ });
3223
+ list.appendChild(btn);
3224
+ }
3225
+ card.appendChild(list);
3226
+ }
3227
+ else if (state.phase === "error") {
3228
+ const err = document.createElement("div");
3229
+ err.className = "int-error";
3230
+ err.textContent = state.message;
3231
+ card.appendChild(err);
3232
+ }
3233
+ return card;
3234
+ }
3235
+ /** Format an ISO timestamp as a short, locale-aware label (best-effort). */
3236
+ formatWhen(iso) {
3237
+ const d = new Date(iso);
3238
+ if (Number.isNaN(d.getTime())) return "";
3239
+ try {
3240
+ return d.toLocaleDateString(void 0, {
3241
+ month: "short",
3242
+ day: "numeric"
3243
+ });
3244
+ } catch {
3245
+ return "";
3246
+ }
3247
+ }
3248
+ /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
2125
3249
  handlePrechatSubmit(form) {
2126
3250
  if (!form.reportValidity()) return;
2127
3251
  const data = new FormData(form);
2128
3252
  const val = (k) => data.get(k)?.trim() || void 0;
3253
+ const checked = (k) => data.get(k) === "on";
2129
3254
  this.controller?.setUserInfo({
2130
3255
  name: val("name"),
2131
3256
  email: val("email"),
2132
- phone: val("phone")
3257
+ phone: val("phone"),
3258
+ consent: {
3259
+ emailOptIn: checked("emailOptIn"),
3260
+ smsOptIn: checked("smsOptIn")
3261
+ }
2133
3262
  });
2134
3263
  this.userInfoSatisfied = true;
2135
3264
  this.render();