@ateam-ai-mobile/ui 0.1.7 → 0.1.8

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.
package/dist/index.mjs CHANGED
@@ -2664,6 +2664,7 @@ import {
2664
2664
  callDeviceTool,
2665
2665
  ensureDeviceToolsLoaded,
2666
2666
  listDeviceTools,
2667
+ hasDeviceTool,
2667
2668
  PLATFORM_VOICE_CALLER
2668
2669
  } from "@ateam-ai-mobile/plugin-runtime";
2669
2670
  var CONNECT_TIMEOUT_MS = 3e4;
@@ -2814,6 +2815,7 @@ var IDLE = {
2814
2815
  muted: false,
2815
2816
  userSpeaking: false,
2816
2817
  lastUserSpeechEndedAt: null,
2818
+ lastDeviceTool: null,
2817
2819
  mounted: false,
2818
2820
  webViewSource: null,
2819
2821
  skillSlug: void 0
@@ -2835,6 +2837,40 @@ var VoiceSessionManager = class {
2835
2837
  * conversation just because it declared voice.
2836
2838
  */
2837
2839
  this.owner = null;
2840
+ /** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
2841
+ * The session is not 'connecting' yet, so a stop() arriving now would otherwise
2842
+ * be an idle no-op — and a start would then mount a call for a screen that has
2843
+ * already gone. A set, not one slot: two concurrent starts must BOTH be
2844
+ * cancellable. stop() cancels every pending start of its caller (the platform:
2845
+ * all of them); another plugin's stop cancels none. */
2846
+ this.pendingStarts = /* @__PURE__ */ new Set();
2847
+ /**
2848
+ * The device-tool names Core ALLOWED for this voice session — Core's sanitized
2849
+ * allowset, delivered to the phone in a `deviceToolAllowset` message.
2850
+ * Authorization gates against THIS (necessary, alongside the registry's can-do).
2851
+ *
2852
+ * WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
2853
+ * (sendDeviceToolList); CORE is the session authority and decides which are
2854
+ * accepted (session.device_tools). Freezing the phone's own proposal would let
2855
+ * a tool Core REJECTED still run locally. So the authz set is populated from
2856
+ * Core's accepted set, never from listDeviceTools(). Until Core's set arrives
2857
+ * this is null and every call FAILS CLOSED. Pinned once per session; the live
2858
+ * registry (which drifts via lazy loads / host registration / tenant changes)
2859
+ * never feeds it.
2860
+ */
2861
+ this.frozenTools = null;
2862
+ /** The voice `sessionId` the current allowset is BOUND to. Authorization
2863
+ * requires it to equal the active session's id (state.sessionId), so a stale
2864
+ * allowset from a previous/other session is refused. */
2865
+ this.frozenSessionId = null;
2866
+ /** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
2867
+ * plugin's own device tools — not other solutions' tools that happen to be
2868
+ * registered on the same phone. null = every registered tool (a normal call,
2869
+ * or the platform's). Set by every start(); like `owner`, kept after stop.
2870
+ * ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
2871
+ * and the bundleManager registers a plugin's tools under that same id (never
2872
+ * the bundle's own short `id`), so exact equality is the whole check. */
2873
+ this.toolScope = null;
2838
2874
  this.injectedTrackHook = INJECT_TRACK_HOOK;
2839
2875
  }
2840
2876
  /** The platform (built-in sheet, mic indicator, session host) sees and
@@ -2870,6 +2906,11 @@ var VoiceSessionManager = class {
2870
2906
  if (!this.canAccess(caller)) throw this.foreignError();
2871
2907
  return this.waitForConnect();
2872
2908
  }
2909
+ if (opts?.skills === "none" && opts?.skillSlug) {
2910
+ throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
2911
+ }
2912
+ const ticket = { caller, cancelled: false };
2913
+ this.pendingStarts.add(ticket);
2873
2914
  try {
2874
2915
  await ensureDeviceToolsLoaded();
2875
2916
  } catch (e) {
@@ -2879,7 +2920,21 @@ var VoiceSessionManager = class {
2879
2920
  if (tokenNeedsRefresh(getToken2())) await refreshToken();
2880
2921
  } catch {
2881
2922
  }
2923
+ this.pendingStarts.delete(ticket);
2924
+ if (ticket.cancelled) throw new Error("voice: stopped before the session started");
2925
+ const after = this.getInternal().callState;
2926
+ if (after === "connected") {
2927
+ if (!this.canAccess(caller)) throw this.foreignError();
2928
+ return this.realSnapshot();
2929
+ }
2930
+ if (after === "connecting") {
2931
+ if (!this.canAccess(caller)) throw this.foreignError();
2932
+ return this.waitForConnect();
2933
+ }
2882
2934
  this.owner = caller;
2935
+ this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
2936
+ this.frozenTools = null;
2937
+ this.frozenSessionId = null;
2883
2938
  const skillSlug = opts?.skillSlug;
2884
2939
  this.setState({
2885
2940
  callState: "connecting",
@@ -2890,14 +2945,23 @@ var VoiceSessionManager = class {
2890
2945
  muted: false,
2891
2946
  mounted: true,
2892
2947
  skillSlug,
2893
- webViewSource: { uri: this.buildUri(skillSlug) }
2948
+ persona: opts?.persona,
2949
+ webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
2894
2950
  });
2895
2951
  return this.waitForConnect();
2896
2952
  }
2897
2953
  stop(caller = PLATFORM_VOICE_CALLER) {
2898
2954
  if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
2955
+ for (const t of [...this.pendingStarts]) {
2956
+ if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
2957
+ t.cancelled = true;
2958
+ this.pendingStarts.delete(t);
2959
+ }
2960
+ }
2899
2961
  this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
2900
2962
  this.rejectPending(new Error("voice: session stopped before it connected"));
2963
+ this.frozenTools = null;
2964
+ this.frozenSessionId = null;
2901
2965
  this.setState({
2902
2966
  ...IDLE,
2903
2967
  // keep the transcript of the call that just ended for any reader still up
@@ -2918,10 +2982,9 @@ var VoiceSessionManager = class {
2918
2982
  }
2919
2983
  /**
2920
2984
  * Stream a silent observation into the live session — tell the model without
2921
- * asking it to speak. The cheap, high-frequency channel (safe once or twice a
2922
- * second) behind a future props.voice.observe(); today the host uses it to run
2923
- * the real-device stream proof (VOICE_RT_STREAM_DEVICE_PROOF) before the gated
2924
- * plugin API is added. Owner-scoped like every other control.
2985
+ * asking it to speak. The cheap channel behind the gated props.voice.observe();
2986
+ * every item stays in the model's context for the call, so a caller sends only
2987
+ * material changes. Owner-scoped like every other control.
2925
2988
  */
2926
2989
  observe(text, caller = PLATFORM_VOICE_CALLER) {
2927
2990
  if (this.state.callState !== "connected") {
@@ -2943,6 +3006,8 @@ var VoiceSessionManager = class {
2943
3006
  listening: !!s.userPartial,
2944
3007
  userSpeaking: s.userSpeaking,
2945
3008
  lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
3009
+ lastDeviceTool: s.lastDeviceTool,
3010
+ authorizedSessionId: this.frozenSessionId,
2946
3011
  lastError: s.error
2947
3012
  };
2948
3013
  }
@@ -2955,6 +3020,8 @@ var VoiceSessionManager = class {
2955
3020
  listening: false,
2956
3021
  userSpeaking: false,
2957
3022
  lastUserSpeechEndedAt: null,
3023
+ lastDeviceTool: null,
3024
+ authorizedSessionId: null,
2958
3025
  lastError: null
2959
3026
  };
2960
3027
  }
@@ -2971,12 +3038,15 @@ var VoiceSessionManager = class {
2971
3038
  return this.canAccess(caller) ? this.state.transcript.slice() : [];
2972
3039
  }
2973
3040
  // ── WebView plumbing ─────────────────────────────────────────
2974
- buildUri(skillSlug) {
3041
+ buildUri(skillSlug, persona, skills) {
2975
3042
  const token = getToken2();
2976
3043
  const tenant = getTenant5();
2977
3044
  const actorId = getUser2()?.actorId || "";
2978
3045
  const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
2979
- return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`;
3046
+ const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
3047
+ const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
3048
+ const skillsParam = skills === "none" ? "&skills=none" : "";
3049
+ return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
2980
3050
  }
2981
3051
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
2982
3052
  handleMessage(event) {
@@ -3040,6 +3110,27 @@ var VoiceSessionManager = class {
3040
3110
  this.sendDeviceToolList(data.correlationId);
3041
3111
  break;
3042
3112
  }
3113
+ // CORE's ALLOWSET for this session — what the model may ASK for. Core sends
3114
+ // it (even empty, on purpose) at session start. It is a NECESSARY gate, not
3115
+ // the whole story: a call must be in the allowset AND the device must
3116
+ // actually have the tool (the registry check inside callDeviceTool) — Core
3117
+ // says what may be asked, the device decides what it will do.
3118
+ //
3119
+ // Origin-checked (only the voice app may set it) and pinned ONCE per session
3120
+ // (a later message cannot widen it). An EMPTY allowset (arrived) refuses
3121
+ // everything but is distinct from NEVER-arrived (frozenTools stays null);
3122
+ // both fail closed here — we never fall back to the phone's own registry,
3123
+ // which is the seam this closes.
3124
+ case "deviceToolAllowset": {
3125
+ const sender = originOf(event?.nativeEvent?.url);
3126
+ const expected = originOf(getVoiceApiUrl2());
3127
+ if (!sender || !expected || sender !== expected) break;
3128
+ if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
3129
+ this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
3130
+ this.frozenSessionId = data.sessionId;
3131
+ }
3132
+ break;
3133
+ }
3043
3134
  case "device-tool-call": {
3044
3135
  const sender = originOf(event?.nativeEvent?.url);
3045
3136
  const expected = originOf(getVoiceApiUrl2());
@@ -3047,14 +3138,64 @@ var VoiceSessionManager = class {
3047
3138
  console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
3048
3139
  break;
3049
3140
  }
3141
+ if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
3142
+ if (!this.isToolAuthorized(data.name)) {
3143
+ const notReady = this.frozenTools === null;
3144
+ console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3145
+ this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
3146
+ break;
3147
+ }
3050
3148
  void this.runDeviceTool(data);
3051
3149
  break;
3052
3150
  }
3053
3151
  }
3054
3152
  }
3055
- /** Answer "what device tools do you have?" — the client is the authority. */
3153
+ /**
3154
+ * Final authorization = Core's frozen allowset ∩ the current valid local
3155
+ * registry, bound to the ACTIVE session. All must hold:
3156
+ * - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
3157
+ * the active state.sessionId) — a missing or stale/wrong-session allowset
3158
+ * fails closed;
3159
+ * - the tool is IN that allowset (Core approved it); AND
3160
+ * - the tool is STILL registered locally right now (removed-after-approval is
3161
+ * refused; a lazily-added tool Core never approved is refused) — and, on a
3162
+ * clean call, registered by the call's owner.
3163
+ * No fallback to the live registry alone, ever.
3164
+ */
3165
+ isToolAuthorized(name) {
3166
+ if (typeof name !== "string") return false;
3167
+ if (this.frozenTools === null || this.frozenSessionId === null) return false;
3168
+ if (this.frozenSessionId !== this.state.sessionId) return false;
3169
+ if (!this.frozenTools.has(name)) return false;
3170
+ return hasDeviceTool(name, this.toolScope ?? void 0);
3171
+ }
3172
+ /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
3173
+ * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
3174
+ * final "not allowed in this session". */
3175
+ postDeviceToolRefusal(correlationId, name, notReady) {
3176
+ const payload = notReady ? {
3177
+ correlationId,
3178
+ ok: false,
3179
+ reason: "not_ready",
3180
+ error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3181
+ } : {
3182
+ correlationId,
3183
+ ok: false,
3184
+ reason: "not_authorized",
3185
+ error: `"${name}" is not available in this voice session.`
3186
+ };
3187
+ this.webView?.injectJavaScript?.(
3188
+ `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3189
+ JSON.stringify(payload)
3190
+ )}); true;`
3191
+ );
3192
+ }
3193
+ /** PROPOSE the phone's available tools — this is an offer to Core, not the
3194
+ * authorization set. Core sanitizes it and returns the accepted set in a
3195
+ * `deviceToolAllowset` message, which is what actually gates execution. */
3056
3196
  sendDeviceToolList(correlationId) {
3057
- const tools = listDeviceTools().map((t) => ({
3197
+ const scope = this.toolScope;
3198
+ const tools = listDeviceTools().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
3058
3199
  name: t.name,
3059
3200
  description: t.description,
3060
3201
  input_schema: t.input_schema
@@ -3132,6 +3273,10 @@ var VoiceSessionManager = class {
3132
3273
  this.rejectPending(new Error("voice: reset"));
3133
3274
  this.state = { ...IDLE };
3134
3275
  this.owner = null;
3276
+ this.toolScope = null;
3277
+ this.pendingStarts.clear();
3278
+ this.frozenTools = null;
3279
+ this.frozenSessionId = null;
3135
3280
  this.listeners.clear();
3136
3281
  this.webView = null;
3137
3282
  }