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

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,41 @@
1
+ /**
2
+ * THE bundle URL of a plugin: the one place it is spelled.
3
+ *
4
+ * Core serves a plugin's React Native bundle at
5
+ * `/api/ui-plugins/<pluginId>/bundle.js`, where the id is URL-encoded because
6
+ * plugin ids contain ':' (`mcp:<connector>:<plugin>`). The bundle manager keys
7
+ * everything it holds by this string: evaluated modules, in-flight downloads and
8
+ * the disk index. To it, two spellings of one plugin's URL are two plugins, which
9
+ * means two downloads, two evaluations and two separate copies of module state.
10
+ *
11
+ * That happened (MGAP-C8). The rendered surface derived the encoded route, while
12
+ * the device-tool loader (6294cc3) used the manifest's `bundleUrl`, which Core
13
+ * fills in UNENCODED. A plugin that was on screen and also had device tools ran
14
+ * as two module instances, and its tools never saw its UI's state.
15
+ *
16
+ * So the URL is DERIVED from the plugin id and never read from a manifest (a
17
+ * connector-supplied URL is ignored, as in ea435149). Every path that builds a
18
+ * plugin's bundle URL, or looks one up, calls this function.
19
+ */
20
+ export function pluginBundleUrl(pluginId: string): string {
21
+ return `/api/ui-plugins/${encodeURIComponent(pluginId)}/bundle.js`;
22
+ }
23
+
24
+ /**
25
+ * Is `url` this plugin's bundle URL, however the id is spelled in it?
26
+ *
27
+ * Core's manifest writes the id unencoded (`/api/ui-plugins/mcp:c:p/bundle.js`).
28
+ * That is the same route as `pluginBundleUrl(pluginId)`, but it is not a key the
29
+ * SDK may use. The check compares decoded forms, so it asks "same route?" without
30
+ * parsing the URL. A URL that cannot be decoded is not a match (fail closed).
31
+ */
32
+ export function isPluginBundleUrl(url: unknown, pluginId: string): boolean {
33
+ if (typeof url !== 'string') return false;
34
+ const canonical = pluginBundleUrl(pluginId);
35
+ if (url === canonical) return true;
36
+ try {
37
+ return decodeURIComponent(url) === decodeURIComponent(canonical);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -6,6 +6,7 @@
6
6
  * just slower on cold start).
7
7
  */
8
8
  import type { PluginGrants } from '../pluginRuntime';
9
+ import { pluginBundleUrl, isPluginBundleUrl } from './bundleUrl';
9
10
 
10
11
  export interface BundleCacheEntry {
11
12
  localPath: string;
@@ -74,6 +75,57 @@ export function selectOwnedCacheEntries<T extends { tenant?: string }>(
74
75
  return (entries || []).filter((e) => !!e && e.tenant === tenant);
75
76
  }
76
77
 
78
+ /**
79
+ * Read the disk index with every entry filed under its plugin's ONE bundle URL
80
+ * (pluginBundleUrl). The SDK reads the index only through this function.
81
+ *
82
+ * Before MGAP-C8, the device-tool loader recorded entries under the manifest's
83
+ * UNENCODED spelling (`/api/ui-plugins/mcp:c:p/bundle.js`), while the rendered
84
+ * surface used the encoded one. A device that ran both can therefore hold one
85
+ * plugin under two keys. Every load now asks for the canonical URL, so an entry
86
+ * stored under the other spelling would never be read again.
87
+ *
88
+ * The decision is to RECOVER such an entry rather than discard it, but only when
89
+ * it provably is the same bundle. An entry moves to the canonical key only when
90
+ * both of these hold:
91
+ * (a) it carries the trusted declaration that names its plugin. That record
92
+ * comes from the manifest, never from the bundle.
93
+ * (b) its URL is that plugin's route in another spelling (isPluginBundleUrl).
94
+ * Its tenant stamp, hash and trusted declaration move with it unchanged. So its
95
+ * ownership (selectOwnedCacheEntries) and its integrity (the hash is re-checked
96
+ * at eval) stay exactly as they were. When both spellings hold an entry, the
97
+ * NEWER one is kept. That is the same last-writer-wins rule one key always had.
98
+ *
99
+ * Everything else stays where it is and is never adopted:
100
+ * - an entry with no trusted declaration (its plugin is unknown without parsing
101
+ * its URL);
102
+ * - a URL that is not the plugin's route at all, such as a connector-declared
103
+ * URL the old loader followed.
104
+ * Such an entry is simply never matched. Its plugin downloads once more under
105
+ * the canonical key, and the hash gate still applies.
106
+ *
107
+ * The index is mutated in place (the manager does read-modify-write on it), and
108
+ * the move reaches disk with the next index write.
109
+ */
110
+ export async function loadCacheIndex(
111
+ storage: BundleStorage = getBundleStorage(),
112
+ ): Promise<Record<string, BundleCacheEntry>> {
113
+ const index = (await storage.loadIndex()) || {};
114
+ for (const [key, entry] of Object.entries(index)) {
115
+ const pluginId = entry?.trusted?.pluginId;
116
+ if (!pluginId) continue;
117
+ const canonical = pluginBundleUrl(pluginId);
118
+ if (key === canonical) continue;
119
+ if (!isPluginBundleUrl(key, pluginId) || !isPluginBundleUrl(entry.bundleUrl, pluginId)) continue;
120
+ delete index[key];
121
+ const held = index[canonical];
122
+ if (!held || (entry.timestamp ?? 0) > (held.timestamp ?? 0)) {
123
+ index[canonical] = { ...entry, bundleUrl: canonical };
124
+ }
125
+ }
126
+ return index;
127
+ }
128
+
77
129
  export interface BundleStorage {
78
130
  loadIndex(): Promise<Record<string, BundleCacheEntry>>;
79
131
  saveIndex(index: Record<string, BundleCacheEntry>): Promise<void>;
@@ -16,6 +16,7 @@ import { nativePluginRegistry } from '../host';
16
16
  import { resolvePluginGrants } from '../pluginRuntime';
17
17
  import type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';
18
18
  import type { DeviceBridgeDomain } from '../sdk/device-state';
19
+ import { pluginBundleUrl } from '../bundles/bundleUrl';
19
20
 
20
21
  interface PluginManifestLike {
21
22
  version?: string;
@@ -64,7 +65,7 @@ function extractBundleUrl(pluginId: string, manifest: PluginManifestLike): { url
64
65
  const hasNativeComponent = !!(render.reactNative?.component || render.component);
65
66
  if (hasNativeComponent) {
66
67
  return {
67
- url: `/api/ui-plugins/${encodeURIComponent(pluginId)}/bundle.js`,
68
+ url: pluginBundleUrl(pluginId),
68
69
  source: injected ? 'ignored-explicit' : 'derived-default',
69
70
  ...(injected && { ignored: injected }),
70
71
  };
package/src/index.ts CHANGED
@@ -19,43 +19,14 @@ export * from './sdk';
19
19
 
20
20
  // Device Bridge v2 — the SEMANTIC layer. `props.deviceState.*`: state names,
21
21
  // snapshots carrying freshness and confidence, and reads that say `unknown`
22
- // rather than throwing.
23
- //
24
- // Exported under its own path rather than star-exported into this namespace,
25
- // deliberately: the whole point of the layer is that a reader can tell at a
26
- // glance which one they are in, and merging both into one import undoes that.
22
+ // rather than throwing. Its plugin-facing types and the isActionable /
23
+ // explainCapability helpers come through `export * from './sdk'` above — the
24
+ // plugin SDK is the one place they are listed, because it is the only module a
25
+ // bundle can require. The whole layer is also available under its own path.
27
26
  export * as deviceStateV2 from './sdk/device-state';
28
- export type {
29
- DeviceState,
30
- DeviceBridgeDomain,
31
- StateQuality,
32
- Freshness,
33
- Confidence,
34
- VisualSnapshot,
35
- SpatialSnapshot,
36
- SpatialPoint,
37
- SpatialIntent,
38
- SpatialEvent,
39
- SpatialStateEvent,
40
- StateEvent,
41
- StateListener,
42
- Subscription,
43
- ActivitySnapshot,
44
- EnvironmentSnapshot,
45
- InteractionSnapshot,
46
- CapabilitySnapshot,
47
- CapabilityStatus,
48
- DeviceContextSnapshot,
49
- ConnectivitySnapshot,
50
- } from './sdk/device-state';
51
- // Device Bridge v2 runtime helpers (VALUES). `neverObserved` is the canonical
52
- // `unknown` freshness the engine's quality module derives from; the others were
53
- // already in the built types but missing from the source root export.
54
- export {
55
- neverObserved,
56
- isActionable,
57
- explainCapability,
58
- } from './sdk/device-state';
27
+ // The canonical `unknown` freshness the engine's quality module derives from —
28
+ // an ENGINE value (the host's device-state domains), not a plugin one.
29
+ export { neverObserved } from './sdk/device-state';
59
30
 
60
31
  // Device Bridge v2 host DI — the semantic counterpart of
61
32
  // setPluginNativeProvider. Separate module on purpose: the moment the two
@@ -148,6 +119,9 @@ export {
148
119
  getNativeBundleUrl,
149
120
  getNativeBundleHash,
150
121
  } from './api/plugins';
122
+ // The one spelling of a plugin's bundle URL (MGAP-C8) — hosts that build or
123
+ // compare one use this, never their own template.
124
+ export { pluginBundleUrl } from './bundles/bundleUrl';
151
125
  export type { PluginSummary, PluginSurfaceMeta } from './api/plugins';
152
126
  export type { PluginManifest as PlatformPluginManifest } from './api/plugins';
153
127
 
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * CameraPreview — the plugin-facing way to SHOW the user what the camera sees.
3
3
  *
4
+ * import { CameraPreview } from '@adas/plugin-sdk';
5
+ * <CameraPreview native={native} /> // native = the plugin's own props.native
6
+ *
7
+ * '@adas/plugin-sdk' is the only SDK module a bundle can require. The same code
8
+ * is published as '@ateam-ai-mobile/plugin-runtime', but that is the host app's
9
+ * package: a bundle that requires it fails at evaluation, by name.
10
+ *
4
11
  * WHY THIS EXISTS RATHER THAN HANDING PLUGINS `expo-camera`.
5
12
  *
6
13
  * The design has exactly two doors to the device: `props.native.*` (mechanical
@@ -1,10 +1,13 @@
1
1
  /**
2
- * @adas/device-state — Device Bridge v2, the SEMANTIC layer.
2
+ * Device state — Device Bridge v2, the SEMANTIC layer.
3
3
  *
4
- * Intended repo path:
4
+ * Repo path:
5
5
  * packages/plugin-runtime/src/sdk/device-state.ts
6
- * Intended module specifier for plugin bundles:
7
- * '@adas/device-state' (NOT '@adas/plugin-sdk')
6
+ * Module specifier for plugin bundles:
7
+ * '@adas/plugin-sdk' — the only SDK module a bundle can require. This file
8
+ * once named '@adas/device-state' here (4110943); the require shim never
9
+ * resolved it, so a bundle that imported it got `{}` and failed later. It is
10
+ * now refused by name at evaluation (bundles/bundleModuleNames.ts).
8
11
  *
9
12
  * ─────────────────────────────────────────────────────────────────────────
10
13
  * WHICH LAYER AM I IN?
@@ -15,7 +18,7 @@
15
18
  * Returns a BARE value. THROWS when it cannot.
16
19
  * Declared in the manifest as `capabilities: { camera: true }`
17
20
  * (the legacy `native: {}` field is still honoured).
18
- * Lives in '@adas/plugin-sdk'.
21
+ * Its types are imported from '@adas/plugin-sdk'.
19
22
  *
20
23
  * props.deviceState.* SEMANTIC. "What is true right now, and how well do
21
24
  * we know it?"
@@ -24,7 +27,8 @@
24
27
  * and able to say `unknown` in every field.
25
28
  * A read NEVER throws for absence.
26
29
  * Declared in the manifest as `stateDomains: ['visual']`.
27
- * Lives in '@adas/device-state' (this file).
30
+ * Defined in this file; its types and helpers are
31
+ * imported from '@adas/plugin-sdk' too.
28
32
  *
29
33
  * The two containers are the discriminator. Every semantic call in a plugin
30
34
  * begins with the eight characters `deviceState.`; every mechanical call
@@ -655,11 +659,20 @@ export type HumanActivity =
655
659
  | 'driving'
656
660
  | 'unknown';
657
661
 
658
- /** FROZEN. Independent of HumanActivity — walking+pocket, driving+mounted. */
662
+ /**
663
+ * FROZEN. Independent of HumanActivity — walking+pocket, driving+mounted.
664
+ *
665
+ * What is returned TODAY: `handheld`, `table` and `unknown` only. Do not write
666
+ * a branch that waits for `pocket` or `mounted`; it will never run. A pocketed
667
+ * phone shows up in the Visual domain's frame quality (`obstructed: true` /
668
+ * `exposure: 'dark'`), not here.
669
+ */
659
670
  export type DevicePlacement =
660
671
  | 'handheld'
672
+ /** RESERVED — never returned until the proximity/light sensor is wired. */
661
673
  | 'pocket'
662
674
  | 'table'
675
+ /** RESERVED — never returned until Bluetooth/CarPlay mount context is wired. */
663
676
  | 'mounted'
664
677
  | 'unknown';
665
678
 
package/src/sdk/index.ts CHANGED
@@ -54,6 +54,7 @@ export type {
54
54
  DeviceToolContext,
55
55
  DeviceToolDefinition,
56
56
  DeviceToolFn,
57
+ DeviceToolFailureReason,
57
58
  } from './types';
58
59
 
59
60
  // Voice — the live conversational SESSION surface (props.voice).
@@ -61,6 +62,8 @@ export type {
61
62
  VoiceAPI,
62
63
  VoiceState,
63
64
  VoiceSnapshot,
65
+ VoiceDeviceToolCall,
66
+ VoiceDeviceToolReason,
64
67
  VoiceTranscriptEntry,
65
68
  VoiceStartOptions,
66
69
  VoicePersona,
@@ -73,6 +76,37 @@ export type {
73
76
  // provider and its own surfaces call it as this caller. See VoiceSessionProvider.
74
77
  export { PLATFORM_VOICE_CALLER } from './voice';
75
78
 
79
+ // Device state — the SEMANTIC layer: the types of what `props.deviceState`
80
+ // returns, and the two pure helpers for reading a snapshot. They are here, in
81
+ // the one module a bundle can require, because a bundle has no other SDK name
82
+ // ('@adas/device-state' never resolved, and is refused by name). What keeps the
83
+ // two layers apart is the CONTAINER a call starts with — `deviceState.` vs
84
+ // `device.` — not the import line.
85
+ export type {
86
+ DeviceState,
87
+ DeviceBridgeDomain,
88
+ StateQuality,
89
+ Freshness,
90
+ Confidence,
91
+ VisualSnapshot,
92
+ SpatialSnapshot,
93
+ SpatialPoint,
94
+ SpatialIntent,
95
+ SpatialEvent,
96
+ SpatialStateEvent,
97
+ StateEvent,
98
+ StateListener,
99
+ Subscription,
100
+ ActivitySnapshot,
101
+ EnvironmentSnapshot,
102
+ InteractionSnapshot,
103
+ CapabilitySnapshot,
104
+ CapabilityStatus,
105
+ DeviceContextSnapshot,
106
+ ConnectivitySnapshot,
107
+ } from './device-state';
108
+ export { isActionable, explainCapability } from './device-state';
109
+
76
110
  // RN primitives (plugins import from the SDK, not from react-native)
77
111
  export {
78
112
  View,
package/src/sdk/types.ts CHANGED
@@ -767,6 +767,19 @@ export interface DeviceToolContext {
767
767
 
768
768
  export type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
769
769
 
770
+ /**
771
+ * Why running a device tool produced no result (`callDeviceTool`'s
772
+ * `{ ok: false, reason }`). The one list: the registry returns these, and the
773
+ * voice snapshot's `lastDeviceTool.reason` carries them unchanged.
774
+ * - `not_found` — no tool is registered under that name.
775
+ * - `no_context` — the tool's device context could not be built.
776
+ * - `timeout` — the tool did not finish within the call's timeout.
777
+ * - `failed` — the tool threw; the error is its message.
778
+ * A tool that RETURNS a value has succeeded as far as the platform is concerned,
779
+ * even if the value describes a failure — throw to report one.
780
+ */
781
+ export type DeviceToolFailureReason = 'not_found' | 'no_context' | 'timeout' | 'failed';
782
+
770
783
  export interface DeviceToolDefinition {
771
784
  /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
772
785
  name: string;
package/src/sdk/voice.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
20
20
  */
21
+ import type { DeviceToolFailureReason } from './types';
21
22
 
22
23
  /** The session lifecycle — identical vocabulary to the built-in `CallState`. */
23
24
  export type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
@@ -29,6 +30,41 @@ export interface VoiceTranscriptEntry {
29
30
  ts: number;
30
31
  }
31
32
 
33
+ /**
34
+ * Why a device-tool call the session received did not produce a result.
35
+ * - the tool RAN and did not return: `not_found` | `no_context` | `timeout` |
36
+ * `failed` (it threw) — see DeviceToolFailureReason;
37
+ * - the phone REFUSED it before running: `not_ready` (Core's tool allowset for
38
+ * this session had not arrived yet — transient, a retry can succeed) or
39
+ * `not_authorized` (not allowed in this session — final);
40
+ * - `invalid`: the call itself was malformed (it named no tool, or carried no
41
+ * correlationId to answer on) — not run.
42
+ */
43
+ export type VoiceDeviceToolReason = DeviceToolFailureReason | 'not_ready' | 'not_authorized' | 'invalid';
44
+
45
+ /**
46
+ * One finished device-tool call, as `VoiceSnapshot.lastDeviceTool` reports it.
47
+ * `name` and `at` keep their original meaning, so a reader of just those two is
48
+ * unaffected.
49
+ */
50
+ export type VoiceDeviceToolCall = {
51
+ /** The tool the model called ('' if the call named none — see `invalid`). */
52
+ name: string;
53
+ /** Epoch ms the call REACHED the phone (not when it finished — that is at + ms). */
54
+ at: number;
55
+ /** Milliseconds from arrival to outcome (≈0 for a refusal). */
56
+ ms: number;
57
+ } & (
58
+ | { ok: true; reason: null; error: null }
59
+ | {
60
+ ok: false;
61
+ reason: VoiceDeviceToolReason;
62
+ /** What went wrong, clipped to 200 characters: tool errors can echo the
63
+ * call's arguments, which may carry what the user said. */
64
+ error: string;
65
+ }
66
+ );
67
+
32
68
  /** A point-in-time read of the session — what a reasoning loop polls. */
33
69
  export interface VoiceSnapshot {
34
70
  state: VoiceState;
@@ -52,10 +88,18 @@ export interface VoiceSnapshot {
52
88
  /** The last error, if `state === 'error'`; also carries a refusal reason when
53
89
  * this surface was handed to a plugin that did not declare `voice`. */
54
90
  lastError: string | null;
55
- /** The most recent LOCAL device-tool call the model made through the session
56
- * (name + epoch ms), or null if none yet. Diagnostic: proves whether the
57
- * model's call_device_tool actually reached the phone vs was never made. */
58
- lastDeviceTool: { name: string; at: number } | null;
91
+ /**
92
+ * The most recently FINISHED device-tool call the model made through this
93
+ * session, with its outcome — or null if none has finished yet. Set once the
94
+ * outcome is known (ran, refused, malformed, threw, timed out), never on
95
+ * arrival, so a refused call can no longer look like one that ran.
96
+ *
97
+ * SCOPED to the caller: a plugin sees only calls to tools IT registered (never
98
+ * another solution's tool names or errors, even on its own call); the platform
99
+ * sees every call. Cleared when the session ends or a new one starts.
100
+ * Diagnostic — every solution's debug view gets "why did that call fail" free.
101
+ */
102
+ lastDeviceTool: VoiceDeviceToolCall | null;
59
103
  /** The sessionId the Core allowset is bound to. Diagnostic: authorization
60
104
  * requires it to equal `sessionId`; a mismatch is why tools refuse. */
61
105
  authorizedSessionId: string | null;
@@ -13,7 +13,7 @@
13
13
  import { listPlugins, getPlugin, getNativeBundleUrl, getNativeBundleHash } from '../api/plugins';
14
14
  import { bundleManager } from '../bundles/bundleManager';
15
15
  import { listDeviceTools } from './registry';
16
- import { getBundleStorage, selectOwnedCacheEntries } from '../bundles/storage';
16
+ import { getBundleStorage, loadCacheIndex, selectOwnedCacheEntries } from '../bundles/storage';
17
17
  import { resolvePluginGrants } from '../pluginRuntime';
18
18
  import { getTenant } from '@ateam-ai-mobile/core';
19
19
 
@@ -142,7 +142,9 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
142
142
  */
143
143
  async function loadFromDiskCache(): Promise<string[]> {
144
144
  try {
145
- const index = await getBundleStorage().loadIndex();
145
+ // Canonical keys only (loadCacheIndex): an entry an older build filed under
146
+ // the manifest's unencoded spelling is recovered under pluginBundleUrl.
147
+ const index = await loadCacheIndex(getBundleStorage());
146
148
  const entries = Object.values(index || {});
147
149
  if (entries.length === 0) return [];
148
150
 
@@ -15,7 +15,7 @@
15
15
  import { createNativeBridge } from '../bridge/native';
16
16
  import { getPluginNative, gatePluginNative } from '../host';
17
17
  import { gateDeviceState, withoutSubscribe } from '../deviceStateHost';
18
- import type { DeviceToolContext, DeviceToolDefinition, NativeCapabilities } from '../sdk/types';
18
+ import type { DeviceToolContext, DeviceToolDefinition, DeviceToolFailureReason, NativeCapabilities } from '../sdk/types';
19
19
  import type { DeviceBridgeDomain } from '../sdk/device-state';
20
20
 
21
21
  export interface DeviceToolSummary {
@@ -27,7 +27,7 @@ export interface DeviceToolSummary {
27
27
 
28
28
  export type DeviceToolOutcome =
29
29
  | { ok: true; result: any }
30
- | { ok: false; error: string; reason: 'not_found' | 'no_context' | 'timeout' | 'failed' };
30
+ | { ok: false; error: string; reason: DeviceToolFailureReason };
31
31
 
32
32
  interface Entry {
33
33
  pluginId: string;