@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1315 @@
1
+ /**
2
+ * @adas/device-state — Device Bridge v2, the SEMANTIC layer.
3
+ *
4
+ * Intended repo path:
5
+ * packages/plugin-runtime/src/sdk/device-state.ts
6
+ * Intended module specifier for plugin bundles:
7
+ * '@adas/device-state' (NOT '@adas/plugin-sdk')
8
+ *
9
+ * ─────────────────────────────────────────────────────────────────────────
10
+ * WHICH LAYER AM I IN?
11
+ *
12
+ * props.device.* MECHANICAL. "Do this to the hardware, now."
13
+ * VERB names: takePhoto, getCurrent, scanBarcode,
14
+ * readOnce, authenticate, pickImage.
15
+ * Returns a BARE value. THROWS when it cannot.
16
+ * Declared in the manifest as `capabilities: { camera: true }`
17
+ * (the legacy `native: {}` field is still honoured).
18
+ * Lives in '@adas/plugin-sdk'.
19
+ *
20
+ * props.deviceState.* SEMANTIC. "What is true right now, and how well do
21
+ * we know it?"
22
+ * STATE names: getSnapshot, subscribe, start, stop.
23
+ * Returns a SNAPSHOT carrying freshness + confidence,
24
+ * and able to say `unknown` in every field.
25
+ * A read NEVER throws for absence.
26
+ * Declared in the manifest as `stateDomains: ['visual']`.
27
+ * Lives in '@adas/device-state' (this file).
28
+ *
29
+ * The two containers are the discriminator. Every semantic call in a plugin
30
+ * begins with the eight characters `deviceState.`; every mechanical call
31
+ * begins with `native.`. There is no line of code where the layer is
32
+ * ambiguous, and no object on which both appear.
33
+ *
34
+ * This is a deliberate departure from
35
+ * Docs/device-bridge-v2/DEVICE_BRIDGE_V2_IMPLEMENTATION_GROUNDING_2026-08-07.md
36
+ * §"Migration strategy", which proposed adding `visual`, `spatial`,
37
+ * `connectivityState`… as fields ON `PluginNative`. See the design note at the
38
+ * bottom of this file for why that layout is rejected and what it costs.
39
+ * ─────────────────────────────────────────────────────────────────────────
40
+ *
41
+ * PROVENANCE MARKERS used throughout. They are not decoration — the freeze
42
+ * really does fix some of this and really does leave the rest open, and an
43
+ * implementor needs to know which is which before changing a line:
44
+ *
45
+ * FROZEN — API_FREEZE 2026-08-07 §15 lists it as a frozen candidate.
46
+ * V0.1 — "v0.1 freeze candidate; may change only when implementation
47
+ * grounding proves a field impossible/non-portable."
48
+ * OPEN — the docs explicitly leave it undecided. Do not quietly close it.
49
+ * DECISION — not in the docs at all; decided here, and reversible.
50
+ */
51
+
52
+ /* eslint-disable @typescript-eslint/no-empty-interface */
53
+
54
+ // ═════════════════════════════════════════════════════════════════════════
55
+ // 1. Common metadata model
56
+ // FROZEN (API_FREEZE §1, §15 "Common metadata model: frozen candidate").
57
+ // Shared verbatim by all eight domains. Do not fork a per-domain variant.
58
+ // ═════════════════════════════════════════════════════════════════════════
59
+
60
+ /** FROZEN (API_FREEZE §1). The eight domain keys. */
61
+ export type DeviceBridgeDomain =
62
+ | 'spatial'
63
+ | 'visual'
64
+ | 'activity'
65
+ | 'deviceContext'
66
+ | 'connectivity'
67
+ | 'capability'
68
+ | 'environment'
69
+ | 'interaction';
70
+
71
+ export type FreshnessState = 'fresh' | 'aging' | 'stale' | 'unknown';
72
+
73
+ /**
74
+ * FROZEN. Both fields are nullable on purpose: "we have no observation at all"
75
+ * has to be expressible without inventing a timestamp.
76
+ *
77
+ * `aging` is a real third state, not a rounding of fresh/stale — a consumer
78
+ * that only branches on fresh-vs-stale is misreading the contract.
79
+ *
80
+ * API_FREEZE §1 "Why observedAt + ageMs": the host may compute `ageMs` at read
81
+ * time, so it can be more accurate than `Date.now() - observedAt` measured on
82
+ * the JS side after a bridge hop. Read `ageMs` when you have it.
83
+ */
84
+ export interface Freshness {
85
+ state: FreshnessState;
86
+ /** Epoch ms of the observation itself. `null` = never observed. */
87
+ observedAt: number | null;
88
+ /** Age at read time. `null` = never observed. */
89
+ ageMs: number | null;
90
+ }
91
+
92
+ export type ConfidenceLevel = 'high' | 'medium' | 'low' | 'unknown';
93
+
94
+ /**
95
+ * FROZEN. `level` is mandatory and ordinal; `score` is optional and exists
96
+ * only "when a stable numeric estimate exists" (API_FREEZE §1).
97
+ *
98
+ * The asymmetry is the point: native must not manufacture 0.5 to fill a field.
99
+ * A missing `score` is information — it says the estimator has no calibrated
100
+ * number, only a bucket.
101
+ */
102
+ export interface Confidence {
103
+ level: ConfidenceLevel;
104
+ /** 0..1. Omitted when no stable numeric estimate exists. */
105
+ score?: number;
106
+ }
107
+
108
+ /**
109
+ * FROZEN. Every snapshot in v2 carries exactly one of these.
110
+ *
111
+ * `freshness` required, `confidence` optional — because some domains report
112
+ * exact OS facts (battery level, audio route) where a confidence is
113
+ * meaningless, and others report inference (activity, indoor/outdoor) where it
114
+ * is mandatory. Cross-domain review: "Freshness is mandatory for dynamic
115
+ * state. Confidence is mandatory for inferred/estimated state; optional for
116
+ * exact OS facts."
117
+ *
118
+ * NOTE (OPEN): freshness is per-SNAPSHOT, never per-field. So a snapshot
119
+ * cannot today say "lighting is fresh but acoustics is stale". The
120
+ * architecture doc raised this for Spatial and Environment and never resolved
121
+ * it. Do not work around it by inventing a second freshness field.
122
+ */
123
+ export interface StateQuality {
124
+ freshness: Freshness;
125
+ confidence?: Confidence;
126
+ }
127
+
128
+ // ── Support / permission / availability / restriction ──────────────────────
129
+ // FROZEN (API_FREEZE §1). Four INDEPENDENT axes. The capability doc is blunt:
130
+ // "Do not collapse all failure modes into a single boolean." A camera can be
131
+ // supported + granted + busy, and each of those three answers leads the caller
132
+ // somewhere different.
133
+
134
+ export type SupportState = 'supported' | 'unsupported' | 'unknown';
135
+
136
+ export type PermissionState =
137
+ | 'granted'
138
+ | 'denied'
139
+ | 'restricted'
140
+ | 'notDetermined'
141
+ | 'limited'
142
+ | 'unavailable'
143
+ | 'unknown';
144
+
145
+ export type AvailabilityState =
146
+ | 'available'
147
+ | 'busy'
148
+ | 'temporarilyUnavailable'
149
+ | 'unavailable'
150
+ | 'unknown';
151
+
152
+ export type RestrictionState =
153
+ | 'none'
154
+ | 'backgroundRestricted'
155
+ | 'systemRestricted'
156
+ | 'policyRestricted'
157
+ | 'lowPowerRestricted'
158
+ | 'unknown';
159
+
160
+ // ── Events ────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * FROZEN (API_FREEZE core rule 4): "Events are meaningful semantic changes,
164
+ * never raw sensor streams." If you find yourself wanting an event per frame
165
+ * or per GPS fix, you are in the wrong layer.
166
+ */
167
+ export interface StateEvent<TName extends string = string, TPayload = unknown> {
168
+ domain: DeviceBridgeDomain;
169
+ name: TName;
170
+ /** Epoch ms. */
171
+ timestamp: number;
172
+ /**
173
+ * OPEN: per-event payload schemas are not specified anywhere in the three
174
+ * design docs. Defaults to `unknown` rather than `any` so a consumer is forced
175
+ * to narrow it rather than read a field that may not exist. A domain that
176
+ * decides its schemas (spatial — see `SpatialStateEvent`) passes `TPayload`,
177
+ * which is the freeze's own second parameter, dropped here until one did.
178
+ */
179
+ payload?: TPayload;
180
+ }
181
+
182
+ export interface Subscription {
183
+ unsubscribe(): void;
184
+ }
185
+
186
+ export type StateListener<TName extends string = string> = (
187
+ event: StateEvent<TName>,
188
+ ) => void;
189
+
190
+ /**
191
+ * VOCABULARY §"Event Naming Rule", expressed as a type.
192
+ *
193
+ * Four domains (visual, activity, spatial, deviceContext) have NO frozen event
194
+ * vocabulary — the vocabulary doc publishes "Initial … Events" lists for
195
+ * connectivity, capability, interaction and environment only. Rather than mint
196
+ * names those docs deliberately did not mint, their events are typed against
197
+ * the naming RULE. A wrong-shaped name is a compile error; the specific
198
+ * vocabulary stays open, as the docs left it — except where a DECISION closes
199
+ * it (spatial, see `SpatialEvent`), and that list is checked against the rule
200
+ * through `AssertConforming`.
201
+ */
202
+ export type ConformingEventName =
203
+ | `${string}Changed`
204
+ | `${string}Started`
205
+ | `${string}Stopped`
206
+ | `${string}Available`
207
+ | `${string}Unavailable`
208
+ | `${string}StateStale`;
209
+
210
+ /**
211
+ * A closed list of names, checked against the naming rule at compile time.
212
+ * Wrap a decided vocabulary in this and a name like `turnCompleted` stops
213
+ * compiling instead of reaching a plugin.
214
+ */
215
+ export type AssertConforming<T extends ConformingEventName> = T;
216
+
217
+ // ── Errors ────────────────────────────────────────────────────────────────
218
+
219
+ /** FROZEN (API_FREEZE §12). */
220
+ export type DeviceBridgeErrorCode =
221
+ | 'UNSUPPORTED'
222
+ | 'PERMISSION_DENIED'
223
+ | 'RESTRICTED'
224
+ | 'BUSY'
225
+ | 'TEMPORARILY_UNAVAILABLE'
226
+ | 'STALE'
227
+ | 'TIMEOUT'
228
+ | 'CANCELLED'
229
+ | 'INTERNAL';
230
+
231
+ /**
232
+ * FROZEN shape (API_FREEZE §12).
233
+ *
234
+ * READ THE SCOPE LINE CAREFULLY. This is for FAILED OPERATIONS —
235
+ * `visual.capture()`, `visual.start()`. It is NOT how the surface reports that
236
+ * a camera is denied or a sensor is missing: "Expected state should still be
237
+ * exposed in snapshots."
238
+ *
239
+ * So: `getSnapshot()` does not throw this. `capture()` and `start()` do.
240
+ * That split is the whole no-fabrication contract in one sentence — a read
241
+ * always returns something honest, an action either happens or explains
242
+ * itself in a typed code.
243
+ */
244
+ export class DeviceBridgeError extends Error {
245
+ readonly code: DeviceBridgeErrorCode;
246
+ readonly domain?: DeviceBridgeDomain;
247
+ /** Whether retrying could plausibly succeed (BUSY yes, UNSUPPORTED no). */
248
+ readonly recoverable?: boolean;
249
+
250
+ constructor(
251
+ code: DeviceBridgeErrorCode,
252
+ message: string,
253
+ opts?: { domain?: DeviceBridgeDomain; recoverable?: boolean },
254
+ ) {
255
+ super(message);
256
+ this.name = 'DeviceBridgeError';
257
+ this.code = code;
258
+ this.domain = opts?.domain;
259
+ this.recoverable = opts?.recoverable;
260
+ }
261
+ }
262
+
263
+ // ── Sessions and the lifecycle pattern ────────────────────────────────────
264
+
265
+ export interface StateSession {
266
+ id: string;
267
+ /** The intent this session was started with. */
268
+ intent: string;
269
+ startedAt: number;
270
+ }
271
+
272
+ export interface StartOptions<TIntent extends string> {
273
+ /**
274
+ * MANDATORY, and that is the single most important line in this file.
275
+ *
276
+ * You cannot open a v2 session without naming what it is FOR. Native then
277
+ * owns sampling rate, resolution, buffering, fusion and power — API_FREEZE
278
+ * core rule 6. `start()` means "maintain state according to this intent",
279
+ * not "open a particular native API" (§2).
280
+ *
281
+ * Contrast the mechanical layer, where `camera.takePhoto()` is a legal,
282
+ * complete, intent-free call. That difference is visible at every call site.
283
+ */
284
+ intent: TIntent;
285
+ /**
286
+ * DECISION (not frozen). §13 requires bounded timeout/cancellation for
287
+ * session starts and says chain STOP must propagate to native, but leaves
288
+ * the plumbing "implementation-specific". `AbortSignal` is what
289
+ * `DeviceToolContext.signal` already carries in this SDK, so reuse it rather
290
+ * than invent a second cancellation idiom.
291
+ */
292
+ signal?: AbortSignal;
293
+ }
294
+
295
+ /**
296
+ * FROZEN pattern (API_FREEZE §2), reproduced as documentation. The eight
297
+ * concrete domains below do NOT extend this — each spells out exactly the
298
+ * methods it has, because §11 warns "domains should not be forced to implement
299
+ * meaningless lifecycle methods" and three domains genuinely have no intent
300
+ * vocabulary at all.
301
+ *
302
+ * Two invariants apply to every domain:
303
+ * - `getSnapshot()` reads CACHED state. It must not trigger expensive raw
304
+ * sensing (no camera open, no network probe, no GPS cold start).
305
+ * - "Multiple plugins may eventually share one native engine; session
306
+ * ownership must not imply duplicate sensor pipelines." (OPEN: the
307
+ * arbitration mechanism is undefined. See DESIGN NOTE 3.)
308
+ */
309
+ export interface StatefulDomain<
310
+ TSnapshot,
311
+ TIntent extends string = never,
312
+ TEvent extends string = string,
313
+ > {
314
+ getSnapshot(): Promise<TSnapshot>;
315
+ start?(options: StartOptions<TIntent>): Promise<StateSession>;
316
+ stop?(sessionId?: string): Promise<void>;
317
+ subscribe?(listener: StateListener<TEvent>): Subscription;
318
+ }
319
+
320
+ /**
321
+ * Why every read is `Promise<T>` even though it is a cached read.
322
+ *
323
+ * Not because it samples — it must not. Because it crosses the RN bridge, and
324
+ * this SDK has no synchronous JSI module today (verified: everything in
325
+ * PluginNative except haptics/connectivity.isOnline is Promise-based). The
326
+ * grounding doc's own target for a warm cached snapshot is p50 < 10 ms /
327
+ * p95 < 30 ms, which is an async-bridge budget, not a sampling budget.
328
+ */
329
+
330
+ // ═════════════════════════════════════════════════════════════════════════
331
+ // 2. Domain 1 — Spatial State
332
+ // ═════════════════════════════════════════════════════════════════════════
333
+
334
+ /**
335
+ * FROZEN (API_FREEZE §3). Copied verbatim, not designed here.
336
+ *
337
+ * This landed as a placeholder first, on the belief that §3's field schema had
338
+ * never been written. It had — the placeholder was a gap in what got read, not
339
+ * a gap in the freeze. Worth recording, because "the docs leave this open" and
340
+ * "I did not find it" produce identical-looking comments and only one of them
341
+ * is a reason to invent a shape.
342
+ */
343
+ export type SpatialIntent =
344
+ | 'navigation'
345
+ | 'fitness'
346
+ | 'geofence'
347
+ | 'lowPowerTracking'
348
+ | 'preciseLocate';
349
+
350
+ /** FROZEN (API_FREEZE §3). */
351
+ export interface SpatialPoint {
352
+ latitude: number;
353
+ longitude: number;
354
+ altitude?: number | null;
355
+ /** Metres. `null` when the platform gives no estimate — never 0, which
356
+ * would read as a perfect fix. */
357
+ accuracyM?: number | null;
358
+ timestamp: number;
359
+ }
360
+
361
+ /** FROZEN (API_FREEZE §3). */
362
+ export interface SpatialSnapshot {
363
+ /** `null` when there is no usable fix. Not a zero coordinate — (0,0) is a
364
+ * real place in the Gulf of Guinea. */
365
+ position: SpatialPoint | null;
366
+ movement: {
367
+ speedMps: number | null;
368
+ bearingDeg: number | null;
369
+ distanceRecentM: number | null;
370
+ state?: 'stationary' | 'moving' | 'unknown';
371
+ };
372
+ orientation: {
373
+ headingDeg: number | null;
374
+ headingAccuracyDeg?: number | null;
375
+ pitchDeg?: number | null;
376
+ rollDeg?: number | null;
377
+ };
378
+ /** Newest last. Empty when nothing has been recorded — the ring is a native
379
+ * concern, and its window is chosen by intent. */
380
+ recentPath: SpatialPoint[];
381
+ changes?: {
382
+ lastMeaningfulTurnDeg?: number | null;
383
+ timestamp?: number | null;
384
+ };
385
+ quality: StateQuality;
386
+ }
387
+
388
+ /**
389
+ * DECISION (not frozen) — the spatial event vocabulary. Six names, all ending
390
+ * in a suffix the naming rule allows, which `AssertConforming` enforces.
391
+ *
392
+ * Drawn from the architecture doc's spatial candidates (meaningful movement,
393
+ * meaningful turn, location quality degraded/recovered, state became stale)
394
+ * and named after the SNAPSHOT fields they report, so an event and a read use
395
+ * one vocabulary: `lastMeaningfulTurnChanged` is `changes.lastMeaningfulTurnDeg`
396
+ * changing. The names an earlier note floated (`turnCompleted`, `arrived`,
397
+ * `headingLost`) are rejected — the first two fail the rule, and "arrived" is a
398
+ * destination, which the vocabulary's Governance puts above Device Bridge.
399
+ */
400
+ export type SpatialEvent = AssertConforming<
401
+ | 'movementStarted'
402
+ | 'movementStopped'
403
+ | 'lastMeaningfulTurnChanged'
404
+ | 'positionConfidenceChanged'
405
+ | 'positionChanged'
406
+ | 'spatialStateStale'
407
+ >;
408
+
409
+ /** A spatial event: the frozen envelope, with its payload always present. */
410
+ type SpatialEventOf<TName extends SpatialEvent, TPayload> =
411
+ StateEvent<TName, TPayload> & { domain: 'spatial'; payload: TPayload };
412
+
413
+ /**
414
+ * DECISION — what each spatial event carries. Only the facts of the
415
+ * transition, in the snapshot's own field names; everything else is one
416
+ * `getSnapshot()` away, which keeps two shapes of the same fact from drifting.
417
+ *
418
+ * `timestamp` on the envelope is when the transition was RECOGNISED. Where a
419
+ * hold delays that on purpose, `since` carries the true onset.
420
+ */
421
+ export type SpatialStateEvent =
422
+ /** `movement.state` became `moving` and held (navigation: 3 s). */
423
+ | SpatialEventOf<'movementStarted', {
424
+ previous: 'stationary' | 'unknown';
425
+ speedMps: number | null;
426
+ bearingDeg: number | null;
427
+ since: number;
428
+ }>
429
+ /** `moving` → `stationary`, held (navigation: 5 s). Never fired on moving →
430
+ * `unknown`: losing the speed is not evidence of stopping. */
431
+ | SpatialEventOf<'movementStopped', { previous: 'moving'; since: number }>
432
+ /** A new completed turn. Signed: positive = clockwise = right. */
433
+ | SpatialEventOf<'lastMeaningfulTurnChanged', { lastMeaningfulTurnDeg: number; timestamp: number }>
434
+ /** The GPS fix band (high ≤10 m, medium ≤50 m, else low) changed and held.
435
+ * The fix alone — a compass calibration change is not a position event. */
436
+ | SpatialEventOf<'positionConfidenceChanged', {
437
+ level: ConfidenceLevel;
438
+ previous: ConfidenceLevel;
439
+ accuracyM: number | null;
440
+ direction: 'dropped' | 'recovered';
441
+ }>
442
+ /** Moved at least max(profile floor, 2 × accuracyM) from the last reported
443
+ * position, so GPS jitter never fires it. The session's first fix fires
444
+ * with `previous: null`. */
445
+ | SpatialEventOf<'positionChanged', {
446
+ position: SpatialPoint;
447
+ previous: SpatialPoint | null;
448
+ distanceM: number | null;
449
+ }>
450
+ /** `quality.freshness.state` became `stale`. Once; re-armed by a fresh read. */
451
+ | SpatialEventOf<'spatialStateStale', { observedAt: number | null; ageMs: number | null }>;
452
+
453
+ export interface SpatialDomain {
454
+ getSnapshot(): Promise<SpatialSnapshot>;
455
+ start(options: StartOptions<SpatialIntent>): Promise<StateSession>;
456
+ stop(sessionId?: string): Promise<void>;
457
+ /**
458
+ * DECISION: the six `SpatialEvent`s, typed per name — narrow on `e.name` and
459
+ * `e.payload` is that event's shape.
460
+ *
461
+ * Events flow only while a spatial session is open; subscribing opens no
462
+ * sensor. They are derived from the same snapshot `getSnapshot()` returns: a
463
+ * listener that re-reads inside the callback sees the snapshot its event came
464
+ * from. A state change must hold, on at least two fixes, before it fires.
465
+ *
466
+ * From a device tool's `ctx.deviceState` this refuses (RESTRICTED): a call
467
+ * has no lifetime to own the listener.
468
+ */
469
+ subscribe(listener: (event: SpatialStateEvent) => void): Subscription;
470
+ }
471
+
472
+ // ═════════════════════════════════════════════════════════════════════════
473
+ // 3. Domain 2 — Visual State
474
+ //
475
+ // Visual is NOT "the camera API". It is a native-maintained visual timeline
476
+ // plus camera/capture state. Everything interpretive — what is in the scene,
477
+ // what the sign says, which corridor to take — sits ABOVE Device Bridge and is
478
+ // not modelled here.
479
+ // ═════════════════════════════════════════════════════════════════════════
480
+
481
+ /** FROZEN (API_FREEZE §4) + VOCABULARY §"Visual Intents". Nine values. */
482
+ export type VisualIntent =
483
+ | 'navigation'
484
+ | 'document'
485
+ | 'ocr'
486
+ | 'shopping'
487
+ | 'inspection'
488
+ | 'accessibility'
489
+ | 'stream'
490
+ | 'highQuality'
491
+ | 'lowPower';
492
+
493
+ /**
494
+ * Media crosses by REFERENCE, never as inline bytes (core rule 3).
495
+ *
496
+ * AN ID AND NOTHING ELSE. The `uri` field is gone (2026-08-16), and its
497
+ * removal answers the question this comment used to record as OPEN — "how does
498
+ * a plugin turn a handle into pixels?" It doesn't. Ever.
499
+ *
500
+ * Frames now live in a native ring keyed by id and are never written to disk.
501
+ * `native.vision.readText/describe/assessQuality` take this id and resolve it
502
+ * INSIDE the trusted layer; the pixels do not cross into plugin code, so a
503
+ * solution's bundle cannot copy, upload or retain them. That is what makes
504
+ * "frames never leave the phone" enforceable rather than a promise.
505
+ *
506
+ * Do not add `uri` or `bytes` back. A plugin that believes it needs the pixels
507
+ * needs a new method on `native.vision` instead — one whose implementation
508
+ * stays on this side of the boundary.
509
+ */
510
+ export interface MediaHandle {
511
+ id: string;
512
+ mimeType?: string;
513
+ /**
514
+ * Epoch ms after which this id no longer resolves — the frame's real
515
+ * eviction time from the native ring, not a guess.
516
+ *
517
+ * Past it, `native.vision` throws rather than returning an empty result:
518
+ * "I cannot look at that frame" and "I looked and found nothing" are
519
+ * different answers and must never be confused by a caller about to speak.
520
+ * In practice a caller rarely hits this, because the timeline is clamped to
521
+ * native retention — see `visualEngine.effectiveWindowMs()`.
522
+ */
523
+ expiresAt?: number | null;
524
+ }
525
+
526
+ /**
527
+ * V0.1. Per-frame usability is CATEGORICAL, not numeric — 'degraded' rather
528
+ * than a blur score. Coarse honest classes beat false precision; the same
529
+ * principle the connectivity doc states outright for connection quality.
530
+ *
531
+ * Every member carries `'unknown'` so a platform that cannot assess blur says
532
+ * so instead of defaulting to 'good'.
533
+ */
534
+ export interface VisualFrameQuality {
535
+ blur?: 'good' | 'degraded' | 'unknown';
536
+ exposure?: 'good' | 'dark' | 'bright' | 'unknown';
537
+ /** `null` = cannot tell. Distinct from `false` = checked, not obstructed. */
538
+ obstructed?: boolean | null;
539
+ }
540
+
541
+ export interface VisualFrameRef {
542
+ handle: MediaHandle;
543
+ timestamp: number;
544
+ orientation?: { headingDeg?: number; pitchDeg?: number; rollDeg?: number };
545
+ quality?: VisualFrameQuality;
546
+ }
547
+
548
+ export interface VisualSnapshot {
549
+ camera: {
550
+ available: boolean;
551
+ /**
552
+ * Independent of `available` on purpose. A camera can be present and
553
+ * permitted (`available: true`) while no session is running
554
+ * (`active: false`) — that is a different situation from a camera the app
555
+ * cannot use at all, and Guide has to say different things about them.
556
+ *
557
+ * DRIFT (flagged, not resolved): the architecture doc's conceptual block
558
+ * also carried `camera.orientation`; the frozen §4 block does not. Not
559
+ * added here.
560
+ */
561
+ active: boolean;
562
+ facing?: 'front' | 'back' | 'unknown';
563
+ };
564
+ /**
565
+ * `null` when there is no usable current frame.
566
+ *
567
+ * This is the anti-fabrication field. The alternative implementation —
568
+ * returning the last frame you happen to still hold — is precisely how a
569
+ * guidance loop ends up describing a corridor the user left thirty seconds
570
+ * ago. If it is not current, it is `null`, and the timeline below is where
571
+ * you go looking for history, knowingly.
572
+ */
573
+ latest: VisualFrameRef | null;
574
+ /**
575
+ * TIME-based, not frame-count-based. Default window ~5s; documented bounded
576
+ * range roughly 1–15s, selected by profile/intent. Native decides how
577
+ * densely to retain frames inside the window and evicts everything outside
578
+ * it automatically.
579
+ *
580
+ * `windowMs` is exposed; FPS knobs are NOT, and must never be added
581
+ * (API_FREEZE §4). If a plugin wants "more frames", it declares a different
582
+ * intent — it does not turn a dial.
583
+ */
584
+ recentTimeline: { windowMs: number; frames: VisualFrameRef[] };
585
+ quality: StateQuality;
586
+ }
587
+
588
+ export interface VisualCaptureResult {
589
+ frame: VisualFrameRef;
590
+ }
591
+
592
+ /**
593
+ * OPEN, worth knowing before you use it: whether `capture({intent})` may pass
594
+ * an intent DIFFERENT from the running session's intent is not addressed
595
+ * anywhere in the three docs.
596
+ */
597
+ export interface VisualCaptureOptions {
598
+ intent: VisualIntent;
599
+ /** §13: capture must support bounded timeout/cancellation. */
600
+ signal?: AbortSignal;
601
+ timeoutMs?: number;
602
+ }
603
+
604
+ export interface VisualDomain {
605
+ /**
606
+ * Cached read. Does NOT open the camera and does NOT take a picture.
607
+ *
608
+ * This is the call a guidance loop makes on every turn. The hot path is:
609
+ * native camera pipeline → rolling native timeline → cached visual state →
610
+ * one snapshot read from RN. "Avoid repeatedly starting the camera or
611
+ * capturing a new image for every plugin-tool call during an interactive
612
+ * session."
613
+ */
614
+ getSnapshot(): Promise<VisualSnapshot>;
615
+
616
+ /** Maintain visual state according to this intent. Not "open the camera". */
617
+ start(options: StartOptions<VisualIntent>): Promise<StateSession>;
618
+
619
+ /**
620
+ * An intentional, one-shot, high-quality-ish acquisition. THROWS
621
+ * DeviceBridgeError on failure — it is an operation, not a read.
622
+ *
623
+ * If you are about to call this once per conversational turn, stop: that is
624
+ * `getSnapshot()`'s job, and doing it here is the frame-timer pattern this
625
+ * whole layer exists to replace.
626
+ */
627
+ capture(options: VisualCaptureOptions): Promise<VisualCaptureResult>;
628
+
629
+ /**
630
+ * OPEN (doc conflict): the architecture doc shows `visual.subscribe(events)`
631
+ * — a filter — while API_FREEZE shows `visual.subscribe(listener)`. The
632
+ * freeze is followed here. Unresolved.
633
+ *
634
+ * OPEN: there is no "Initial Visual Events" list anywhere. The candidate set
635
+ * in prose (camera became available/unavailable, frame quality degraded/
636
+ * recovered, camera appears obstructed, significant scene change, visual
637
+ * session suspended/resumed, visual state became stale) is NOT a minted
638
+ * vocabulary. Typed against the naming rule only.
639
+ */
640
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
641
+
642
+ stop(sessionId?: string): Promise<void>;
643
+ }
644
+
645
+ // ═════════════════════════════════════════════════════════════════════════
646
+ // 4. Domain 3 — Human Activity State
647
+ // ═════════════════════════════════════════════════════════════════════════
648
+
649
+ /** FROZEN (API_FREEZE §5, VOCABULARY §"Human Activity Values"). */
650
+ export type HumanActivity =
651
+ | 'stationary'
652
+ | 'walking'
653
+ | 'running'
654
+ | 'cycling'
655
+ | 'driving'
656
+ | 'unknown';
657
+
658
+ /** FROZEN. Independent of HumanActivity — walking+pocket, driving+mounted. */
659
+ export type DevicePlacement =
660
+ | 'handheld'
661
+ | 'pocket'
662
+ | 'table'
663
+ | 'mounted'
664
+ | 'unknown';
665
+
666
+ /**
667
+ * FROZEN. Note `driving` is BOTH an intent here and a HumanActivity value
668
+ * above. The docs define no relationship between the two and do not flag the
669
+ * collision: declaring intent `driving` does not assert the user is driving.
670
+ */
671
+ export type ActivityIntent =
672
+ | 'navigation'
673
+ | 'fitness'
674
+ | 'driving'
675
+ | 'safety'
676
+ | 'accessibility'
677
+ | 'lowPower';
678
+
679
+ export interface ActivitySnapshot {
680
+ human: {
681
+ state: HumanActivity;
682
+ /** `null` when the start of the current activity is not known. */
683
+ since: number | null;
684
+ /** REQUIRED, unlike StateQuality.confidence — an inferred activity may
685
+ * never be handed over without saying how sure the inference is. */
686
+ confidence: Confidence;
687
+ };
688
+ devicePose: {
689
+ placement: DevicePlacement;
690
+ /** V0.1. These four values appear only in the freeze TS, not in the
691
+ * vocabulary doc — unlike the placement values. Mild drift, flagged. */
692
+ orientation?: 'portrait' | 'landscape' | 'flat' | 'unknown';
693
+ confidence: Confidence;
694
+ };
695
+ /**
696
+ * Advisory only. `likelyNext` is a hint and never a fact.
697
+ * OPEN: transition names are bare `string` — no controlled vocabulary exists
698
+ * anywhere, which sits awkwardly beside "Intents are not arbitrary strings".
699
+ */
700
+ transition?: {
701
+ current?: string | null;
702
+ likelyNext?: string | null;
703
+ confidence?: Confidence;
704
+ };
705
+ /** OPEN: no window length or cap is specified for Activity (Spatial gets
706
+ * ~10–20s, Visual ~5s). Required array — an empty one is a real answer. */
707
+ recentTransitions: Array<{ name: string; timestamp: number }>;
708
+ quality: StateQuality;
709
+ }
710
+
711
+ export interface ActivityDomain {
712
+ getSnapshot(): Promise<ActivitySnapshot>;
713
+ /**
714
+ * OPEN: §11's naming list shows only `activity.getSnapshot()`, and §2 makes
715
+ * start/stop optional. Whether Activity is session-based or a passive
716
+ * always-on domain is genuinely unstated. Included here because the six
717
+ * frozen ActivityIntent values would otherwise have no seam to arrive
718
+ * through — but this is a DECISION, not a reading of the freeze.
719
+ */
720
+ start(options: StartOptions<ActivityIntent>): Promise<StateSession>;
721
+ stop(sessionId?: string): Promise<void>;
722
+ /** OPEN: no "Initial Activity Events" list exists. */
723
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
724
+ }
725
+
726
+ // ═════════════════════════════════════════════════════════════════════════
727
+ // 5. Domain 4 — Device Context
728
+ //
729
+ // SCOPE WARNING. The early architecture draft put lifecycle, audio route and
730
+ // hardware availability here. The freeze REMOVED all three: app foreground/
731
+ // background and active channels are canonicalized under Interaction; hardware
732
+ // support/permission moved to Capability. Frozen Device Context is power +
733
+ // thermal + resources, and nothing else.
734
+ // ═════════════════════════════════════════════════════════════════════════
735
+
736
+ export interface DeviceContextSnapshot {
737
+ power: {
738
+ /** 0..1 FRACTION, not a percentage. `null` = not known. */
739
+ batteryLevel: number | null;
740
+ charging: boolean | null;
741
+ lowPowerMode: boolean | null;
742
+ };
743
+ thermal: {
744
+ state: 'nominal' | 'fair' | 'serious' | 'critical' | 'unknown';
745
+ };
746
+ resources: {
747
+ memoryPressure?: 'normal' | 'warning' | 'critical' | 'unknown';
748
+ storagePressure?: 'normal' | 'warning' | 'critical' | 'unknown';
749
+ };
750
+ /** Confidence is legitimately omitted here — these are exact OS facts, not
751
+ * estimates. Freshness still matters: a populated batteryLevel says nothing
752
+ * about whether it was sampled a second or a minute ago. */
753
+ quality: StateQuality;
754
+ }
755
+
756
+ /**
757
+ * NO INTENT TYPE, and that is deliberate — not an oversight, and not a gap to
758
+ * fill in later. There is no `DeviceContextIntent` in the freeze and no
759
+ * "Device Context Intents" section in the vocabulary, because
760
+ * (cross-domain review) "A domain may expose an intent only if it changes
761
+ * native implementation strategy in a meaningful way." Reading battery and
762
+ * thermal does not.
763
+ *
764
+ * The direction of flow is the other way round: Device Context is an INPUT to
765
+ * how OTHER domains satisfy their intents. Visual intent `navigation` +
766
+ * device context `battery 5% + low-power mode` → native picks a lower-power
767
+ * camera strategy while still honouring the Visual contract. The plugin does
768
+ * not retune anything, and must not be asked to.
769
+ *
770
+ * Do not add `deviceContext.start({intent:'lowPower'})` because five other
771
+ * domains have a lowPower intent.
772
+ */
773
+ export interface DeviceContextDomain {
774
+ getSnapshot(): Promise<DeviceContextSnapshot>;
775
+ /**
776
+ * OPEN: `DeviceContextDomain`'s interface body is never written in the
777
+ * freeze, and no Device Context event names are frozen (the vocabulary has
778
+ * "Initial … Events" for connectivity/capability/interaction/environment
779
+ * only). DECISION: expose subscribe, because the candidate events —
780
+ * low-power mode changed, charging changed, thermal pressure changed — are
781
+ * exactly what an adaptive session needs and polling for them is wasteful.
782
+ *
783
+ * OPEN: "battery crossed significant threshold" and "changed materially"
784
+ * have no numbers anywhere, and Device Context has no profiles to
785
+ * parameterize them.
786
+ */
787
+ subscribe(listener: StateListener<ConformingEventName>): Subscription;
788
+ }
789
+
790
+ // ═════════════════════════════════════════════════════════════════════════
791
+ // 6. Domain 5 — Connectivity State
792
+ // ═════════════════════════════════════════════════════════════════════════
793
+
794
+ /** FROZEN (API_FREEZE §7, VOCABULARY §"Connectivity Intents"). */
795
+ export type ConnectivityIntent =
796
+ | 'lowLatencyConversation'
797
+ | 'interactive'
798
+ | 'bulkTransfer'
799
+ | 'backgroundSync'
800
+ | 'offlineResilient'
801
+ | 'lowPower';
802
+
803
+ /**
804
+ * V0.1, and note it is FLAT — the connectivity design doc sketched a nested
805
+ * shape (internet{…}, quality{…}, recent_history{…}) and the freeze flattened
806
+ * it. Two unreconciled deltas, carried here as-is rather than merged:
807
+ * - `bandwidthClass` appears in the freeze but in neither the design doc nor
808
+ * the vocabulary's Connectivity Values.
809
+ * - there is NO recent-history field, although the design doc requires native
810
+ * to maintain recent transitions and leaves the default window OPEN.
811
+ */
812
+ export interface ConnectivitySnapshot {
813
+ /** NOTE: the design doc says "online / offline / uncertain"; the frozen
814
+ * third value is `unknown`. The two words are never reconciled. */
815
+ reachability: 'online' | 'offline' | 'unknown';
816
+ transport: 'wifi' | 'cellular' | 'ethernet' | 'other' | 'none' | 'unknown';
817
+ qualityClass:
818
+ | 'excellent'
819
+ | 'good'
820
+ | 'degraded'
821
+ | 'poor'
822
+ | 'unusable'
823
+ | 'unknown';
824
+ stability: 'stable' | 'changing' | 'flapping' | 'unknown';
825
+ /** `null` when no recent measurement exists. Explicitly preferred over
826
+ * reporting a stale number: "return unknown rather than stale precision." */
827
+ latencyMs?: number | null;
828
+ bandwidthClass?: 'high' | 'medium' | 'low' | 'unknown';
829
+ metered?: boolean | null;
830
+ constrained?: boolean | null;
831
+ quality: StateQuality;
832
+ }
833
+
834
+ /** FROZEN v0.1 (VOCABULARY §"Initial Connectivity Events"). All ten. */
835
+ export type ConnectivityEvent =
836
+ | 'connectivityOnline'
837
+ | 'connectivityOffline'
838
+ | 'transportChanged'
839
+ | 'connectivityDegraded'
840
+ | 'connectivityRecovered'
841
+ | 'connectivityUnstable'
842
+ | 'connectivityStable'
843
+ | 'meteredStateChanged'
844
+ | 'constrainedStateChanged'
845
+ | 'connectivityStateStale';
846
+
847
+ export interface ConnectivityDomain {
848
+ /**
849
+ * A fast local cached read. It must NOT ping, and must NOT run a bandwidth
850
+ * or speed test. Latency is sampled on native's own schedule, chosen by
851
+ * intent, and cached with a timestamp.
852
+ */
853
+ getSnapshot(): Promise<ConnectivitySnapshot>;
854
+ /**
855
+ * OPEN: `ConnectivityDomain` is named in §11 but never defined, so whether
856
+ * connectivity is session-based is unstated. DECISION: yes — six frozen
857
+ * intents with documented behavioural differences (lowLatencyConversation
858
+ * "prioritize fresh latency knowledge"; lowPower "rely primarily on passive
859
+ * OS signals and minimize active probes") only mean something if there is a
860
+ * way to declare one.
861
+ */
862
+ start(options: StartOptions<ConnectivityIntent>): Promise<StateSession>;
863
+ stop(sessionId?: string): Promise<void>;
864
+ subscribe(listener: StateListener<ConnectivityEvent>): Subscription;
865
+ }
866
+
867
+ // ═════════════════════════════════════════════════════════════════════════
868
+ // 7. Domain 6 — Capability / Permission State
869
+ //
870
+ // The "why" domain. When another snapshot says `unknown`, this is where the
871
+ // reason lives. Descriptive only — it never triggers a permission dialog.
872
+ // ═════════════════════════════════════════════════════════════════════════
873
+
874
+ /**
875
+ * FROZEN. Four independent axes, deliberately not collapsed. The canonical
876
+ * worked example from the capability doc: supported + granted + busy + none —
877
+ * a camera you own, are allowed to use, and cannot have right now.
878
+ */
879
+ export interface CapabilityStatus {
880
+ support: SupportState;
881
+ permission?: PermissionState;
882
+ availability: AvailabilityState;
883
+ restriction?: RestrictionState;
884
+ }
885
+
886
+ export interface CapabilitySnapshot {
887
+ camera: CapabilityStatus;
888
+ microphone: CapabilityStatus;
889
+ /** The only capability the freeze gives scoped detail to. Health and
890
+ * photos/media do NOT get one — see the gap note below. */
891
+ location: CapabilityStatus & {
892
+ precision?: 'precise' | 'approximate' | 'unknown';
893
+ backgroundPermission?: PermissionState;
894
+ };
895
+ motion: CapabilityStatus;
896
+ bluetooth: CapabilityStatus;
897
+ uwb: CapabilityStatus;
898
+ nfc: CapabilityStatus;
899
+ notifications: CapabilityStatus;
900
+ /**
901
+ * GAP (flagged): the capability doc notes health "authorization is often
902
+ * record-type specific rather than a single global permission" and its
903
+ * candidate shape had a `permission_summary`. The frozen §9 gives health a
904
+ * plain CapabilityStatus, so scoped health authorization is NOT
905
+ * representable in v0.1. Its own exit criterion ("scoped permissions are
906
+ * representable") is therefore only partly met.
907
+ */
908
+ health: CapabilityStatus;
909
+ backgroundExecution: CapabilityStatus;
910
+ /** GAP (flagged): photos/media appears in the design doc's native inputs and
911
+ * its full-vs-limited scope discussion, but there is NO photos key in the
912
+ * frozen CapabilitySnapshot. Not added here. */
913
+ quality: StateQuality;
914
+ }
915
+
916
+ /** FROZEN v0.1 (VOCABULARY §"Initial Capability Events"). All ten. */
917
+ export type CapabilityEvent =
918
+ | 'capabilityChanged'
919
+ | 'permissionChanged'
920
+ | 'capabilityAvailable'
921
+ | 'capabilityUnavailable'
922
+ /** NOTE: this pair does not match the documented naming patterns. The docs
923
+ * neither flag nor reconcile it; carried verbatim rather than "fixed". */
924
+ | 'capabilityBusy'
925
+ | 'capabilityFree'
926
+ | 'restrictionChanged'
927
+ | 'locationPrecisionChanged'
928
+ | 'backgroundPermissionChanged'
929
+ | 'capabilityStateStale';
930
+
931
+ /**
932
+ * NO INTENT TYPE — the one domain that is intent-less by explicit reasoning
933
+ * rather than by omission: "Capability State itself should generally be cheap
934
+ * and mostly event-driven, so it does not need many sensing profiles."
935
+ * A candidate `interactive/background/minimal` vocabulary is floated in the
936
+ * design doc and explicitly conditional ("only be kept if implementation
937
+ * proves they add value"). Nothing frozen. Not added.
938
+ *
939
+ * Also NOT here: any `requestPermission()`. Permission-request UX is outside
940
+ * this state engine by design, and a floated generic primitive is future work.
941
+ */
942
+ export interface CapabilityDomain {
943
+ getSnapshot(): Promise<CapabilitySnapshot>;
944
+ subscribe(listener: StateListener<CapabilityEvent>): Subscription;
945
+ }
946
+
947
+ // ═════════════════════════════════════════════════════════════════════════
948
+ // 8. Domain 7 — Environment State
949
+ // ═════════════════════════════════════════════════════════════════════════
950
+
951
+ /** FROZEN (VOCABULARY §"Environment Intents"). */
952
+ export type EnvironmentIntent =
953
+ | 'ambient'
954
+ | 'navigation'
955
+ | 'accessibility'
956
+ | 'proximity'
957
+ | 'safety'
958
+ | 'lowPower';
959
+
960
+ export interface EnvironmentSnapshot {
961
+ /** Privacy is part of the SHAPE, not advice: this says `dim`, never a camera
962
+ * frame; `loud`, never microphone audio; a beacon count, never a BLE
963
+ * payload. Raw audio/images/BLE payloads are not part of this domain. */
964
+ lighting: {
965
+ level: 'dark' | 'dim' | 'normal' | 'bright' | 'veryBright' | 'unknown';
966
+ lux?: number | null;
967
+ confidence?: Confidence;
968
+ };
969
+ acoustics: {
970
+ level: 'quiet' | 'moderate' | 'loud' | 'veryLoud' | 'unknown';
971
+ approximateDb?: number | null;
972
+ confidence?: Confidence;
973
+ };
974
+ /** `uncertain` and `unknown` are different: uncertain = we looked and cannot
975
+ * decide; unknown = we have no basis to decide. */
976
+ enclosure: {
977
+ state: 'indoor' | 'outdoor' | 'uncertain' | 'unknown';
978
+ confidence?: Confidence;
979
+ };
980
+ proximity?: {
981
+ distanceClass?: 'immediate' | 'near' | 'far' | 'unknown';
982
+ confidence?: Confidence;
983
+ };
984
+ /** The only sub-block with no confidence — these are presence booleans, not
985
+ * estimates. Note they cannot distinguish unsupported / denied / switched
986
+ * off; that distinction lives in `capability`. */
987
+ nearbySignals?: {
988
+ bleAvailable?: boolean;
989
+ beaconCount?: number | null;
990
+ uwbAvailable?: boolean;
991
+ nfcAvailable?: boolean;
992
+ };
993
+ quality: StateQuality;
994
+ }
995
+
996
+ /** FROZEN v0.1 (VOCABULARY §"Initial Environment Events"). All ten. */
997
+ export type EnvironmentEvent =
998
+ | 'lightingChanged'
999
+ | 'environmentBecameDark'
1000
+ | 'environmentBecameBright'
1001
+ | 'noiseLevelChanged'
1002
+ | 'environmentBecameLoud'
1003
+ | 'indoorOutdoorChanged'
1004
+ | 'proximityChanged'
1005
+ | 'nearbySignalAppeared'
1006
+ | 'nearbySignalDisappeared'
1007
+ | 'environmentStateStale';
1008
+
1009
+ export interface EnvironmentDomain {
1010
+ getSnapshot(): Promise<EnvironmentSnapshot>;
1011
+ start(options: StartOptions<EnvironmentIntent>): Promise<StateSession>;
1012
+ stop(sessionId?: string): Promise<void>;
1013
+ subscribe(listener: StateListener<EnvironmentEvent>): Subscription;
1014
+ }
1015
+
1016
+ // ═════════════════════════════════════════════════════════════════════════
1017
+ // 9. Domain 8 — Interaction State
1018
+ //
1019
+ // Reports interaction FACTS. It never decides "pause the guide" or "speak now"
1020
+ // — that is orchestration and belongs to the Plugin Tool / Skill / AI.
1021
+ // ═════════════════════════════════════════════════════════════════════════
1022
+
1023
+ export interface InteractionSnapshot {
1024
+ lifecycle: {
1025
+ appState: 'foreground' | 'background' | 'inactive' | 'unknown';
1026
+ screenOn: boolean | null;
1027
+ userActive?: boolean | null;
1028
+ idleDurationMs?: number | null;
1029
+ };
1030
+ audio: {
1031
+ captureActive: boolean | null;
1032
+ playbackActive: boolean | null;
1033
+ route:
1034
+ | 'speaker'
1035
+ | 'earpiece'
1036
+ | 'wiredHeadset'
1037
+ | 'bluetooth'
1038
+ | 'carAudio'
1039
+ | 'other'
1040
+ | 'unknown';
1041
+ /** `none` (checked, nothing interrupting) and `unknown` (no knowledge) are
1042
+ * deliberately different values. */
1043
+ interruption: 'none' | 'temporary' | 'system' | 'unknown';
1044
+ };
1045
+ /** Optional GROUP: a platform that cannot report voice session state omits
1046
+ * the whole block rather than guessing `false` for each field. */
1047
+ voice?: {
1048
+ sessionActive: boolean | null;
1049
+ userSpeaking?: boolean | null;
1050
+ assistantSpeaking?: boolean | null;
1051
+ };
1052
+ /** May report that a camera session is running; must NOT duplicate frame
1053
+ * state. Frames belong to `visual`. */
1054
+ visual?: {
1055
+ cameraSessionActive: boolean | null;
1056
+ };
1057
+ quality: StateQuality;
1058
+ }
1059
+
1060
+ /** FROZEN v0.1 (VOCABULARY §"Initial Interaction Events"). All twelve. */
1061
+ export type InteractionEvent =
1062
+ | 'appStateChanged'
1063
+ | 'screenStateChanged'
1064
+ | 'audioRouteChanged'
1065
+ | 'audioInterruptionStarted'
1066
+ | 'audioInterruptionEnded'
1067
+ | 'voiceSessionStarted'
1068
+ | 'voiceSessionStopped'
1069
+ | 'cameraSessionStarted'
1070
+ | 'cameraSessionStopped'
1071
+ | 'userBecameActive'
1072
+ | 'userBecameIdle'
1073
+ | 'interactionStateStale';
1074
+
1075
+ /**
1076
+ * NO INTENT TYPE. §10 declares only `InteractionSnapshot`, and the vocabulary
1077
+ * — which does publish intents for spatial, visual, activity, connectivity and
1078
+ * environment — has no "Interaction Intents" section. Five profiles are
1079
+ * floated in the design doc, hedged twice ("Likely profiles, if needed") with
1080
+ * no obligation attached to any of them. Not added.
1081
+ */
1082
+ export interface InteractionDomain {
1083
+ getSnapshot(): Promise<InteractionSnapshot>;
1084
+ subscribe(listener: StateListener<InteractionEvent>): Subscription;
1085
+ }
1086
+
1087
+ // ═════════════════════════════════════════════════════════════════════════
1088
+ // 10. Root surface
1089
+ // ═════════════════════════════════════════════════════════════════════════
1090
+
1091
+ /**
1092
+ * FROZEN name (API_FREEZE §11) and FROZEN member names.
1093
+ *
1094
+ * §11's "Recommended naming rule" says to call it `visual.getSnapshot()`, not
1095
+ * `device.visual.*`. Read here as a rule about the WIRE/tool-name convention
1096
+ * (`device.*` stays a human/backend routing convention for callable plugin
1097
+ * tools) rather than a ban on a plugin-prop container. That reading is a
1098
+ * judgement call and is flagged as such — if it is wrong, the fix is to rename
1099
+ * the PROP, never to merge these eight members into `PluginNative`.
1100
+ */
1101
+ export interface DeviceBridgeV2 {
1102
+ readonly spatial: SpatialDomain;
1103
+ readonly visual: VisualDomain;
1104
+ readonly activity: ActivityDomain;
1105
+ readonly deviceContext: DeviceContextDomain;
1106
+ readonly connectivity: ConnectivityDomain;
1107
+ readonly capability: CapabilityDomain;
1108
+ readonly environment: EnvironmentDomain;
1109
+ readonly interaction: InteractionDomain;
1110
+ }
1111
+
1112
+ /** The name plugin authors see. See DESIGN NOTE 1 for why not `deviceBridge`. */
1113
+ export type DeviceState = DeviceBridgeV2;
1114
+
1115
+ // ═════════════════════════════════════════════════════════════════════════
1116
+ // 11. Manifest gate — a SEPARATE declaration from `native`
1117
+ // ═════════════════════════════════════════════════════════════════════════
1118
+
1119
+ /**
1120
+ * DECISION (not in the docs). A second, differently-named manifest field, so a
1121
+ * plugin cannot half-declare its way across the boundary:
1122
+ *
1123
+ * native: { camera: true } → props.device.camera.takePhoto()
1124
+ * stateDomains: ['visual', 'spatial'] → props.deviceState.visual.getSnapshot()
1125
+ *
1126
+ * Declaring `native.camera` grants NOTHING in the semantic layer, and vice
1127
+ * versa. There is no field whose meaning spans both.
1128
+ *
1129
+ * How an UNDECLARED domain must behave — and this is the load-bearing part:
1130
+ * it returns a normal snapshot in which every enum is `unknown`, every
1131
+ * nullable is `null`, and `quality.freshness` is
1132
+ * `{ state:'unknown', observedAt:null, ageMs:null }`. It does NOT throw and it
1133
+ * does NOT return undefined.
1134
+ *
1135
+ * Why that matters here specifically: today's `gatePluginNative` gives an
1136
+ * undeclared capability a proxy whose methods `console.warn` and return
1137
+ * `undefined` (packages/plugin-runtime/src/host.ts:67-76). So
1138
+ * `await native.location.getCurrent()` on an undeclared plugin resolves to
1139
+ * `undefined`, and `pos?.latitude` is `undefined` — which then flows into an
1140
+ * LLM answer as a confident nothing. A silent undefined is a fabrication
1141
+ * vector; a snapshot of explicit unknowns is not.
1142
+ *
1143
+ * The "why" is readable from `capability.getSnapshot()` using an existing
1144
+ * frozen enum member: a manifest-gate denial maps to
1145
+ * `restriction: 'policyRestricted'` — the plugin manifest IS a policy. No new
1146
+ * vocabulary needed. (The docs never consider a manifest gate, so this
1147
+ * mapping is a DECISION, but it uses only frozen values.)
1148
+ */
1149
+ export type StateDomainDeclaration = readonly DeviceBridgeDomain[];
1150
+
1151
+ // ═════════════════════════════════════════════════════════════════════════
1152
+ // 12. Small helpers — the "present does not mean fresh" guard
1153
+ // DECISION. Not frozen, not in any doc. Included because the single most
1154
+ // likely misuse of this API is reading a value and ignoring `quality`, and a
1155
+ // one-line guard is easier to reach for than a correct hand-rolled check.
1156
+ // ═════════════════════════════════════════════════════════════════════════
1157
+
1158
+ const CONFIDENCE_ORDER: Record<ConfidenceLevel, number> = {
1159
+ unknown: 0,
1160
+ low: 1,
1161
+ medium: 2,
1162
+ high: 3,
1163
+ };
1164
+
1165
+ /**
1166
+ * Is this snapshot good enough to act on?
1167
+ *
1168
+ * Deliberately requires the caller to state a budget: there is no universal
1169
+ * "fresh enough", and the docs give no numeric thresholds for any domain
1170
+ * (native chooses sampling behaviour). A guidance loop wants ~1500 ms; a
1171
+ * battery-aware background job is happy with 60 s.
1172
+ *
1173
+ * `unknown` freshness fails: it means nothing was ever observed, which is not
1174
+ * the same as an old-but-real reading and must not be treated as one.
1175
+ */
1176
+ export function isActionable(
1177
+ quality: StateQuality,
1178
+ budget: { maxAgeMs: number; minConfidence?: ConfidenceLevel },
1179
+ ): boolean {
1180
+ const { freshness, confidence } = quality;
1181
+ if (freshness.state === 'unknown' || freshness.state === 'stale') return false;
1182
+ if (freshness.ageMs === null || freshness.ageMs > budget.maxAgeMs) return false;
1183
+ if (budget.minConfidence) {
1184
+ const have = CONFIDENCE_ORDER[confidence?.level ?? 'unknown'];
1185
+ if (have < CONFIDENCE_ORDER[budget.minConfidence]) return false;
1186
+ }
1187
+ return true;
1188
+ }
1189
+
1190
+ /** The honest "we have never observed this" value. Use it instead of
1191
+ * inventing a timestamp when constructing a degraded snapshot. This is the
1192
+ * canonical source of the `unknown` freshness shape — the engine's
1193
+ * `qualityUnknown()` derives from it so the two never drift. */
1194
+ export function neverObserved(): Freshness {
1195
+ return { state: 'unknown', observedAt: null, ageMs: null };
1196
+ }
1197
+
1198
+ /**
1199
+ * One human-readable sentence for why a capability cannot answer, built ONLY
1200
+ * from what the four axes actually say. Returns null when the capability is
1201
+ * fine — so a caller cannot accidentally print a reason that does not exist.
1202
+ */
1203
+ export function explainCapability(status: CapabilityStatus): string | null {
1204
+ if (status.support === 'unsupported') return 'this device does not have it';
1205
+ if (status.permission === 'denied') return 'permission was denied';
1206
+ if (status.permission === 'restricted') return 'permission is restricted';
1207
+ if (status.permission === 'notDetermined') return 'permission has not been asked for yet';
1208
+ if (status.permission === 'limited') return 'only limited access was granted';
1209
+ if (status.restriction === 'policyRestricted') return 'this plugin did not declare it';
1210
+ if (status.restriction === 'backgroundRestricted') return 'the app is in the background';
1211
+ if (status.restriction === 'lowPowerRestricted') return 'the device is in low-power mode';
1212
+ if (status.availability === 'busy') return 'it is in use by something else';
1213
+ if (status.availability === 'temporarilyUnavailable') return 'it is temporarily unavailable';
1214
+ if (status.availability === 'unavailable') return 'it is unavailable';
1215
+ if (status.support === 'unknown' || status.availability === 'unknown') {
1216
+ return 'its state could not be determined';
1217
+ }
1218
+ return null;
1219
+ }
1220
+
1221
+ // ═════════════════════════════════════════════════════════════════════════
1222
+ // 13. Integration seams into the existing SDK
1223
+ // ═════════════════════════════════════════════════════════════════════════
1224
+
1225
+ /**
1226
+ * Additive extension of `PluginProps` (sdk/types.ts:259).
1227
+ *
1228
+ * `deviceState` is OPTIONAL because a host that has not installed a v2
1229
+ * provider must not pretend to have one. `undefined` means "this host has no
1230
+ * semantic layer", which a plugin can check once; it does NOT mean "the
1231
+ * sensors said nothing". Those are different, and collapsing them would
1232
+ * reintroduce exactly the ambiguity this file exists to remove.
1233
+ */
1234
+ export interface PluginPropsV2Extension {
1235
+ readonly deviceState?: DeviceState;
1236
+ }
1237
+
1238
+ /**
1239
+ * Additive extension of `DeviceToolContext` (sdk/types.ts:320-327).
1240
+ *
1241
+ * This is the seam that matters most: device tools are what the AI calls, and
1242
+ * device tools are where a fabricated value reaches a user. The lint rule
1243
+ * proposed alongside this file (`no-mechanical-sensing-in-device-tools`) bans
1244
+ * `ctx.native.camera`, `ctx.native.location` and `ctx.native.connectivity`
1245
+ * inside a `DeviceToolDefinition.run` body — a device tool must reach sensing
1246
+ * through `ctx.deviceState`. UI code is unaffected.
1247
+ */
1248
+ export interface DeviceToolContextV2Extension {
1249
+ readonly deviceState?: DeviceState;
1250
+ }
1251
+
1252
+ // ═════════════════════════════════════════════════════════════════════════
1253
+ // DESIGN NOTES
1254
+ // ═════════════════════════════════════════════════════════════════════════
1255
+
1256
+ /**
1257
+ * DESIGN NOTE 1 — why the prop is `deviceState`, not `deviceBridge`.
1258
+ *
1259
+ * "Device Bridge" already names three different things in this codebase:
1260
+ * - `DeviceBridge`, the telemetry sync class that polls collectors and
1261
+ * pushes to the relay (packages/expo/src/device-bridge/index.ts:60);
1262
+ * - `PluginNative`, which sdk/types.ts:323 documents verbatim as
1263
+ * "Device Bridge — camera, location, sensors, storage";
1264
+ * - the v2 semantic layer in this file.
1265
+ * A fourth use of the word would be the opposite of unmistakable. `deviceState`
1266
+ * says what the object is (state, not actions) and collides with nothing.
1267
+ *
1268
+ *
1269
+ * DESIGN NOTE 2 — why NOT `PluginNative.visual` / `PluginNative.connectivityState`.
1270
+ *
1271
+ * The implementation-grounding doc proposes adding the eight domains as fields
1272
+ * on `PluginNative`, with `connectivityState` to dodge the collision with the
1273
+ * existing `connectivity`. Rejected, for three reasons:
1274
+ *
1275
+ * (a) It puts `native.camera.takePhoto()` and `native.visual.getSnapshot()`
1276
+ * on one object, one keystroke apart in autocomplete. The layer becomes
1277
+ * invisible at the call site, which is the failure being designed out.
1278
+ * (b) `native.connectivity` vs `native.connectivityState` is a confusable
1279
+ * pair by construction — two fields, one object, near-identical names,
1280
+ * different layers, different return shapes. `deviceState.connectivity`
1281
+ * vs `native.connectivity` is disambiguated by the first token instead.
1282
+ * (c) The gate model would have to span both meanings of one manifest field.
1283
+ *
1284
+ * The grounding doc's actual constraint is §14's "Do not create a second
1285
+ * plugin runtime" — and this design does not. Same runtime, same registration,
1286
+ * same bundle pipeline, same props object; one additional namespace on it.
1287
+ * Everything else in that doc's file-by-file plan (resident engines, warm
1288
+ * sessions, reference counting, no pipeline per tool call, the p50<10ms /
1289
+ * p95<30ms snapshot budget) stands unchanged.
1290
+ *
1291
+ *
1292
+ * DESIGN NOTE 3 — what this file does NOT decide, because the docs did not.
1293
+ *
1294
+ * - Per-intent behavioural contracts. The architecture doc MANDATES that each
1295
+ * intent document latency goal, quality target, continuous-vs-one-shot,
1296
+ * rolling-history behaviour, power posture, background behaviour, required
1297
+ * permissions and output shape. No document supplies these for ANY of the
1298
+ * nine visual intents, and there is no equivalent table for activity. The
1299
+ * vocabulary is minted; the semantics behind each name are not. This is the
1300
+ * largest hole in the whole design and no amount of TypeScript closes it.
1301
+ * - Multi-consumer session ownership / reference counting.
1302
+ * - MediaHandle → bytes resolution, and expiry behaviour.
1303
+ * - Whether `visual.capture()` may use a different intent from the session.
1304
+ * - Event vocabularies for visual, activity, deviceContext. (Spatial's is a
1305
+ * DECISION, recorded at `SpatialEvent`, not a reading of the docs.)
1306
+ * - Per-event payload schemas for every domain but spatial
1307
+ * (`SpatialStateEvent`, likewise a DECISION).
1308
+ * - Background/foreground semantics for activity and connectivity (specified
1309
+ * only for visual).
1310
+ * - Numeric thresholds for fresh/aging/stale, and for "materially changed".
1311
+ * - Cross-domain conflict resolution (activity says walking, spatial speed
1312
+ * says vehicle) — explicitly deferred as future State Engine Fusion and
1313
+ * out of v2 scope. A v2 snapshot must be internally coherent; it is NOT
1314
+ * obliged to reconcile itself with another domain.
1315
+ */