@ateam-ai-mobile/ui 0.1.7 → 0.1.9

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;
@@ -2799,6 +2800,11 @@ var buildObserveScript = (text) => `
2799
2800
  }
2800
2801
  })(${JSON.stringify(text)}); true;
2801
2802
  `;
2803
+ var DEVICE_TOOL_ERROR_MAX = 200;
2804
+ function clipError(message) {
2805
+ const chars = Array.from(message);
2806
+ return chars.length > DEVICE_TOOL_ERROR_MAX ? chars.slice(0, DEVICE_TOOL_ERROR_MAX - 1).join("") + "\u2026" : message;
2807
+ }
2802
2808
  function originOf(url) {
2803
2809
  if (!url) return null;
2804
2810
  const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url);
@@ -2814,6 +2820,7 @@ var IDLE = {
2814
2820
  muted: false,
2815
2821
  userSpeaking: false,
2816
2822
  lastUserSpeechEndedAt: null,
2823
+ deviceToolCalls: [],
2817
2824
  mounted: false,
2818
2825
  webViewSource: null,
2819
2826
  skillSlug: void 0
@@ -2835,6 +2842,45 @@ var VoiceSessionManager = class {
2835
2842
  * conversation just because it declared voice.
2836
2843
  */
2837
2844
  this.owner = null;
2845
+ /** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
2846
+ * The session is not 'connecting' yet, so a stop() arriving now would otherwise
2847
+ * be an idle no-op — and a start would then mount a call for a screen that has
2848
+ * already gone. A set, not one slot: two concurrent starts must BOTH be
2849
+ * cancellable. stop() cancels every pending start of its caller (the platform:
2850
+ * all of them); another plugin's stop cancels none. */
2851
+ this.pendingStarts = /* @__PURE__ */ new Set();
2852
+ /**
2853
+ * The device-tool names Core ALLOWED for this voice session — Core's sanitized
2854
+ * allowset, delivered to the phone in a `deviceToolAllowset` message.
2855
+ * Authorization gates against THIS (necessary, alongside the registry's can-do).
2856
+ *
2857
+ * WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
2858
+ * (sendDeviceToolList); CORE is the session authority and decides which are
2859
+ * accepted (session.device_tools). Freezing the phone's own proposal would let
2860
+ * a tool Core REJECTED still run locally. So the authz set is populated from
2861
+ * Core's accepted set, never from listDeviceTools(). Until Core's set arrives
2862
+ * this is null and every call FAILS CLOSED. Pinned once per session; the live
2863
+ * registry (which drifts via lazy loads / host registration / tenant changes)
2864
+ * never feeds it.
2865
+ */
2866
+ this.frozenTools = null;
2867
+ /** The voice `sessionId` the current allowset is BOUND to. Authorization
2868
+ * requires it to equal the active session's id (state.sessionId), so a stale
2869
+ * allowset from a previous/other session is refused. */
2870
+ this.frozenSessionId = null;
2871
+ /** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
2872
+ * plugin's own device tools — not other solutions' tools that happen to be
2873
+ * registered on the same phone. null = every registered tool (a normal call,
2874
+ * or the platform's). Set by every start(); like `owner`, kept after stop.
2875
+ * ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
2876
+ * and the bundleManager registers a plugin's tools under that same id (never
2877
+ * the bundle's own short `id`), so exact equality is the whole check. */
2878
+ this.toolScope = null;
2879
+ /** Bumped whenever a session begins or ends. A device-tool call remembers the
2880
+ * epoch it arrived in, and its outcome is recorded only if that session is
2881
+ * still the current one — a tool that finishes after hang-up (or after a new
2882
+ * call started) must not show up as the new session's last call. */
2883
+ this.sessionEpoch = 0;
2838
2884
  this.injectedTrackHook = INJECT_TRACK_HOOK;
2839
2885
  }
2840
2886
  /** The platform (built-in sheet, mic indicator, session host) sees and
@@ -2864,12 +2910,17 @@ var VoiceSessionManager = class {
2864
2910
  async start(opts, caller = PLATFORM_VOICE_CALLER) {
2865
2911
  if (this.state.callState === "connected") {
2866
2912
  if (!this.canAccess(caller)) throw this.foreignError();
2867
- return this.realSnapshot();
2913
+ return this.realSnapshot(caller);
2868
2914
  }
2869
2915
  if (this.state.callState === "connecting") {
2870
2916
  if (!this.canAccess(caller)) throw this.foreignError();
2871
- return this.waitForConnect();
2917
+ return this.waitForConnect(caller);
2918
+ }
2919
+ if (opts?.skills === "none" && opts?.skillSlug) {
2920
+ throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
2872
2921
  }
2922
+ const ticket = { caller, cancelled: false };
2923
+ this.pendingStarts.add(ticket);
2873
2924
  try {
2874
2925
  await ensureDeviceToolsLoaded();
2875
2926
  } catch (e) {
@@ -2879,7 +2930,22 @@ var VoiceSessionManager = class {
2879
2930
  if (tokenNeedsRefresh(getToken2())) await refreshToken();
2880
2931
  } catch {
2881
2932
  }
2933
+ this.pendingStarts.delete(ticket);
2934
+ if (ticket.cancelled) throw new Error("voice: stopped before the session started");
2935
+ const after = this.getInternal().callState;
2936
+ if (after === "connected") {
2937
+ if (!this.canAccess(caller)) throw this.foreignError();
2938
+ return this.realSnapshot(caller);
2939
+ }
2940
+ if (after === "connecting") {
2941
+ if (!this.canAccess(caller)) throw this.foreignError();
2942
+ return this.waitForConnect(caller);
2943
+ }
2882
2944
  this.owner = caller;
2945
+ this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
2946
+ this.frozenTools = null;
2947
+ this.frozenSessionId = null;
2948
+ this.sessionEpoch++;
2883
2949
  const skillSlug = opts?.skillSlug;
2884
2950
  this.setState({
2885
2951
  callState: "connecting",
@@ -2888,16 +2954,29 @@ var VoiceSessionManager = class {
2888
2954
  aiPartial: "",
2889
2955
  userPartial: "",
2890
2956
  muted: false,
2957
+ // A session that ended from inside the voice page (not via stop()) left
2958
+ // its calls here; the new session starts with none.
2959
+ deviceToolCalls: [],
2891
2960
  mounted: true,
2892
2961
  skillSlug,
2893
- webViewSource: { uri: this.buildUri(skillSlug) }
2962
+ persona: opts?.persona,
2963
+ webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
2894
2964
  });
2895
- return this.waitForConnect();
2965
+ return this.waitForConnect(caller);
2896
2966
  }
2897
2967
  stop(caller = PLATFORM_VOICE_CALLER) {
2898
2968
  if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
2969
+ for (const t of [...this.pendingStarts]) {
2970
+ if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
2971
+ t.cancelled = true;
2972
+ this.pendingStarts.delete(t);
2973
+ }
2974
+ }
2899
2975
  this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
2900
2976
  this.rejectPending(new Error("voice: session stopped before it connected"));
2977
+ this.frozenTools = null;
2978
+ this.frozenSessionId = null;
2979
+ this.sessionEpoch++;
2901
2980
  this.setState({
2902
2981
  ...IDLE,
2903
2982
  // keep the transcript of the call that just ended for any reader still up
@@ -2918,10 +2997,9 @@ var VoiceSessionManager = class {
2918
2997
  }
2919
2998
  /**
2920
2999
  * 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.
3000
+ * asking it to speak. The cheap channel behind the gated props.voice.observe();
3001
+ * every item stays in the model's context for the call, so a caller sends only
3002
+ * material changes. Owner-scoped like every other control.
2925
3003
  */
2926
3004
  observe(text, caller = PLATFORM_VOICE_CALLER) {
2927
3005
  if (this.state.callState !== "connected") {
@@ -2931,9 +3009,11 @@ var VoiceSessionManager = class {
2931
3009
  this.webView?.injectJavaScript?.(buildObserveScript(text));
2932
3010
  }
2933
3011
  getSnapshot(caller = PLATFORM_VOICE_CALLER) {
2934
- return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
3012
+ return this.canAccess(caller) ? this.realSnapshot(caller) : this.foreignSnapshot();
2935
3013
  }
2936
- realSnapshot() {
3014
+ /** The snapshot for a caller that may access the session. Everything is the
3015
+ * session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
3016
+ realSnapshot(caller) {
2937
3017
  const s = this.state;
2938
3018
  return {
2939
3019
  state: s.callState,
@@ -2943,9 +3023,23 @@ var VoiceSessionManager = class {
2943
3023
  listening: !!s.userPartial,
2944
3024
  userSpeaking: s.userSpeaking,
2945
3025
  lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
3026
+ lastDeviceTool: this.lastDeviceToolFor(caller),
3027
+ authorizedSessionId: this.frozenSessionId,
2946
3028
  lastError: s.error
2947
3029
  };
2948
3030
  }
3031
+ /**
3032
+ * The last finished device-tool call THIS caller may see. The platform (the
3033
+ * user's own surface) sees every call. A plugin sees only calls to tools it
3034
+ * registered — even on a session it owns, the model can call another
3035
+ * solution's tool (a normal call offers every registered tool), and that
3036
+ * tool's name and error are not this plugin's to read.
3037
+ */
3038
+ lastDeviceToolFor(caller) {
3039
+ const calls = this.state.deviceToolCalls;
3040
+ if (caller === PLATFORM_VOICE_CALLER) return calls.length ? calls[calls.length - 1].call : null;
3041
+ return calls.find((r) => r.owner === caller)?.call ?? null;
3042
+ }
2949
3043
  foreignSnapshot() {
2950
3044
  return {
2951
3045
  state: "idle",
@@ -2955,6 +3049,8 @@ var VoiceSessionManager = class {
2955
3049
  listening: false,
2956
3050
  userSpeaking: false,
2957
3051
  lastUserSpeechEndedAt: null,
3052
+ lastDeviceTool: null,
3053
+ authorizedSessionId: null,
2958
3054
  lastError: null
2959
3055
  };
2960
3056
  }
@@ -2971,12 +3067,15 @@ var VoiceSessionManager = class {
2971
3067
  return this.canAccess(caller) ? this.state.transcript.slice() : [];
2972
3068
  }
2973
3069
  // ── WebView plumbing ─────────────────────────────────────────
2974
- buildUri(skillSlug) {
3070
+ buildUri(skillSlug, persona, skills) {
2975
3071
  const token = getToken2();
2976
3072
  const tenant = getTenant5();
2977
3073
  const actorId = getUser2()?.actorId || "";
2978
3074
  const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
2979
- return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`;
3075
+ const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
3076
+ const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
3077
+ const skillsParam = skills === "none" ? "&skills=none" : "";
3078
+ return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
2980
3079
  }
2981
3080
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
2982
3081
  handleMessage(event) {
@@ -3040,6 +3139,27 @@ var VoiceSessionManager = class {
3040
3139
  this.sendDeviceToolList(data.correlationId);
3041
3140
  break;
3042
3141
  }
3142
+ // CORE's ALLOWSET for this session — what the model may ASK for. Core sends
3143
+ // it (even empty, on purpose) at session start. It is a NECESSARY gate, not
3144
+ // the whole story: a call must be in the allowset AND the device must
3145
+ // actually have the tool (the registry check inside callDeviceTool) — Core
3146
+ // says what may be asked, the device decides what it will do.
3147
+ //
3148
+ // Origin-checked (only the voice app may set it) and pinned ONCE per session
3149
+ // (a later message cannot widen it). An EMPTY allowset (arrived) refuses
3150
+ // everything but is distinct from NEVER-arrived (frozenTools stays null);
3151
+ // both fail closed here — we never fall back to the phone's own registry,
3152
+ // which is the seam this closes.
3153
+ case "deviceToolAllowset": {
3154
+ const sender = originOf(event?.nativeEvent?.url);
3155
+ const expected = originOf(getVoiceApiUrl2());
3156
+ if (!sender || !expected || sender !== expected) break;
3157
+ if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
3158
+ this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
3159
+ this.frozenSessionId = data.sessionId;
3160
+ }
3161
+ break;
3162
+ }
3043
3163
  case "device-tool-call": {
3044
3164
  const sender = originOf(event?.nativeEvent?.url);
3045
3165
  const expected = originOf(getVoiceApiUrl2());
@@ -3047,40 +3167,100 @@ var VoiceSessionManager = class {
3047
3167
  console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
3048
3168
  break;
3049
3169
  }
3050
- void this.runDeviceTool(data);
3170
+ void this.handleDeviceToolCall(data);
3051
3171
  break;
3052
3172
  }
3053
3173
  }
3054
3174
  }
3055
- /** Answer "what device tools do you have?" — the client is the authority. */
3175
+ /**
3176
+ * One device-tool call, arrival to outcome: validate → authorize → run. The
3177
+ * answer goes back to the mini-app (the model reads it) AND becomes
3178
+ * `lastDeviceTool` — the same object, recorded only once it is known, so a
3179
+ * refused call never looks like one that ran.
3180
+ */
3181
+ async handleDeviceToolCall(data) {
3182
+ const { correlationId, name, args, timeoutMs } = data || {};
3183
+ const at = Date.now();
3184
+ const epoch = this.sessionEpoch;
3185
+ const toolName = typeof name === "string" ? name : "";
3186
+ const sessionOwner = this.owner;
3187
+ const owner = typeof sessionOwner === "string" && toolName && hasDeviceTool(toolName, sessionOwner) ? sessionOwner : null;
3188
+ let answer;
3189
+ if (!toolName) {
3190
+ answer = { ok: false, reason: "invalid", error: "The device tool call named no tool." };
3191
+ } else if (!correlationId) {
3192
+ answer = {
3193
+ ok: false,
3194
+ reason: "invalid",
3195
+ error: `The call to "${toolName}" carried no correlationId, so it could not be answered.`
3196
+ };
3197
+ } else if (!this.isToolAuthorized(toolName)) {
3198
+ const notReady = this.frozenTools === null;
3199
+ console.warn(`[voiceSession] REFUSED "${toolName}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3200
+ answer = notReady ? {
3201
+ ok: false,
3202
+ reason: "not_ready",
3203
+ error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3204
+ } : { ok: false, reason: "not_authorized", error: `"${toolName}" is not available in this voice session.` };
3205
+ } else {
3206
+ answer = await this.runDeviceTool(toolName, args, timeoutMs);
3207
+ }
3208
+ if (correlationId) this.postDeviceToolResult({ correlationId, ...answer });
3209
+ if (epoch !== this.sessionEpoch) return;
3210
+ const ms = Math.max(0, Date.now() - at);
3211
+ const call = answer.ok ? { name: toolName, at, ms, ok: true, reason: null, error: null } : { name: toolName, at, ms, ok: false, reason: answer.reason, error: clipError(answer.error) };
3212
+ this.setState({
3213
+ deviceToolCalls: [...this.state.deviceToolCalls.filter((r) => r.owner !== owner), { owner, call }]
3214
+ });
3215
+ }
3216
+ /**
3217
+ * Final authorization = Core's frozen allowset ∩ the current valid local
3218
+ * registry, bound to the ACTIVE session. All must hold:
3219
+ * - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
3220
+ * the active state.sessionId) — a missing or stale/wrong-session allowset
3221
+ * fails closed;
3222
+ * - the tool is IN that allowset (Core approved it); AND
3223
+ * - the tool is STILL registered locally right now (removed-after-approval is
3224
+ * refused; a lazily-added tool Core never approved is refused) — and, on a
3225
+ * clean call, registered by the call's owner.
3226
+ * No fallback to the live registry alone, ever.
3227
+ */
3228
+ isToolAuthorized(name) {
3229
+ if (typeof name !== "string") return false;
3230
+ if (this.frozenTools === null || this.frozenSessionId === null) return false;
3231
+ if (this.frozenSessionId !== this.state.sessionId) return false;
3232
+ if (!this.frozenTools.has(name)) return false;
3233
+ return hasDeviceTool(name, this.toolScope ?? void 0);
3234
+ }
3235
+ /** Answer the mini-app's pending device-tool request (a call's outcome, or the
3236
+ * tool list) — it resolves the request with this payload by correlationId. */
3237
+ postDeviceToolResult(payload) {
3238
+ this.webView?.injectJavaScript?.(
3239
+ `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3240
+ JSON.stringify(payload)
3241
+ )}); true;`
3242
+ );
3243
+ }
3244
+ /** PROPOSE the phone's available tools — this is an offer to Core, not the
3245
+ * authorization set. Core sanitizes it and returns the accepted set in a
3246
+ * `deviceToolAllowset` message, which is what actually gates execution. */
3056
3247
  sendDeviceToolList(correlationId) {
3057
- const tools = listDeviceTools().map((t) => ({
3248
+ const scope = this.toolScope;
3249
+ const tools = listDeviceTools().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
3058
3250
  name: t.name,
3059
3251
  description: t.description,
3060
3252
  input_schema: t.input_schema
3061
3253
  }));
3062
- this.webView?.injectJavaScript?.(
3063
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3064
- JSON.stringify({ correlationId, ok: true, tools })
3065
- )}); true;`
3066
- );
3254
+ this.postDeviceToolResult({ correlationId, ok: true, tools });
3067
3255
  }
3068
- /** Run a device tool and post the outcome back into the WebView. */
3069
- async runDeviceTool(data) {
3070
- const { correlationId, name, args, timeoutMs } = data || {};
3071
- if (!correlationId || typeof name !== "string") return;
3072
- let payload;
3256
+ /** Run an authorized device tool. Never throws: every failure is an answer. */
3257
+ async runDeviceTool(name, args, timeoutMs) {
3073
3258
  try {
3074
3259
  const outcome = await callDeviceTool(name, args || {}, { timeoutMs });
3075
- payload = outcome.ok ? { correlationId, ok: true, result: outcome.result } : { correlationId, ok: false, error: outcome.error, reason: outcome.reason };
3260
+ return outcome.ok ? { ok: true, result: outcome.result } : { ok: false, reason: outcome.reason, error: outcome.error };
3076
3261
  } catch (err) {
3077
- payload = { correlationId, ok: false, reason: "failed", error: err?.message || String(err) };
3262
+ return { ok: false, reason: "failed", error: err?.message || String(err) };
3078
3263
  }
3079
- this.webView?.injectJavaScript?.(
3080
- `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3081
- JSON.stringify(payload)
3082
- )}); true;`
3083
- );
3084
3264
  }
3085
3265
  // ── internals ────────────────────────────────────────────────
3086
3266
  setState(partial) {
@@ -3100,22 +3280,21 @@ var VoiceSessionManager = class {
3100
3280
  }
3101
3281
  }
3102
3282
  }
3103
- waitForConnect() {
3283
+ waitForConnect(caller) {
3104
3284
  return new Promise((resolve, reject) => {
3105
3285
  const timer = setTimeout(() => {
3106
3286
  this.pending = this.pending.filter((p) => p.timer !== timer);
3107
3287
  reject(new Error("voice: timed out waiting to connect (is VoiceSessionHost mounted?)"));
3108
3288
  }, CONNECT_TIMEOUT_MS);
3109
- this.pending.push({ resolve, reject, timer });
3289
+ this.pending.push({ caller, resolve, reject, timer });
3110
3290
  });
3111
3291
  }
3112
3292
  resolvePending() {
3113
- const snap = this.realSnapshot();
3114
3293
  const waiting = this.pending;
3115
3294
  this.pending = [];
3116
3295
  for (const p of waiting) {
3117
3296
  clearTimeout(p.timer);
3118
- p.resolve(snap);
3297
+ p.resolve(this.realSnapshot(p.caller));
3119
3298
  }
3120
3299
  }
3121
3300
  rejectPending(err) {
@@ -3132,6 +3311,11 @@ var VoiceSessionManager = class {
3132
3311
  this.rejectPending(new Error("voice: reset"));
3133
3312
  this.state = { ...IDLE };
3134
3313
  this.owner = null;
3314
+ this.toolScope = null;
3315
+ this.pendingStarts.clear();
3316
+ this.frozenTools = null;
3317
+ this.frozenSessionId = null;
3318
+ this.sessionEpoch++;
3135
3319
  this.listeners.clear();
3136
3320
  this.webView = null;
3137
3321
  }