@ateam-ai-mobile/plugin-runtime 0.1.20 → 0.1.22

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.
@@ -16,9 +16,11 @@
16
16
  import { getPluginNative, gatePluginNative } from './host';
17
17
  import { gateDeviceState } from './deviceStateHost';
18
18
  import { gateVoice } from './voiceHost';
19
+ import { gateAiLive, aiLiveCameraHolder } from './aiLiveHost';
19
20
  import type { PluginNative, NativeCapabilities } from './sdk/types';
20
21
  import type { DeviceState, DeviceBridgeDomain } from './sdk/device-state';
21
22
  import type { VoiceAPI } from './sdk/voice';
23
+ import type { AiLiveAPI } from './sdk/aiLive';
22
24
 
23
25
  /** The gated surface a plugin Component receives. `device` and `native` are the
24
26
  * SAME object (native is the deprecated alias). */
@@ -27,6 +29,8 @@ export interface PluginRuntimeProps {
27
29
  native: PluginNative;
28
30
  deviceState: DeviceState;
29
31
  voice: VoiceAPI;
32
+ /** The workspace's chosen AI watching the phone camera, live (`picturesToAi`). */
33
+ aiLive: AiLiveAPI;
30
34
  }
31
35
 
32
36
  /** One declaration source — either the server manifest or the registry entry. */
