@ateam-ai-mobile/expo 0.1.20 → 0.1.21

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.
Files changed (51) hide show
  1. package/dist/aiLive/provider.d.mts +20 -0
  2. package/dist/aiLive/provider.d.ts +20 -0
  3. package/dist/aiLive/provider.js +400 -0
  4. package/dist/aiLive/provider.js.map +1 -0
  5. package/dist/aiLive/provider.mjs +15 -0
  6. package/dist/aiLive/provider.mjs.map +1 -0
  7. package/dist/bootstrap.js +350 -3
  8. package/dist/bootstrap.js.map +1 -1
  9. package/dist/bootstrap.mjs +12 -8
  10. package/dist/bootstrap.mjs.map +1 -1
  11. package/dist/capabilities/index.d.mts +40 -1
  12. package/dist/capabilities/index.d.ts +40 -1
  13. package/dist/capabilities/index.js +30 -0
  14. package/dist/capabilities/index.js.map +1 -1
  15. package/dist/capabilities/index.mjs +36 -6
  16. package/dist/capabilities/index.mjs.map +1 -1
  17. package/dist/{chunk-42FP5DTO.mjs → chunk-2ZQEQJQ6.mjs} +1 -1
  18. package/dist/chunk-2ZQEQJQ6.mjs.map +1 -0
  19. package/dist/{chunk-KRGCWVDJ.mjs → chunk-BAZFMTCC.mjs} +2 -2
  20. package/dist/{chunk-RZDAY2GS.mjs → chunk-C62NQA4F.mjs} +4 -4
  21. package/dist/chunk-JUBLG3PN.mjs +351 -0
  22. package/dist/chunk-JUBLG3PN.mjs.map +1 -0
  23. package/dist/{chunk-SOUNJY7R.mjs → chunk-OEX4OKYQ.mjs} +10 -10
  24. package/dist/{chunk-6JX222YX.mjs → chunk-WQK6PUBN.mjs} +9 -2
  25. package/dist/chunk-WQK6PUBN.mjs.map +1 -0
  26. package/dist/device/createDeviceProvider.js.map +1 -1
  27. package/dist/device/createDeviceProvider.mjs +6 -6
  28. package/dist/device-bridge/index.mjs +3 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/index.mjs +1 -1
  31. package/dist/push/silentSync.mjs +5 -5
  32. package/dist/vision/index.d.mts +7 -1
  33. package/dist/vision/index.d.ts +7 -1
  34. package/dist/vision/index.js +8 -0
  35. package/dist/vision/index.js.map +1 -1
  36. package/dist/vision/index.mjs +3 -1
  37. package/ios/AdaVisionModule.swift +44 -1
  38. package/package.json +10 -4
  39. package/src/aiLive/provider.ts +242 -0
  40. package/src/aiLive/streamProtocol.ts +90 -0
  41. package/src/bootstrap.ts +7 -1
  42. package/src/capabilities/index.ts +2 -0
  43. package/src/capabilities/picturesToAi.ts +40 -0
  44. package/src/capabilities/store.ts +30 -0
  45. package/src/index.ts +3 -0
  46. package/src/vision/index.ts +14 -0
  47. package/dist/chunk-42FP5DTO.mjs.map +0 -1
  48. package/dist/chunk-6JX222YX.mjs.map +0 -1
  49. /package/dist/{chunk-KRGCWVDJ.mjs.map → chunk-BAZFMTCC.mjs.map} +0 -0
  50. /package/dist/{chunk-RZDAY2GS.mjs.map → chunk-C62NQA4F.mjs.map} +0 -0
  51. /package/dist/{chunk-SOUNJY7R.mjs.map → chunk-OEX4OKYQ.mjs.map} +0 -0
package/dist/index.mjs CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  parseWalkJsonl,
10
10
  setupExpoProviders,
11
11
  visualEngine
12
- } from "./chunk-42FP5DTO.mjs";
12
+ } from "./chunk-2ZQEQJQ6.mjs";
13
13
  import {
14
14
  cameraTasks
15
15
  } from "./chunk-6LXF3JVX.mjs";
@@ -7,16 +7,16 @@ import {
7
7
  import {
8
8
  getDeviceSyncConfig
9
9
  } from "../chunk-GA7DS3PA.mjs";
10
- import {
11
- readDeviceId
12
- } from "../chunk-IJPR5ITF.mjs";
13
10
  import {
14
11
  syncDomains
15
- } from "../chunk-RZDAY2GS.mjs";
16
- import "../chunk-KOXF2FZM.mjs";
12
+ } from "../chunk-C62NQA4F.mjs";
17
13
  import "../chunk-XQSY6MHP.mjs";
14
+ import "../chunk-KOXF2FZM.mjs";
18
15
  import "../chunk-7FWYEHN5.mjs";
19
16
  import "../chunk-J6XRNX7P.mjs";
17
+ import {
18
+ readDeviceId
19
+ } from "../chunk-IJPR5ITF.mjs";
20
20
  import "../chunk-WMJKH4XE.mjs";
21
21
 
22
22
  // src/push/silentSync.ts
@@ -86,5 +86,11 @@ declare function describe(id: string, maxLabels?: number): Promise<VisionLabel[]
86
86
  * items with boxes. [] = looked, found nothing above `minConfidence`; throws =
87
87
  * couldn't look (id aged out, or the model isn't in this build). */
88
88
  declare function detectObjects(id: string, maxItems?: number, minConfidence?: number): Promise<VisionObject[]>;
89
+ /**
90
+ * A published frame as a base64 JPEG, its longer side at most `maxSide`. FOR THE SDK'S AI LIVE SENDER ONLY
91
+ * (aiLive/provider.ts), which calls it after the two-sided "Pictures to AI" consent: this is the one way a
92
+ * picture leaves native, and no plugin-facing API reaches it. An older binary refuses by name.
93
+ */
94
+ declare function jpegById(id: string, maxSide?: number, quality?: number): Promise<string>;
89
95
 
90
- export { CameraPreview, type RawFrameQuality, type VisionFacing, type VisionFrame, type VisionLabel, type VisionObject, type VisionText, assessQuality, describe, detectObjects, isSupported, onVisionFrame, qualityById, readText, startVision, stopVision, visionRetentionMs };
96
+ export { CameraPreview, type RawFrameQuality, type VisionFacing, type VisionFrame, type VisionLabel, type VisionObject, type VisionText, assessQuality, describe, detectObjects, isSupported, jpegById, onVisionFrame, qualityById, readText, startVision, stopVision, visionRetentionMs };
@@ -86,5 +86,11 @@ declare function describe(id: string, maxLabels?: number): Promise<VisionLabel[]
86
86
  * items with boxes. [] = looked, found nothing above `minConfidence`; throws =
87
87
  * couldn't look (id aged out, or the model isn't in this build). */
88
88
  declare function detectObjects(id: string, maxItems?: number, minConfidence?: number): Promise<VisionObject[]>;
89
+ /**
90
+ * A published frame as a base64 JPEG, its longer side at most `maxSide`. FOR THE SDK'S AI LIVE SENDER ONLY
91
+ * (aiLive/provider.ts), which calls it after the two-sided "Pictures to AI" consent: this is the one way a
92
+ * picture leaves native, and no plugin-facing API reaches it. An older binary refuses by name.
93
+ */
94
+ declare function jpegById(id: string, maxSide?: number, quality?: number): Promise<string>;
89
95
 
