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