@@ -105,6 +109,11 @@ export function resolvePluginRuntimeProps(src: PluginRuntimeSource, holder: stri
105
109
  // session is isolated per solution: declaring voice runs YOUR conversation, it
106
110
  // does not attach to another plugin's live one.
107
111
  const voice = gateVoice(!!(capabilities as any)?.voice, src.pluginId);
112
+ // Pictures to the chosen AI: gated by the TRUSTED `picturesToAi` declaration,
113
+ // bound to this surface's holder so closing the screen stops it. The camera its
114
+ // sender reads is the SDK's own hold (not a domain the plugin declared, and never
115
+ // handed to the plugin), built here because this is where device gates are called.
116
+ const aiLive = gateAiLive(capabilities, src.pluginId, holder, () => gateDeviceState(['visual'], aiLiveCameraHolder(holder)).visual);
108
117
  // device and native are intentionally the SAME gated object (R4 rename).
109
- return { device: native, native, deviceState, voice };
118
+ return { device: native, native, deviceState, voice, aiLive };
110
119
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * props.aiLive — the workspace's CHOSEN AI watching this phone's camera, live.
3
+ *
4
+ * The workspace admin picks and tests a vision setup in AI Models ("Drinking", a
5
+ * hosted model, the classes it must notice). A screen that wants that AI to watch
6
+ * the phone camera calls start(); the SDK then sends pictures to the workspace's
7
+ * live session at the rate and size the setup was TESTED at, and hands the screen
8
+ * what the AI saw through onEpisode ("drinking" started / ended). The screen never
9
+ * touches a picture: it gets events, not pixels.
10
+ *
11
+ * Two people must say yes before a picture leaves the phone (owner decision
12
+ * 2026-10-25): the solution declares `native: { picturesToAi: true }`, AND the
13
+ * person on the phone allowed "Camera pictures to AI" in the app's settings. Both
14
+ * are asked at start() AND for as long as it sends: taking the yes back (the
15
+ * switch, the camera's own switch, the OS permission) stops it, and no picture is
16
+ * sent after that (GPT review 2026-10-30). Owner decision 2026-10-29: only while
17
+ * the screen that asked is open, and with "Sending to AI" on screen — the
18
+ * indicator is the PLATFORM's (AiLiveIndicator in @ateam-ai-mobile/ui), a plugin
19
+ * cannot hide it, and closing the screen stops it.
20
+ *
21
+ * Each phone session is the phone's OWN live session, capped at US$0.50 by Core,
22
+ * and it is one connection: start() opening it starts the session, and its end
23
+ * (stop(), the screen closing, the app gone, the network lost) ends it. Live
24
+ * sessions are per person (workspace + actor): other people's never get in the
25
+ * way, and when the person already has one running (started on the AI Models
26
+ * page or on another device), start() refuses rather than join it.
27
+ */
28
+ import type { Subscription } from './device-state';
29
+
30
+ export type AiLiveCamera = 'front' | 'back';
31
+
32
+ /** What the AI saw over time, as the live session's episode events. */
33
+ export interface AiLiveEpisode {
34
+ /** episode.started / .interrupted / .recovered / .ongoing / .ended */
35
+ type: string;
36
+ /** The task class id from the chosen setup, e.g. "drinking". */
37
+ class: string;
38
+ episodeId: string;
39
+ /** Whose camera saw it: the signed-in person whose live session this is (live sessions are per
40
+ * person, workspace + actor). Always there: an event without it never reaches the screen. */
41
+ actorId: string;
42
+ /** When (ms since the epoch, the session's clock). */
43
+ at: number;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ export type AiLiveState =
48
+ /** Nothing running for this screen. */
49
+ | 'idle'
50
+ /** Asking for the live session, opening the camera and the connection. */
51
+ | 'starting'
52
+ /** Pictures are going to the AI ("Sending to AI" is on screen). */
53
+ | 'sending'
54
+ /** Stopped by stop(), by the screen closing, or by the person taking the yes back (lastError says so). */
55
+ | 'stopped'
56
+ /** It could not start or it broke; `lastError` says why in plain words. */
57
+ | 'error';
58
+
59
+ export interface AiLiveSnapshot {
60
+ state: AiLiveState;
61
+ camera: AiLiveCamera | null;
62
+ /** Pictures sent since start. */
63
+ sent: number;
64
+ /** Picture slots that sent nothing (no fresh camera frame, or the connection was busy). */
65
+ skipped: number;
66
+ lastEpisode: AiLiveEpisode | null;
67
+ lastError: string | null;
68
+ }
69
+
70
+ export interface AiLiveStartOptions {
71
+ /** Which camera: 'back' (default) or 'front', the selfie camera. */
72
+ camera?: AiLiveCamera;
73
+ }
74
+
75
+ export interface AiLiveAPI {
76
+ /** Start sending this phone's camera to the chosen AI, in a live session of the
77
+ * phone's own (capped at US$0.50). Refuses by name when either side has not said
78
+ * yes, when another screen is sending already, or when the person already has a
79
+ * live session running elsewhere. */
80
+ start(opts?: AiLiveStartOptions): Promise<AiLiveSnapshot>;
81
+ /** Stop sending. Closing the screen does the same. */
82
+ stop(): Promise<void>;
83
+ getSnapshot(): AiLiveSnapshot;
84
+ /** Called with every episode event the AI makes while this screen sends. */
85
+ onEpisode(cb: (ev: AiLiveEpisode) => void): Subscription;
86
+ /** Called whenever the snapshot changes. */
87
+ subscribe(cb: (snap: AiLiveSnapshot) => void): Subscription;
88
+ }
89
+
90
+ /** What the host supplies (setAiLiveProvider). `owner` is the screen's holder id,
91
+ * captured by the gate — a plugin never supplies its own. */
92
+ export interface AiLiveProvider {
93
+ /** `ctx.allowed` is the two-sided consent, asked again right before every picture:
94
+ * once it answers no, the provider sends nothing more and stops. */
95
+ start(opts: AiLiveStartOptions, owner: string, ctx: { visual: import('./device-state').DeviceState['visual']; allowed: () => boolean }): Promise<AiLiveSnapshot>;
96
+ /** `why` (optional) is what the screen then reads as lastError, e.g. the yes taken back. */
97
+ stop(owner: string, why?: string): Promise<void>;
98
+ getSnapshot(owner: string): AiLiveSnapshot;
99
+ onEpisode(cb: (ev: AiLiveEpisode) => void, owner: string): Subscription;
100
+ subscribe(cb: (snap: AiLiveSnapshot) => void, owner: string): Subscription;
101
+ /** The screen is gone: drop its callbacks and its last snapshot (after stop). */
102
+ forget(owner: string): void;
103
+ /** Whether ANY screen is sending right now — what the platform's indicator shows. */
104
+ isSending(): boolean;
105
+ /** Any change of isSending(). */
106
+ subscribeSending(cb: () => void): Subscription;
107
+ }
108
+
109
+ export const AI_LIVE_IDLE: AiLiveSnapshot = Object.freeze({
110
+ state: 'idle', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: null,
111
+ }) as AiLiveSnapshot;
package/src/sdk/index.ts CHANGED
@@ -76,6 +76,18 @@ export type {
76
76
  // provider and its own surfaces call it as this caller. See VoiceSessionProvider.
77
77
  export { PLATFORM_VOICE_CALLER } from './voice';
78
78
 
79
+ // AI live — the workspace's chosen AI watching the phone camera (props.aiLive).
80
+ export type {
81
+ AiLiveAPI,
82
+ AiLiveCamera,
83
+ AiLiveEpisode,
84
+ AiLiveProvider,
85
+ AiLiveSnapshot,
86
+ AiLiveStartOptions,
87
+ AiLiveState,
88
+ } from './aiLive';
89
+ export { AI_LIVE_IDLE } from './aiLive';
90
+
79
91
  // Device state — the SEMANTIC layer: the types of what `props.deviceState`
80
92
  // returns, and the two pure helpers for reading a snapshot. They are here, in
81
93
  // the one module a bundle can require, because a bundle has no other SDK name
package/src/sdk/types.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { DeviceState, DeviceBridgeDomain } from './device-state';
8
8
  import type { VoiceAPI } from './voice';
9
+ import type { AiLiveAPI } from './aiLive';
9
10
 
10
11
  // ── Plugin Context ──────────────────────────────────────────
11
12
 
@@ -258,11 +259,16 @@ export interface BluetoothDevice {
258
259
  // deviceState.visual.getSnapshot() → "is there a current frame, how old"
259
260
  // native.vision.readText(frame) → "what does the sign say"
260
261
  //
261
- // FRAMES NEVER LEAVE THE PHONE. That is Arie's decision (2026-08-15) and it is
262
- // the whole reason this is a platform capability rather than an API call from
263
- // a plugin: analysis runs on-device, so there is no per-frame cost and no image
264
- // on anyone's server. An implementation that POSTs the frame somewhere would
265
- // satisfy the types and violate the design.
262
+ // FRAMES STAY ON THE PHONE UNLESS TWO PEOPLE SAY YES. Arie's decision
263
+ // (2026-08-15: frames never leave the phone) is the default and the reason this
264
+ // is a platform capability rather than an API call from a plugin: analysis runs
265
+ // on-device, so there is no per-frame cost and no image on anyone's server.
266
+ // Amended 2026-10-25: a picture MAY leave only when the solution's developer
267
+ // declares `picturesToAi` AND the person on the phone allowed it in the host's
268
+ // settings. `mayPicturesLeavePhone()` (host.ts) is the one check; any path that
269
+ // sends a picture off the phone must call it and refuse when it says no. No
270
+ // such path exists yet. An implementation that POSTs a frame without that check
271
+ // would satisfy the types and violate the design.
266
272
  //
267
273
  // IT TAKES A HANDLE, NOT PIXELS. `MediaHandle.id` from a VisualFrameRef — the
268
274
  // core rule that media crosses by reference. A plugin never holds bytes.
@@ -595,6 +601,11 @@ export interface NativeCapabilities {
595
601
  nfc?: boolean;
596
602
  /** Real biometric auth (Face ID / Touch ID / fingerprint). */
597
603
  biometricsV2?: boolean;
604
+ /** The developer's half of "camera pictures may go to an AI service off the
605
+ * phone". Declaring it grants nothing by itself: the person on the phone must
606
+ * also allow it in the host's settings. Both are checked together by
607
+ * `mayPicturesLeavePhone()`. Not declared = pictures never leave. */
608
+ picturesToAi?: boolean;
598
609
  }
599
610
 
600
611
  // ── Lifecycle hooks ─────────────────────────────────────────
@@ -677,6 +688,14 @@ export interface PluginProps {
677
688
  * plugin gets a refusal (reads say so, actions throw) — never a fake `idle`.
678
689
  */
679
690
  voice: VoiceAPI;
691
+ /**
692
+ * The workspace's CHOSEN AI watching the phone camera, live — gated to plugins
693
+ * that declared `capabilities: { picturesToAi: true }` AND used only when the
694
+ * person on the phone allowed "Pictures to AI". The screen gets episodes
695
+ * ("drinking" started), never pictures; "Sending to AI" is platform-owned.
696
+ * REQUIRED like `voice`: an undeclared plugin gets a refusal, never a fake idle.
697
+ */
698
+ aiLive: AiLiveAPI;
680
699
  theme: ThemeTokens;
681
700
  context: PluginContext;
682
701
  /** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.