90
- export { CameraPreview, type RawFrameQuality, type VisionFacing, type VisionFrame, type VisionLabel, type VisionObject, type VisionText, assessQuality, describe, detectObjects, isSupported, onVisionFrame, qualityById, readText, startVision, stopVision, visionRetentionMs };
96
+ export { CameraPreview, type RawFrameQuality, type VisionFacing, type VisionFrame, type VisionLabel, type VisionObject, type VisionText, assessQuality, describe, detectObjects, isSupported, jpegById, onVisionFrame, qualityById, readText, startVision, stopVision, visionRetentionMs };
@@ -25,6 +25,7 @@ __export(vision_exports, {
25
25
  describe: () => describe,
26
26
  detectObjects: () => detectObjects,
27
27
  isSupported: () => isSupported,
28
+ jpegById: () => jpegById,
28
29
  onVisionFrame: () => onVisionFrame,
29
30
  qualityById: () => qualityById,
30
31
  readText: () => readText,
@@ -95,6 +96,12 @@ async function describe(id, maxLabels = 5) {
95
96
  async function detectObjects(id, maxItems = 10, minConfidence = 0.25) {
96
97
  return verb("detectObjects")(id, maxItems, minConfidence);
97
98
  }
99
+ async function jpegById(id, maxSide = 768, quality = 0.7) {
100
+ if (native && typeof native.jpegById !== "function") {
101
+ throw new Error("sending pictures to AI needs a newer app build \u2014 this one cannot make a picture from a camera frame");
102
+ }
103
+ return verb("jpegById")(id, maxSide, quality);
104
+ }
98
105
  // Annotate the CommonJS export names for ESM import in node:
99
106
  0 && (module.exports = {
100
107
  CameraPreview,
@@ -102,6 +109,7 @@ async function detectObjects(id, maxItems = 10, minConfidence = 0.25) {
102
109
  describe,
103
110
  detectObjects,
104
111
  isSupported,
112
+ jpegById,
105
113
  onVisionFrame,
106
114
  qualityById,
107
115
  readText,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/vision/index.ts"],"sourcesContent":["// ada-vision — on-device image understanding, our own native code (no camera lib).\n//\n// On-device, frames never leave the phone:\n// assessQuality(uri) — one-shot quality of a LOCAL frame file.\n// startVision()/onVisionFrame — the headless camera session that publishes a\n// timeline frame (id + quality, never pixels) at a\n// cadence; readText/describe/detectObjects resolve\n// a published id ON-DEVICE.\n//\n// The luma math mirrors ./analysis.js (which has the exhaustive unit tests).\n//\n// requireOptionalNativeModule: on a binary WITHOUT the native module, the import\n// is null, isSupported() is false, and the session calls refuse cleanly (no crash).\n\nimport type { ComponentType } from 'react';\nimport { requireOptionalNativeModule } from 'expo-modules-core';\nimport { requireNativeView } from 'expo';\n\n/** TEST-ONLY live preview bound to the same headless session (so a tester can\n * aim). The real Guide runs headless and never mounts this. Null on a binary\n * without the native view, so importing this module never crashes. */\nexport const CameraPreview: ComponentType<any> | null = (() => {\n try { return requireNativeView('AdaVision'); } catch { return null; }\n})();\n\nexport interface RawFrameQuality {\n blur: 'sharp' | 'slight' | 'severe' | 'unknown';\n exposure: 'good' | 'dark' | 'bright' | 'unknown';\n /** true = lens covered; false = clearly not; null = not assessed. */\n obstructed: boolean | null;\n}\n\ninterface Subscription { remove(): void }\n\n/** A published timeline frame from the vision session (metadata only — never\n * pixels; the buffer stays in the native ring, resolved by id). */\nexport interface VisionFrame {\n id: string;\n ts: number;\n expiresAt: number;\n blur: RawFrameQuality['blur'];\n exposure: RawFrameQuality['exposure'];\n obstructed: RawFrameQuality['obstructed'];\n}\n\n/** One OCR result on a frame — matches the SDK's VisionText. */\nexport interface VisionText {\n text: string;\n /** 0..1 recogniser confidence (never a 1.0 filler). */\n confidence: number | null;\n /** Normalised 0..1 box, origin TOP-left, in the UPRIGHT portrait frame (native\n * rotates frames at capture) — x runs from the user's left to their right. */\n box?: { x: number; y: number; width: number; height: number };\n}\n\n/** One classifier label — a vocabulary term, NOT prose. Matches VisionLabel. */\nexport interface VisionLabel {\n label: string;\n confidence: number | null;\n}\n\n/** One detected object — a located item with a box, from the bundled detector. */\nexport interface VisionObject {\n /** Open-Images class name (e.g. \"Car\", \"Traffic light\", \"Building\", \"Person\"). */\n label: string;\n /** 0..1 detector confidence. */\n confidence: number | null;\n /** Normalised 0..1 box, origin TOP-left, in the UPRIGHT portrait frame (native\n * rotates frames at capture) — x runs from the user's left to their right. */\n box?: { x: number; y: number; width: number; height: number };\n}\n\ninterface AdaVisionNative {\n isSupported(): boolean;\n assessQuality(uri: string): Promise<RawFrameQuality>;\n startVision(intervalMs: number): Promise<void>;\n /** Absent from binaries built before the front camera existed. */\n startVisionCamera?(intervalMs: number, facing: VisionFacing): Promise<void>;\n stopVision(): void;\n visionRetentionMs(): number;\n qualityById(id: string): RawFrameQuality | null;\n readText(id: string, languages: string[] | null): Promise<VisionText[]>;\n describe(id: string, maxLabels: number): Promise<VisionLabel[]>;\n detectObjects(id: string, maxItems: number, minConfidence: number): Promise<VisionObject[]>;\n addListener(event: 'visionFrame', cb: (f: VisionFrame) => void): Subscription;\n}\n\nconst native = requireOptionalNativeModule<AdaVisionNative>('AdaVision');\n\n/** The native function, or a refusal by name: Android's AdaVision has assessQuality only\n * (AI Models F4), so a verb it lacks says so instead of failing as \"not a function\". */\nfunction verb<K extends keyof AdaVisionNative>(name: K): AdaVisionNative[K] {\n if (!native) throw new Error('ada-vision native module not present in this build');\n const fn = native[name];\n if (typeof fn !== 'function') {\n throw Object.assign(new Error(`UNAVAILABLE: ada-vision ${String(name)} is not implemented on this platform (iOS only)`), { code: 'UNAVAILABLE' });\n }\n return (fn as Function).bind(native) as AdaVisionNative[K];\n}\n\n/** Is the native vision module present in this binary? */\nexport function isSupported(): boolean {\n try { return native?.isSupported() ?? false; } catch { return false; }\n}\n\n/** One-shot: measure a frame's quality from its LOCAL file. Throws if it cannot look. */\nexport async function assessQuality(uri: string): Promise<RawFrameQuality> {\n if (!native) throw new Error('ada-vision native module not present in this build');\n return native.assessQuality(uri);\n}\n\n// ── Vision session (feeds the Visual Engine) ──\n\n/** Which camera the vision session reads. */\nexport type VisionFacing = 'front' | 'back';\n\n/**\n * Start the vision session on `facing` — publishes a timeline frame every\n * `intervalMs`. The back camera works on every binary; the front camera needs a\n * binary built with it, and an older one REFUSES it rather than quietly reading\n * the back camera instead.\n */\nexport async function startVision(intervalMs = 1000, facing: VisionFacing = 'back'): Promise<void> {\n if (facing !== 'front' && facing !== 'back') {\n throw new Error(`Unknown camera \"${String(facing)}\" — use \"front\" or \"back\"`);\n }\n if (facing === 'back') return verb('startVision')(intervalMs);\n if (native && typeof native.startVisionCamera !== 'function') {\n throw new Error('the front camera needs a newer app build — this one can read the back camera only');\n }\n return verb('startVisionCamera')!(intervalMs, facing);\n}\n\n/** Stop the vision session and release the camera. */\nexport function stopVision(): void {\n native?.stopVision?.();\n}\n\n/** Subscribe to published timeline frames (id + quality + expiry; no pixels). */\nexport function onVisionFrame(cb: (f: VisionFrame) => void): Subscription {\n if (!native) return { remove() {} };\n return native.addListener('visionFrame', cb);\n}\n\n/** How far back the native ring can still resolve a frame id (ms). */\nexport function visionRetentionMs(): number {\n return native?.visionRetentionMs?.() ?? 0;\n}\n\n/** Resolve a published frame id → its measured quality (null if evicted). */\nexport function qualityById(id: string): RawFrameQuality | null {\n return native?.qualityById?.(id) ?? null;\n}\n\n/** On-device OCR on a published frame. [] = looked, no text; throws = couldn't look. */\nexport async function readText(id: string, languages?: string[]): Promise<VisionText[]> {\n return verb('readText')(id, languages ?? null);\n}\n\n/** On-device image classification → labels (not prose). [] = nothing recognised. */\nexport async function describe(id: string, maxLabels = 5): Promise<VisionLabel[]> {\n return verb('describe')(id, maxLabels);\n}\n\n/** On-device object detection (bundled YOLOv8n, Open-Images vocabulary) → located\n * items with boxes. [] = looked, found nothing above `minConfidence`; throws =\n * couldn't look (id aged out, or the model isn't in this build). */\nexport async function detectObjects(id: string, maxItems = 10, minConfidence = 0.25): Promise<VisionObject[]> {\n return verb('detectObjects')(id, maxItems, minConfidence);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,+BAA4C;AAC5C,kBAAkC;AAK3B,IAAM,iBAA4C,MAAM;AAC7D,MAAI;AAAE,eAAO,+BAAkB,WAAW;AAAA,EAAG,QAAQ;AAAE,WAAO;AAAA,EAAM;AACtE,GAAG;AAgEH,IAAM,aAAS,sDAA6C,WAAW;AAIvE,SAAS,KAAsC,MAA6B;AAC1E,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oDAAoD;AACjF,QAAM,KAAK,OAAO,IAAI;AACtB,MAAI,OAAO,OAAO,YAAY;AAC5B,UAAM,OAAO,OAAO,IAAI,MAAM,2BAA2B,OAAO,IAAI,CAAC,iDAAiD,GAAG,EAAE,MAAM,cAAc,CAAC;AAAA,EAClJ;AACA,SAAQ,GAAgB,KAAK,MAAM;AACrC;AAGO,SAAS,cAAuB;AACrC,MAAI;AAAE,WAAO,QAAQ,YAAY,KAAK;AAAA,EAAO,QAAQ;AAAE,WAAO;AAAA,EAAO;AACvE;AAGA,eAAsB,cAAc,KAAuC;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oDAAoD;AACjF,SAAO,OAAO,cAAc,GAAG;AACjC;AAaA,eAAsB,YAAY,aAAa,KAAM,SAAuB,QAAuB;AACjG,MAAI,WAAW,WAAW,WAAW,QAAQ;AAC3C,UAAM,IAAI,MAAM,mBAAmB,OAAO,MAAM,CAAC,gCAA2B;AAAA,EAC9E;AACA,MAAI,WAAW,OAAQ,QAAO,KAAK,aAAa,EAAE,UAAU;AAC5D,MAAI,UAAU,OAAO,OAAO,sBAAsB,YAAY;AAC5D,UAAM,IAAI,MAAM,wFAAmF;AAAA,EACrG;AACA,SAAO,KAAK,mBAAmB,EAAG,YAAY,MAAM;AACtD;AAGO,SAAS,aAAmB;AACjC,UAAQ,aAAa;AACvB;AAGO,SAAS,cAAc,IAA4C;AACxE,MAAI,CAAC,OAAQ,QAAO,EAAE,SAAS;AAAA,EAAC,EAAE;AAClC,SAAO,OAAO,YAAY,eAAe,EAAE;AAC7C;AAGO,SAAS,oBAA4B;AAC1C,SAAO,QAAQ,oBAAoB,KAAK;AAC1C;AAGO,SAAS,YAAY,IAAoC;AAC9D,SAAO,QAAQ,cAAc,EAAE,KAAK;AACtC;AAGA,eAAsB,SAAS,IAAY,WAA6C;AACtF,SAAO,KAAK,UAAU,EAAE,IAAI,aAAa,IAAI;AAC/C;AAGA,eAAsB,SAAS,IAAY,YAAY,GAA2B;AAChF,SAAO,KAAK,UAAU,EAAE,IAAI,SAAS;AACvC;AAKA,eAAsB,cAAc,IAAY,WAAW,IAAI,gBAAgB,MAA+B;AAC5G,SAAO,KAAK,eAAe,EAAE,IAAI,UAAU,aAAa;AAC1D;","names":[]}
1
+ {"version":3,"sources":["../../src/vision/index.ts"],"sourcesContent":["// ada-vision — on-device image understanding, our own native code (no camera lib).\n//\n// On-device, frames never leave the phone:\n// assessQuality(uri) — one-shot quality of a LOCAL frame file.\n// startVision()/onVisionFrame — the headless camera session that publishes a\n// timeline frame (id + quality, never pixels) at a\n// cadence; readText/describe/detectObjects resolve\n// a published id ON-DEVICE.\n//\n// The luma math mirrors ./analysis.js (which has the exhaustive unit tests).\n//\n// requireOptionalNativeModule: on a binary WITHOUT the native module, the import\n// is null, isSupported() is false, and the session calls refuse cleanly (no crash).\n\nimport type { ComponentType } from 'react';\nimport { requireOptionalNativeModule } from 'expo-modules-core';\nimport { requireNativeView } from 'expo';\n\n/** TEST-ONLY live preview bound to the same headless session (so a tester can\n * aim). The real Guide runs headless and never mounts this. Null on a binary\n * without the native view, so importing this module never crashes. */\nexport const CameraPreview: ComponentType<any> | null = (() => {\n try { return requireNativeView('AdaVision'); } catch { return null; }\n})();\n\nexport interface RawFrameQuality {\n blur: 'sharp' | 'slight' | 'severe' | 'unknown';\n exposure: 'good' | 'dark' | 'bright' | 'unknown';\n /** true = lens covered; false = clearly not; null = not assessed. */\n obstructed: boolean | null;\n}\n\ninterface Subscription { remove(): void }\n\n/** A published timeline frame from the vision session (metadata only — never\n * pixels; the buffer stays in the native ring, resolved by id). */\nexport interface VisionFrame {\n id: string;\n ts: number;\n expiresAt: number;\n blur: RawFrameQuality['blur'];\n exposure: RawFrameQuality['exposure'];\n obstructed: RawFrameQuality['obstructed'];\n}\n\n/** One OCR result on a frame — matches the SDK's VisionText. */\nexport interface VisionText {\n text: string;\n /** 0..1 recogniser confidence (never a 1.0 filler). */\n confidence: number | null;\n /** Normalised 0..1 box, origin TOP-left, in the UPRIGHT portrait frame (native\n * rotates frames at capture) — x runs from the user's left to their right. */\n box?: { x: number; y: number; width: number; height: number };\n}\n\n/** One classifier label — a vocabulary term, NOT prose. Matches VisionLabel. */\nexport interface VisionLabel {\n label: string;\n confidence: number | null;\n}\n\n/** One detected object — a located item with a box, from the bundled detector. */\nexport interface VisionObject {\n /** Open-Images class name (e.g. \"Car\", \"Traffic light\", \"Building\", \"Person\"). */\n label: string;\n /** 0..1 detector confidence. */\n confidence: number | null;\n /** Normalised 0..1 box, origin TOP-left, in the UPRIGHT portrait frame (native\n * rotates frames at capture) — x runs from the user's left to their right. */\n box?: { x: number; y: number; width: number; height: number };\n}\n\ninterface AdaVisionNative {\n isSupported(): boolean;\n assessQuality(uri: string): Promise<RawFrameQuality>;\n startVision(intervalMs: number): Promise<void>;\n /** Absent from binaries built before the front camera existed. */\n startVisionCamera?(intervalMs: number, facing: VisionFacing): Promise<void>;\n /** Absent from binaries built before AI live existed. A base64 JPEG. */\n jpegById?(id: string, maxSide: number, quality: number): Promise<string>;\n stopVision(): void;\n visionRetentionMs(): number;\n qualityById(id: string): RawFrameQuality | null;\n readText(id: string, languages: string[] | null): Promise<VisionText[]>;\n describe(id: string, maxLabels: number): Promise<VisionLabel[]>;\n detectObjects(id: string, maxItems: number, minConfidence: number): Promise<VisionObject[]>;\n addListener(event: 'visionFrame', cb: (f: VisionFrame) => void): Subscription;\n}\n\nconst native = requireOptionalNativeModule<AdaVisionNative>('AdaVision');\n\n/** The native function, or a refusal by name: Android's AdaVision has assessQuality only\n * (AI Models F4), so a verb it lacks says so instead of failing as \"not a function\". */\nfunction verb<K extends keyof AdaVisionNative>(name: K): AdaVisionNative[K] {\n if (!native) throw new Error('ada-vision native module not present in this build');\n const fn = native[name];\n if (typeof fn !== 'function') {\n throw Object.assign(new Error(`UNAVAILABLE: ada-vision ${String(name)} is not implemented on this platform (iOS only)`), { code: 'UNAVAILABLE' });\n }\n return (fn as Function).bind(native) as AdaVisionNative[K];\n}\n\n/** Is the native vision module present in this binary? */\nexport function isSupported(): boolean {\n try { return native?.isSupported() ?? false; } catch { return false; }\n}\n\n/** One-shot: measure a frame's quality from its LOCAL file. Throws if it cannot look. */\nexport async function assessQuality(uri: string): Promise<RawFrameQuality> {\n if (!native) throw new Error('ada-vision native module not present in this build');\n return native.assessQuality(uri);\n}\n\n// ── Vision session (feeds the Visual Engine) ──\n\n/** Which camera the vision session reads. */\nexport type VisionFacing = 'front' | 'back';\n\n/**\n * Start the vision session on `facing` — publishes a timeline frame every\n * `intervalMs`. The back camera works on every binary; the front camera needs a\n * binary built with it, and an older one REFUSES it rather than quietly reading\n * the back camera instead.\n */\nexport async function startVision(intervalMs = 1000, facing: VisionFacing = 'back'): Promise<void> {\n if (facing !== 'front' && facing !== 'back') {\n throw new Error(`Unknown camera \"${String(facing)}\" — use \"front\" or \"back\"`);\n }\n if (facing === 'back') return verb('startVision')(intervalMs);\n if (native && typeof native.startVisionCamera !== 'function') {\n throw new Error('the front camera needs a newer app build — this one can read the back camera only');\n }\n return verb('startVisionCamera')!(intervalMs, facing);\n}\n\n/** Stop the vision session and release the camera. */\nexport function stopVision(): void {\n native?.stopVision?.();\n}\n\n/** Subscribe to published timeline frames (id + quality + expiry; no pixels). */\nexport function onVisionFrame(cb: (f: VisionFrame) => void): Subscription {\n if (!native) return { remove() {} };\n return native.addListener('visionFrame', cb);\n}\n\n/** How far back the native ring can still resolve a frame id (ms). */\nexport function visionRetentionMs(): number {\n return native?.visionRetentionMs?.() ?? 0;\n}\n\n/** Resolve a published frame id → its measured quality (null if evicted). */\nexport function qualityById(id: string): RawFrameQuality | null {\n return native?.qualityById?.(id) ?? null;\n}\n\n/** On-device OCR on a published frame. [] = looked, no text; throws = couldn't look. */\nexport async function readText(id: string, languages?: string[]): Promise<VisionText[]> {\n return verb('readText')(id, languages ?? null);\n}\n\n/** On-device image classification → labels (not prose). [] = nothing recognised. */\nexport async function describe(id: string, maxLabels = 5): Promise<VisionLabel[]> {\n return verb('describe')(id, maxLabels);\n}\n\n/** On-device object detection (bundled YOLOv8n, Open-Images vocabulary) → located\n * items with boxes. [] = looked, found nothing above `minConfidence`; throws =\n * couldn't look (id aged out, or the model isn't in this build). */\nexport async function detectObjects(id: string, maxItems = 10, minConfidence = 0.25): Promise<VisionObject[]> {\n return verb('detectObjects')(id, maxItems, minConfidence);\n}\n\n/**\n * A published frame as a base64 JPEG, its longer side at most `maxSide`. FOR THE SDK'S AI LIVE SENDER ONLY\n * (aiLive/provider.ts), which calls it after the two-sided \"Pictures to AI\" consent: this is the one way a\n * picture leaves native, and no plugin-facing API reaches it. An older binary refuses by name.\n */\nexport async function jpegById(id: string, maxSide = 768, quality = 0.7): Promise<string> {\n if (native && typeof native.jpegById !== 'function') {\n throw new Error('sending pictures to AI needs a newer app build — this one cannot make a picture from a camera frame');\n }\n return verb('jpegById')!(id, maxSide, quality);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,+BAA4C;AAC5C,kBAAkC;AAK3B,IAAM,iBAA4C,MAAM;AAC7D,MAAI;AAAE,eAAO,+BAAkB,WAAW;AAAA,EAAG,QAAQ;AAAE,WAAO;AAAA,EAAM;AACtE,GAAG;AAkEH,IAAM,aAAS,sDAA6C,WAAW;AAIvE,SAAS,KAAsC,MAA6B;AAC1E,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oDAAoD;AACjF,QAAM,KAAK,OAAO,IAAI;AACtB,MAAI,OAAO,OAAO,YAAY;AAC5B,UAAM,OAAO,OAAO,IAAI,MAAM,2BAA2B,OAAO,IAAI,CAAC,iDAAiD,GAAG,EAAE,MAAM,cAAc,CAAC;AAAA,EAClJ;AACA,SAAQ,GAAgB,KAAK,MAAM;AACrC;AAGO,SAAS,cAAuB;AACrC,MAAI;AAAE,WAAO,QAAQ,YAAY,KAAK;AAAA,EAAO,QAAQ;AAAE,WAAO;AAAA,EAAO;AACvE;AAGA,eAAsB,cAAc,KAAuC;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oDAAoD;AACjF,SAAO,OAAO,cAAc,GAAG;AACjC;AAaA,eAAsB,YAAY,aAAa,KAAM,SAAuB,QAAuB;AACjG,MAAI,WAAW,WAAW,WAAW,QAAQ;AAC3C,UAAM,IAAI,MAAM,mBAAmB,OAAO,MAAM,CAAC,gCAA2B;AAAA,EAC9E;AACA,MAAI,WAAW,OAAQ,QAAO,KAAK,aAAa,EAAE,UAAU;AAC5D,MAAI,UAAU,OAAO,OAAO,sBAAsB,YAAY;AAC5D,UAAM,IAAI,MAAM,wFAAmF;AAAA,EACrG;AACA,SAAO,KAAK,mBAAmB,EAAG,YAAY,MAAM;AACtD;AAGO,SAAS,aAAmB;AACjC,UAAQ,aAAa;AACvB;AAGO,SAAS,cAAc,IAA4C;AACxE,MAAI,CAAC,OAAQ,QAAO,EAAE,SAAS;AAAA,EAAC,EAAE;AAClC,SAAO,OAAO,YAAY,eAAe,EAAE;AAC7C;AAGO,SAAS,oBAA4B;AAC1C,SAAO,QAAQ,oBAAoB,KAAK;AAC1C;AAGO,SAAS,YAAY,IAAoC;AAC9D,SAAO,QAAQ,cAAc,EAAE,KAAK;AACtC;AAGA,eAAsB,SAAS,IAAY,WAA6C;AACtF,SAAO,KAAK,UAAU,EAAE,IAAI,aAAa,IAAI;AAC/C;AAGA,eAAsB,SAAS,IAAY,YAAY,GAA2B;AAChF,SAAO,KAAK,UAAU,EAAE,IAAI,SAAS;AACvC;AAKA,eAAsB,cAAc,IAAY,WAAW,IAAI,gBAAgB,MAA+B;AAC5G,SAAO,KAAK,eAAe,EAAE,IAAI,UAAU,aAAa;AAC1D;AAOA,eAAsB,SAAS,IAAY,UAAU,KAAK,UAAU,KAAsB;AACxF,MAAI,UAAU,OAAO,OAAO,aAAa,YAAY;AACnD,UAAM,IAAI,MAAM,0GAAqG;AAAA,EACvH;AACA,SAAO,KAAK,UAAU,EAAG,IAAI,SAAS,OAAO;AAC/C;","names":[]}
@@ -4,13 +4,14 @@ import {
4
4
  describe,
5
5
  detectObjects,
6
6
  isSupported,
7
+ jpegById,
7
8
  onVisionFrame,
8
9
  qualityById,
9
10
  readText,
10
11
  startVision,
11
12
  stopVision,
12
13
  visionRetentionMs
13
- } from "../chunk-6JX222YX.mjs";
14
+ } from "../chunk-WQK6PUBN.mjs";
14
15
  import "../chunk-WMJKH4XE.mjs";
15
16
  export {
16
17
  CameraPreview,
@@ -18,6 +19,7 @@ export {
18
19
  describe,
19
20
  detectObjects,
20
21
  isSupported,
22
+ jpegById,
21
23
  onVisionFrame,
22
24
  qualityById,
23
25
  readText,
@@ -12,7 +12,10 @@ import UIKit
12
12
  // reads each live frame's luma plane, publishes a
13
13
  // timeline frame (id + quality, never pixels) at a
14
14
  // cadence, and resolves readText/describe/detectObjects
15
- // by id INSIDE native. No photo, no JPEG, no file.
15
+ // by id INSIDE native. No photo, no file.
16
+ // jpegById(id, maxSide, q) — the ONE way a frame leaves native: a JPEG for the SDK's
17
+ // AI live sender, after the two-sided "Pictures to AI" consent
18
+ // (plugin-runtime mayPicturesLeavePhone). Plugins never call it.
16
19
  public class AdaVisionModule: Module {
17
20
  private let vision = VisionSession()
18
21
 
@@ -77,6 +80,16 @@ public class AdaVisionModule: Module {
77
80
  }
78
81
  }
79
82
 
83
+ /** A published frame as a base64 JPEG whose longer side is at most `maxSide`. Only the SDK's AI live
84
+ * sender calls it, after both sides said yes to "Pictures to AI"; a plugin never gets pixels. Rejects
85
+ * if the id is unknown or aged out of the ring. */
86
+ AsyncFunction("jpegById") { (id: String, maxSide: Int, quality: Double, promise: Promise) in
87
+ self.vision.jpegById(id, maxSide: maxSide, quality: quality) { b64, err in
88
+ if let err = err { promise.reject("AdaVision", err.localizedDescription) }
89
+ else { promise.resolve(b64) }
90
+ }
91
+ }
92
+
80
93
  AsyncFunction("assessQuality") { (uri: String) throws -> [String: Any] in
81
94
  guard let image = AdaVisionModule.loadImage(uri) else {
82
95
  throw Exception(name: "AdaVisionUnreadable", description: "Could not read frame at \(uri)")
@@ -446,6 +459,36 @@ final class VisionSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat
446
459
  return vn
447
460
  }()
448
461
 
462
+ // MARK: jpegById — a published frame as a JPEG, for the AI live sender only.
463
+ // Same latest-wins queue as readText/describe. Scaled down (never up) so the longer
464
+ // side is at most maxSide: the size the chosen setup was tested at.
465
+ func jpegById(_ id: String, maxSide: Int, quality: Double, completion: @escaping (String?, Error?) -> Void) {
466
+ queue.async {
467
+ guard let cg = self.ring.first(where: { $0.id == id })?.image else {
468
+ completion(nil, NSError(domain: "AdaVision", code: 6,
469
+ userInfo: [NSLocalizedDescriptionKey: "No frame for id \(id) — unknown or aged out of the ring"]))
470
+ return
471
+ }
472
+ let longest = max(cg.width, cg.height)
473
+ var image = UIImage(cgImage: cg)
474
+ if maxSide > 0 && longest > maxSide {
475
+ let scale = Double(maxSide) / Double(longest)
476
+ let size = CGSize(width: (Double(cg.width) * scale).rounded(), height: (Double(cg.height) * scale).rounded())
477
+ let format = UIGraphicsImageRendererFormat.default()
478
+ format.scale = 1
479
+ image = UIGraphicsImageRenderer(size: size, format: format).image { _ in
480
+ UIImage(cgImage: cg).draw(in: CGRect(origin: .zero, size: size))
481
+ }
482
+ }
483
+ guard let data = image.jpegData(compressionQuality: CGFloat(min(max(quality, 0.1), 1.0))) else {
484
+ completion(nil, NSError(domain: "AdaVision", code: 8,
485
+ userInfo: [NSLocalizedDescriptionKey: "Could not encode the frame as a JPEG"]))
486
+ return
487
+ }
488
+ completion(data.base64EncodedString(), nil)
489
+ }
490
+ }
491
+
449
492
  // MARK: detectObjects — on-device object detection (bundled Core ML), by id.
450
493
  // Same latest-wins discipline as readText/describe (serial `queue`). Boxes go
451
494
  // through the same topLeftBox as readText: normalised 0..1, TOP-left origin, in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/expo",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Expo-backed providers for the ADAS SDK \u2014 haptics, bundle disk cache, attachment cache, device bridge.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,9 +9,9 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.20",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.20",
14
- "@ateam-ai-mobile/ui": "0.1.20",
12
+ "@ateam-ai-mobile/core": "0.1.21",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.21",
14
+ "@ateam-ai-mobile/ui": "0.1.21",
15
15
  "zustand": "^5.0.0"
16
16
  },
17
17
  "peerDependencies": {
@@ -136,6 +136,12 @@
136
136
  "import": "./dist/vision/index.mjs",
137
137
  "require": "./dist/vision/index.js"
138
138
  },
139
+ "./ai-live": {
140
+ "react-native": "./src/aiLive/provider.ts",
141
+ "types": "./dist/aiLive/provider.d.ts",
142
+ "import": "./dist/aiLive/provider.mjs",
143
+ "require": "./dist/aiLive/provider.js"
144
+ },
139
145
  "./maps": {
140
146
  "react-native": "./src/maps/index.ts",
141
147
  "types": "./dist/maps/index.d.ts",
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The AI live provider: this phone's camera → the workspace's live session → the chosen AI,
3
+ * and what the AI saw back to the screen. Installed by the host once at startup:
4
+ * import { createAiLiveProvider } from '@ateam-ai-mobile/expo/ai-live';
5
+ * setAiLiveProvider(createAiLiveProvider());
6
+ * A plugin reaches it only through props.aiLive,
7
+ * whose gate (plugin-runtime aiLiveHost) has already checked the two-sided consent.
8
+ *
9
+ * One camera, one sender: while one screen sends, another's start() is refused by name.
10
+ *
11
+ * start():
12
+ * 1. the live session: reuse the one running (an admin may have started it on the AI Models
13
+ * page) or start one for the phone with a capped money slice (PHONE_SLICE_USD), and remember
14
+ * that this phone started it, so stop() ends only what it began;
15
+ * 2. the camera: the screen's held visual session on the requested camera;
16
+ * 3. Core's stream endpoint (/ws/stream, kind "ai-models.live"), the login in the subprotocol;
17
+ * 4. once Core says "opened": one picture per slot at the TESTED rate and size (the session's
18
+ * `camera`), from the newest fresh frame, as a JPEG made by native (jpegById). A slot with no
19
+ * fresh frame, or while the socket is still busy, sends nothing and still takes its sequence
20
+ * number, so Core reports it as a gap;
21
+ * 5. {"event":"episode"} from Core → the screen's onEpisode callbacks.
22
+ */
23
+ import { getCoreWsUrl, getTenant, getToken, mcpToolCall, unwrapMcpResult } from '@ateam-ai-mobile/core';
24
+ import type { AiLiveEpisode, AiLiveProvider, AiLiveSnapshot, AiLiveStartOptions, DeviceState, Subscription } from '@ateam-ai-mobile/plugin-runtime';
25
+ import { jpegById as nativeJpegById } from '../vision';
26
+ import { CLOSE_MESSAGE, STREAM_PATH, SUBPROTOCOL, base64ToBytes, describeClose, encodeFrame, openMessage, parseServerEvent } from './streamProtocol';
27
+
28
+ /** The most one phone session may spend (owner decision 2026-10-29), held from the AI Models budget. */
29
+ export const PHONE_SLICE_USD = 0.5;
30
+ export const LIVE_KIND = 'ai-models.live';
31
+ /** A frame older than this is not "what the camera sees now" and is not sent. */
32
+ const FRESH_MS = 3000;
33
+ /** Bytes still waiting in the socket above which a slot is skipped rather than queued. */
34
+ const MAX_BUFFERED = 512 * 1024;
35
+ const DEFAULT_CAMERA = { fps: 1, maxPx: 768 };
36
+
37
+ type Visual = DeviceState['visual'];
38
+
39
+ export interface AiLiveDeps {
40
+ callCp(method: string, params?: Record<string, unknown>): Promise<any>;
41
+ WebSocketImpl: any;
42
+ wsBase(): string;
43
+ token(): string | null;
44
+ tenant(): string | null;
45
+ jpegById(id: string, maxSide: number, quality: number): Promise<string>;
46
+ now(): number;
47
+ setInterval(fn: () => void, ms: number): any;
48
+ clearInterval(handle: any): void;
49
+ }
50
+
51
+ const SESSION_UP = new Set(['starting', 'running', 'degraded']);
52
+
53
+ async function defaultCallCp(method: string, params: Record<string, unknown> = {}): Promise<any> {
54
+ const res = await mcpToolCall('cp.ai_models_api', { method, params });
55
+ if (res?.error) throw new Error(res.error.message || 'AI Models refused the request');
56
+ const out = unwrapMcpResult(res?.result);
57
+ if (res?.result?.isError) throw new Error(typeof out === 'string' ? out : out?.message || out?.error || 'AI Models refused the request');
58
+ if (out && typeof out === 'object' && out.ok === false) throw new Error(out.message || out.error || 'AI Models refused the request');
59
+ return out;
60
+ }
61
+
62
+ export const defaultAiLiveDeps = (): AiLiveDeps => ({
63
+ callCp: defaultCallCp,
64
+ WebSocketImpl: (globalThis as any).WebSocket,
65
+ wsBase: getCoreWsUrl,
66
+ token: getToken,
67
+ tenant: getTenant,
68
+ jpegById: nativeJpegById,
69
+ now: Date.now,
70
+ setInterval: (fn, ms) => setInterval(fn, ms),
71
+ clearInterval: (h) => clearInterval(h),
72
+ });
73
+
74
+ const idle = (): AiLiveSnapshot => ({ state: 'idle', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: null });
75
+
76
+ export function createAiLiveProvider(overrides: Partial<AiLiveDeps> = {}): AiLiveProvider {
77
+ const deps: AiLiveDeps = { ...defaultAiLiveDeps(), ...overrides };
78
+ let owner: string | null = null; // the screen sending now
79
+ let snap: AiLiveSnapshot = idle();
80
+ let ws: any = null;
81
+ let loop: any = null;
82
+ let visual: Visual | null = null;
83
+ let startedSession = false; // this phone started the live session, so it stops it
84
+ let closingOnPurpose = false;
85
+ let seq = 0;
86
+ let busy = false;
87
+ // Which session a slot belongs to. A slot awaits the camera and the JPEG maker; when the screen
88
+ // stops (or another starts) meanwhile, that slot's picture must go nowhere, not into the next one.
89
+ let gen = 0;
90
+ let lastFrameId: string | null = null;
91
+ let camera = DEFAULT_CAMERA;
92
+ let lastOwner: string | null = null; // whose the current snapshot is (it outlives a stop)
93
+ // Each screen hears only its own session: callbacks are kept per holder.
94
+ const episodeCbs = new Map<string, Set<(ev: AiLiveEpisode) => void>>();
95
+ const snapCbs = new Map<string, Set<(s: AiLiveSnapshot) => void>>();
96
+ const sendingCbs = new Set<() => void>();
97
+ let wasSending = false;
98
+ const add = <T>(m: Map<string, Set<T>>, who: string, cb: T): Subscription => {
99
+ let set = m.get(who);
100
+ if (!set) { set = new Set(); m.set(who, set); }
101
+ set.add(cb);
102
+ return { unsubscribe: () => { set!.delete(cb); } };
103
+ };
104
+
105
+ const set = (patch: Partial<AiLiveSnapshot>) => {
106
+ snap = { ...snap, ...patch };
107
+ for (const cb of (lastOwner && snapCbs.get(lastOwner)) || []) { try { cb(snap); } catch { /* a listener's error is its own */ } }
108
+ const sending = snap.state === 'starting' || snap.state === 'sending';
109
+ if (sending !== wasSending) { wasSending = sending; for (const cb of sendingCbs) { try { cb(); } catch { /* idem */ } } }
110
+ };
111
+
112
+ function stopTimers() {
113
+ if (loop != null) { deps.clearInterval(loop); loop = null; }
114
+ }
115
+
116
+ async function slot() {
117
+ const mySeq = seq++; // every slot takes a number, sent or not
118
+ const g = gen;
119
+ const sock = ws;
120
+ const current = () => g === gen && ws === sock && sock?.readyState === 1;
121
+ if (busy || !current()) { set({ skipped: snap.skipped + 1 }); return; }
122
+ if ((sock.bufferedAmount || 0) > MAX_BUFFERED) { set({ skipped: snap.skipped + 1 }); return; }
123
+ busy = true;
124
+ try {
125
+ const v = await visual!.getSnapshot();
126
+ if (!current()) return;
127
+ const f: any = v?.latest;
128
+ const id = f?.handle?.id;
129
+ const t = Number(f?.timestamp);
130
+ if (!id || id === lastFrameId || !(deps.now() - t < FRESH_MS)) { set({ skipped: snap.skipped + 1 }); return; }
131
+ const b64 = await deps.jpegById(id, camera.maxPx, 0.7);
132
+ if (!current()) return;
133
+ const frame = encodeFrame({ seq: mySeq, tCapture: t, payload: base64ToBytes(b64) });
134
+ sock.send(frame.buffer);
135
+ lastFrameId = id;
136
+ set({ sent: snap.sent + 1 });
137
+ } catch (e: any) {
138
+ if (g === gen) set({ skipped: snap.skipped + 1, lastError: String(e?.message || e) });
139
+ } finally {
140
+ if (g === gen) busy = false;
141
+ }
142
+ }
143
+
144
+ function openSocket(): Promise<void> {
145
+ return new Promise((resolve, reject) => {
146
+ const token = deps.token();
147
+ const tenant = deps.tenant();
148
+ if (!token || !tenant) { reject(new Error('not signed in to a workspace')); return; }
149
+ const url = `${deps.wsBase()}${STREAM_PATH}?tenant=${encodeURIComponent(tenant)}`;
150
+ let opened = false;
151
+ const sock = new deps.WebSocketImpl(url, [SUBPROTOCOL, `adas.bearer.${token}`]);
152
+ ws = sock;
153
+ sock.binaryType = 'arraybuffer';
154
+ sock.onopen = () => { try { sock.send(openMessage(LIVE_KIND)); } catch (e) { reject(e); } };
155
+ sock.onmessage = (m: any) => {
156
+ const ev = parseServerEvent(m?.data);
157
+ if (!ev) return;
158
+ if (ev.event === 'opened' && !opened) { opened = true; resolve(); return; }
159
+ if (ev.event === 'episode' && ev.data && typeof ev.data === 'object') {
160
+ const episode = ev.data as AiLiveEpisode;
161
+ set({ lastEpisode: episode });
162
+ for (const cb of (owner && episodeCbs.get(owner)) || []) { try { cb(episode); } catch { /* a listener's error is its own */ } }
163
+ }
164
+ };
165
+ sock.onerror = () => { /* the close that follows says why */ };
166
+ sock.onclose = (c: any) => {
167
+ const why = describeClose(Number(c?.code), c?.reason);
168
+ if (!opened) { reject(new Error(why)); return; }
169
+ if (closingOnPurpose || ws !== sock) return;
170
+ void teardown('error', why);
171
+ };
172
+ });
173
+ }
174
+
175
+ async function teardown(state: 'stopped' | 'error', lastError: string | null) {
176
+ const hadSession = startedSession;
177
+ startedSession = false;
178
+ gen++;
179
+ stopTimers();
180
+ const sock = ws;
181
+ ws = null;
182
+ closingOnPurpose = true;
183
+ try { if (sock && sock.readyState === 1) sock.send(CLOSE_MESSAGE); } catch { /* closing anyway */ }
184
+ try { sock?.close?.(); } catch { /* already closed */ }
185
+ closingOnPurpose = false;
186
+ const v = visual;
187
+ visual = null;
188
+ try { await v?.stop?.(); } catch { /* the camera hold is released by the gate too */ }
189
+ if (hadSession) { try { await deps.callCp('liveStop'); } catch { /* the session ends by itself when no picture arrives */ } }
190
+ owner = null;
191
+ set({ state, lastError });
192
+ }
193
+
194
+ return {
195
+ async start(opts: AiLiveStartOptions, who: string, ctx: { visual: Visual }) {
196
+ if (owner && owner !== who) throw Object.assign(new Error('aiLive: another screen is sending to AI right now. Close it first.'), { code: 'CONFLICT' });
197
+ if (owner === who) return snap;
198
+ const cam = opts?.camera === 'front' ? 'front' : 'back';
199
+ if (opts?.camera && opts.camera !== 'front' && opts.camera !== 'back') throw new Error(`aiLive: unknown camera "${String(opts.camera)}" — use "front" or "back"`);
200
+ owner = who;
201
+ lastOwner = who;
202
+ visual = ctx.visual;
203
+ gen++; seq = 0; lastFrameId = null; busy = false;
204
+ snap = { ...idle(), camera: cam };
205
+ set({ state: 'starting' });
206
+ try {
207
+ let session = (await deps.callCp('liveStatus'))?.session;
208
+ if (!SESSION_UP.has(session?.state)) {
209
+ session = (await deps.callCp('liveStart', { source: 'phone', budgetSliceUsd: PHONE_SLICE_USD }))?.session;
210
+ startedSession = true;
211
+ }
212
+ const c = session?.camera;
213
+ camera = c && Number(c.fps) > 0 && Number(c.maxPx) > 0 ? { fps: Number(c.fps), maxPx: Number(c.maxPx) } : DEFAULT_CAMERA;
214
+ await visual.start({ intent: 'stream', facing: cam } as any);
215
+ await openSocket();
216
+ loop = deps.setInterval(() => { void slot(); }, Math.max(200, Math.round(1000 / camera.fps)));
217
+ set({ state: 'sending' });
218
+ return snap;
219
+ } catch (e: any) {
220
+ await teardown('error', String(e?.message || e));
221
+ throw e;
222
+ }
223
+ },
224
+ async stop(who: string) {
225
+ if (owner !== who) return;
226
+ await teardown('stopped', null);
227
+ },
228
+ getSnapshot: (who: string) => (lastOwner === who ? snap : idle()),
229
+ onEpisode: (cb, who) => add(episodeCbs, who, cb),
230
+ subscribe: (cb, who) => add(snapCbs, who, cb),
231
+ forget(who: string) {
232
+ episodeCbs.delete(who);
233
+ snapCbs.delete(who);
234
+ if (lastOwner === who && owner === null) { lastOwner = null; snap = idle(); }
235
+ },
236
+ isSending: () => snap.state === 'starting' || snap.state === 'sending',
237
+ subscribeSending(cb) {
238
+ sendingCbs.add(cb);
239
+ return { unsubscribe: () => { sendingCbs.delete(cb); } } as Subscription;
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The wire protocol of Core's binary stream endpoint (/ws/stream), for the AI live sender.
3
+ *
4
+ * A PORT of Core's `packages/media-stream/src/index.js` (@ateam-ai/media-stream, not on npm
5
+ * yet), limited to what a sender needs. The frame bytes are pinned by
6
+ * tests/aiLiveStreamProtocol.test.mjs against the layout that file documents, so a drift is
7
+ * a red test, not a silent mismatch. DEAD — replace with an import of @ateam-ai/media-stream
8
+ * once Core publishes it.
9
+ *
10
+ * Frame envelope (bytes): 0 version (1) · 1 flags · 2-5 seq uint32 BE (one per sampling slot
11
+ * of the SENDER, taken or skipped) · 6-13 tCapture float64 BE (ms since the epoch) · 14- payload.
12
+ */
13
+ export const STREAM_PATH = '/ws/stream';
14
+ export const SUBPROTOCOL = 'adas.v1';
15
+ export const HEADER_BYTES = 14;
16
+ const ENVELOPE_VERSION = 1;
17
+
18
+ export const CLOSE = Object.freeze({
19
+ AUTH_MISSING: 4001,
20
+ AUTH_FAILED: 4002,
21
+ NO_SUCH_WORKSPACE: 4003,
22
+ NOT_ENTITLED: 4004,
23
+ BAD_REQUEST: 4400,
24
+ UNKNOWN_KIND: 4404,
25
+ TIMEOUT: 4408,
26
+ OVER_LIMIT: 4429,
27
+ TOO_BIG: 1009,
28
+ CONSUMER_FAILING: 1011,
29
+ });
30
+
31
+ export function encodeFrame({ seq, tCapture, payload }: { seq: number; tCapture: number; payload: Uint8Array }): Uint8Array {
32
+ const out = new Uint8Array(HEADER_BYTES + payload.length);
33
+ const v = new DataView(out.buffer);
34
+ v.setUint8(0, ENVELOPE_VERSION);
35
+ v.setUint8(1, 0);
36
+ v.setUint32(2, seq >>> 0, false);
37
+ v.setFloat64(6, tCapture, false);
38
+ out.set(payload, HEADER_BYTES);
39
+ return out;
40
+ }
41
+
42
+ export function openMessage(kind: string): string {
43
+ return JSON.stringify({ type: 'open', kind });
44
+ }
45
+
46
+ export const CLOSE_MESSAGE = JSON.stringify({ type: 'close' });
47
+
48
+ /** A server text frame → { event, data }, or null for anything that is not an event. */
49
+ export function parseServerEvent(text: unknown): { event: string; data: any } | null {
50
+ if (typeof text !== 'string') return null;
51
+ try {
52
+ const m = JSON.parse(text);
53
+ return m && typeof m.event === 'string' ? { event: m.event, data: m.data } : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ const WHY: Record<number, string> = {
60
+ [CLOSE.AUTH_MISSING]: 'The login or the workspace was missing. Sign in again and try once more.',
61
+ [CLOSE.AUTH_FAILED]: 'The login was refused. Sign in again and try once more.',
62
+ [CLOSE.NO_SUCH_WORKSPACE]: 'This workspace was not found.',
63
+ [CLOSE.NOT_ENTITLED]: 'This workspace may not stream right now.',
64
+ [CLOSE.TIMEOUT]: 'The connection to the AI timed out.',
65
+ [CLOSE.OVER_LIMIT]: 'Too many cameras are sending to this workspace. Stop another one first.',
66
+ [CLOSE.TOO_BIG]: 'A picture was too big to send.',
67
+ [CLOSE.CONSUMER_FAILING]: 'The AI stopped taking pictures. Check the live session in AI Models.',
68
+ };
69
+
70
+ /** A close as one plain sentence: the server's own words for a refused open. */
71
+ export function describeClose(code: number, reason?: string): string {
72
+ if (code === CLOSE.BAD_REQUEST && reason) return reason;
73
+ return WHY[code] || (reason ? reason : `The connection to the AI closed (${code}).`);
74
+ }
75
+
76
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
77
+ const B64_INDEX = (() => { const t = new Int16Array(256).fill(-1); for (let i = 0; i < 64; i++) t[B64.charCodeAt(i)] = i; return t; })();
78
+
79
+ /** Base64 (the native module's JPEG) → bytes. Ignores padding and line breaks. */
80
+ export function base64ToBytes(b64: string): Uint8Array {
81
+ const clean = b64.replace(/[^A-Za-z0-9+/]/g, '');
82
+ const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
83
+ let o = 0, acc = 0, bits = 0;
84
+ for (let i = 0; i < clean.length; i++) {
85
+ acc = (acc << 6) | B64_INDEX[clean.charCodeAt(i)];
86
+ bits += 6;
87
+ if (bits >= 8) { bits -= 8; out[o++] = (acc >> bits) & 0xff; }
88
+ }
89
+ return o === out.length ? out : out.subarray(0, o);
90
+ }
package/src/bootstrap.ts CHANGED
@@ -29,12 +29,13 @@
29
29
  * this module-load bootstrap's scope).
30
30
  */
31
31
  import { initAdas, setAcsReachabilityReporter, type TokenProvider, type AdasUser, type AdasSession } from '@ateam-ai-mobile/core';
32
- import { setDeviceStateProvider, registerNativePlugin, registerPlatformDeviceTools, type RegisteredPlugin } from '@ateam-ai-mobile/plugin-runtime';
32
+ import { setDeviceStateProvider, registerNativePlugin, registerPlatformDeviceTools, setAiLiveProvider, type RegisteredPlugin } from '@ateam-ai-mobile/plugin-runtime';
33
33
  import { setOAuthProvider, setUIActionNavigator, setPluginWebViewExtraHosts, setCallAudioProvider, type UIActionRoute } from '@ateam-ai-mobile/ui';
34
34
  import { createCallAudioProvider } from './audio';
35
35
 
36
36
  import { setupExpoProviders, type ExpoAttachmentCache } from './index';
37
37
  import { createDeviceProvider } from './device/createDeviceProvider';
38
+ import { createAiLiveProvider } from './aiLive/provider';
38
39
  import { createDeviceState } from './device-state';
39
40
  import { createExpoOAuthProvider } from './oauth/expoOAuth';
40
41
  import * as deviceConnectivity from './device-bridge/collectors/connectivity';
@@ -154,6 +155,11 @@ export function bootstrapExpoAdas(opts: BootstrapExpoAdasOptions): { attachmentC
154
155
  createDeviceState({ connectivity: deviceConnectivity, location: deviceLocation }),
155
156
  );
156
157
 
158
+ // props.aiLive — this phone's camera to the workspace's chosen AI. Each use still
159
+ // needs the plugin's `picturesToAi` AND the person's "Camera pictures to AI"
160
+ // (the capabilities store installs that half), both checked at start().
161
+ setAiLiveProvider(createAiLiveProvider());
162
+
157
163
  // LOCAL device tools — register the static, host-owned platform tools
158
164
  // (device.echo / device.ping) at load, so the device path answers even before
159
165
  // any plugin bundle or sign-in: the platform tier deliberately does not depend
@@ -13,6 +13,8 @@
13
13
  * because the device bridge statically pulls the optional expo modules.
14
14
  */
15
15
  export { useCapabilitiesStore } from './store';
16
+ // "Camera pictures to AI": the person's half of the consent props.aiLive needs, and what to tell them first.
17
+ export { picturesToAiAllowed, PICTURES_TO_AI_ASK } from './picturesToAi';
16
18
  export type { CapabilityState } from './store';
17
19
  export { configureDeviceSync } from './config';
18
20
  export type { DeviceSyncConfig } from './config';