@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-PKDQLNBL.mjs +2873 -0
- package/dist/chunk-PKDQLNBL.mjs.map +1 -0
- package/dist/index.d.mts +1759 -8
- package/dist/index.d.ts +1759 -8
- package/dist/index.js +2467 -1321
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +335 -2139
- package/dist/index.mjs.map +1 -1
- package/dist/loader-5LUVXUY2.mjs +9 -0
- package/dist/loader-5LUVXUY2.mjs.map +1 -0
- package/package.json +2 -2
- package/src/bundles/bundleManager.ts +368 -27
- package/src/bundles/integrity.ts +29 -0
- package/src/bundles/storage.ts +40 -0
- package/src/deviceStateHost.ts +197 -0
- package/src/hooks/usePlugin.ts +1 -1
- package/src/host.ts +80 -8
- package/src/index.ts +90 -1
- package/src/pluginRuntime.ts +97 -0
- package/src/sdk/CameraPreview.tsx +94 -0
- package/src/sdk/device-state.ts +1225 -0
- package/src/sdk/index.ts +35 -3
- package/src/sdk/registration.ts +39 -0
- package/src/sdk/types.ts +345 -8
- package/src/sdk/voice.ts +148 -0
- package/src/tools/loader.ts +167 -0
- package/src/tools/platformTools.ts +41 -0
- package/src/tools/registry.ts +183 -0
- package/src/tools/serverCalls.ts +78 -0
- package/src/tools/sessionSync.ts +107 -0
- package/src/voiceHost.ts +126 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient device-tool registration, driven by the auth session (R14).
|
|
3
|
+
*
|
|
4
|
+
* The LOCAL reasoning path must be live BEFORE voice opens — otherwise the only
|
|
5
|
+
* proof of a loadable external skill is a voice-triggered one. So the host wires
|
|
6
|
+
* this to its EXISTING auth-session subscription (no new transport, no plugin
|
|
7
|
+
* special-case): a tenant becoming authenticated loads that tenant's external
|
|
8
|
+
* device-tool bundles (and re-ensures the static platform tools); a tenant
|
|
9
|
+
* SWITCH is a change of tenant while still authenticated, so it clears the old
|
|
10
|
+
* tenant's tools and reloads; sign-out resets the loader latch (the registry
|
|
11
|
+
* itself is cleared by the host's logout teardown, which already calls
|
|
12
|
+
* clearDeviceTools()).
|
|
13
|
+
*
|
|
14
|
+
* The decision is a PURE function so the lifecycle is testable without a network
|
|
15
|
+
* or a device; the effects are injected (same DI shape as the host's logout
|
|
16
|
+
* teardown) so a test drives them with fakes.
|
|
17
|
+
*/
|
|
18
|
+
import { registerPlatformDeviceTools } from './platformTools';
|
|
19
|
+
import { clearDeviceTools } from './registry';
|
|
20
|
+
// NOTE: `./loader` pulls in the bundle manager (react-native, core), so it is
|
|
21
|
+
// imported LAZILY inside the real effects below — keeping this module itself
|
|
22
|
+
// node-testable (a test injects fake effects and never touches that graph).
|
|
23
|
+
|
|
24
|
+
export interface SessionSnapshot {
|
|
25
|
+
authenticated: boolean;
|
|
26
|
+
/** The tenant the session is scoped to (derive it; never a stored copy). */
|
|
27
|
+
tenant: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DeviceToolsAction = 'load' | 'reset' | 'none';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* PURE: what should the device-tool registry do for this session transition?
|
|
34
|
+
* - a new authenticated tenant (initial sign-in OR a switch) → 'load'
|
|
35
|
+
* - the same authenticated tenant again (e.g. a silent token refresh) → 'none'
|
|
36
|
+
* - leaving authenticated (sign-out) → 'reset'
|
|
37
|
+
*/
|
|
38
|
+
export function deviceToolsActionForSession(
|
|
39
|
+
next: SessionSnapshot,
|
|
40
|
+
prevTenant: string | null,
|
|
41
|
+
): { action: DeviceToolsAction; tenant: string | null } {
|
|
42
|
+
const tenant = next.authenticated ? (next.tenant ?? null) : null;
|
|
43
|
+
if (tenant && tenant !== prevTenant) return { action: 'load', tenant };
|
|
44
|
+
if (!tenant && prevTenant) return { action: 'reset', tenant: null };
|
|
45
|
+
return { action: 'none', tenant: prevTenant };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The effects a session transition drives — injected so a test can fake them. */
|
|
49
|
+
export interface DeviceToolsSessionDeps {
|
|
50
|
+
/** Drop the previous tenant's device tools (a switch does not run logout teardown). */
|
|
51
|
+
clear: () => void;
|
|
52
|
+
/** Ensure the static, host-owned platform device tools are present. */
|
|
53
|
+
registerPlatform: () => void;
|
|
54
|
+
/** Forget the "already loaded" latch so a later (non-forced) load runs — sign-out. */
|
|
55
|
+
resetLoader: () => void;
|
|
56
|
+
/** Fire-and-forget reload of the current tenant's external device-tool bundles. */
|
|
57
|
+
load: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const REAL_DEPS: DeviceToolsSessionDeps = {
|
|
61
|
+
clear: clearDeviceTools,
|
|
62
|
+
registerPlatform: registerPlatformDeviceTools,
|
|
63
|
+
// `./loader` reaches react-native via the bundle manager; import it only when
|
|
64
|
+
// an effect actually fires (real app), never at module load (tests).
|
|
65
|
+
resetLoader: () => {
|
|
66
|
+
void import('./loader').then((m) => m.resetDeviceToolLoader());
|
|
67
|
+
},
|
|
68
|
+
load: () => {
|
|
69
|
+
void import('./loader').then((m) => {
|
|
70
|
+
m.resetDeviceToolLoader();
|
|
71
|
+
return m.ensureDeviceToolsLoaded({ force: true });
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let prevTenant: string | null = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Apply a session transition to the device-tool registry. Call from the host's
|
|
80
|
+
* existing auth-session subscription with a derived snapshot. Idempotent per
|
|
81
|
+
* tenant: repeated snapshots for the same authenticated tenant do nothing, so a
|
|
82
|
+
* silent token refresh does not churn the registry. Returns the action taken.
|
|
83
|
+
*/
|
|
84
|
+
export function syncDeviceToolsForSession(
|
|
85
|
+
next: SessionSnapshot,
|
|
86
|
+
deps: DeviceToolsSessionDeps = REAL_DEPS,
|
|
87
|
+
): DeviceToolsAction {
|
|
88
|
+
const { action, tenant } = deviceToolsActionForSession(next, prevTenant);
|
|
89
|
+
prevTenant = tenant;
|
|
90
|
+
if (action === 'load') {
|
|
91
|
+
// A new authenticated tenant: drop the prior tenant's tools, re-seat the
|
|
92
|
+
// static platform tools, then reload this tenant's external bundles (forced).
|
|
93
|
+
deps.clear();
|
|
94
|
+
deps.registerPlatform();
|
|
95
|
+
deps.load();
|
|
96
|
+
} else if (action === 'reset') {
|
|
97
|
+
// Sign-out: the host's logout teardown clears the registry; we only forget
|
|
98
|
+
// the loader latch so a later sign-in reloads.
|
|
99
|
+
deps.resetLoader();
|
|
100
|
+
}
|
|
101
|
+
return action;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Test seam: forget the last-seen tenant so a test starts from signed-out. */
|
|
105
|
+
export function __resetSessionSyncForTest(): void {
|
|
106
|
+
prevTenant = null;
|
|
107
|
+
}
|
package/src/voiceHost.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host DI + gate for `props.voice` — the session counterpart of
|
|
3
|
+
* setDeviceStateProvider / setPluginNativeProvider.
|
|
4
|
+
*
|
|
5
|
+
* The app mounts one live voice session (VoiceSessionHost in @ateam-ai-mobile/ui)
|
|
6
|
+
* and hands it here once at startup via setVoiceProvider. gateVoice then decides,
|
|
7
|
+
* per plugin, whether that session is exposed:
|
|
8
|
+
*
|
|
9
|
+
* - declared (`capabilities: { voice: true }`) + a provider present → the real
|
|
10
|
+
* session.
|
|
11
|
+
* - declared but no provider (no session host in this build) → a refusal that
|
|
12
|
+
* names the reason.
|
|
13
|
+
* - NOT declared → a refusal that names the reason.
|
|
14
|
+
*
|
|
15
|
+
* ABSENCE IS AN ANSWER (spec invariant 6). A denied surface never fakes an
|
|
16
|
+
* `idle` snapshot and never silently no-ops: reads report `state: 'error'` with
|
|
17
|
+
* an explicit `lastError`, and actions throw. A silent `idle` would read to a
|
|
18
|
+
* reasoning loop as "a working session that simply isn't connected yet" — a
|
|
19
|
+
* fabrication, the same class of bug as an undefined that flows into an answer.
|
|
20
|
+
*/
|
|
21
|
+
import type { VoiceAPI, VoiceSnapshot, VoiceSessionProvider } from './sdk/voice';
|
|
22
|
+
|
|
23
|
+
let _provider: VoiceSessionProvider | null = null;
|
|
24
|
+
|
|
25
|
+
/** Host DI: the app supplies its live (owner-aware) voice session. Called once
|
|
26
|
+
* at startup; pass null to detach (e.g. in a build with no voice). */
|
|
27
|
+
export function setVoiceProvider(provider: VoiceSessionProvider | null): void {
|
|
28
|
+
_provider = provider || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether this build has a voice session to expose — for diagnostics. */
|
|
32
|
+
export function implementedVoice(): boolean {
|
|
33
|
+
return !!_provider;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function voiceDenial(why: string): Error {
|
|
37
|
+
const err: any = new Error(`voice: ${why}`);
|
|
38
|
+
// Frozen vocabulary (matches gateDeviceState): a capability/manifest gate IS a
|
|
39
|
+
// policy, so a denial is RESTRICTED — no new error vocabulary.
|
|
40
|
+
err.code = 'RESTRICTED';
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The refused surface. Reads answer honestly ("I am not available, here is
|
|
46
|
+
* why"); actions throw. Deliberately NOT a fake `idle` and NOT a silent no-op.
|
|
47
|
+
*/
|
|
48
|
+
function deniedVoice(why: string): VoiceAPI {
|
|
49
|
+
const fail = () => { throw voiceDenial(why); };
|
|
50
|
+
return {
|
|
51
|
+
async start() { return fail(); },
|
|
52
|
+
stop() { fail(); },
|
|
53
|
+
setMuted() { fail(); },
|
|
54
|
+
prompt() { fail(); },
|
|
55
|
+
observe() { fail(); },
|
|
56
|
+
getSnapshot(): VoiceSnapshot {
|
|
57
|
+
return {
|
|
58
|
+
state: 'error',
|
|
59
|
+
sessionId: null,
|
|
60
|
+
muted: false,
|
|
61
|
+
speaking: false,
|
|
62
|
+
listening: false,
|
|
63
|
+
userSpeaking: false,
|
|
64
|
+
lastUserSpeechEndedAt: null,
|
|
65
|
+
lastError: why,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
subscribe() { return fail(); },
|
|
69
|
+
transcript() { return []; },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A per-plugin VoiceAPI that stamps `pluginId` as the caller on every call, so
|
|
75
|
+
* the owner-aware provider can isolate it. TWO plugins get TWO facades bound to
|
|
76
|
+
* TWO identities; neither can read, control, or attach to the other's live
|
|
77
|
+
* session — even though both declared voice and both share the one physical
|
|
78
|
+
* session. A plugin never supplies its own identity: it is captured here.
|
|
79
|
+
*/
|
|
80
|
+
function scopedVoice(provider: VoiceSessionProvider, pluginId: string): VoiceAPI {
|
|
81
|
+
return {
|
|
82
|
+
start: (opts) => provider.start(opts, pluginId),
|
|
83
|
+
stop: () => provider.stop(pluginId),
|
|
84
|
+
setMuted: (muted) => provider.setMuted(muted, pluginId),
|
|
85
|
+
prompt: (input) => provider.prompt(input, pluginId),
|
|
86
|
+
observe: (text) => provider.observe(text, pluginId),
|
|
87
|
+
getSnapshot: () => provider.getSnapshot(pluginId),
|
|
88
|
+
subscribe: (cb) => provider.subscribe(cb, pluginId),
|
|
89
|
+
transcript: () => provider.transcript(pluginId),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The `voice` a plugin receives, gated by whether it declared the capability AND
|
|
95
|
+
* scoped to its identity.
|
|
96
|
+
*
|
|
97
|
+
* `declared` is `!!capabilities.voice` from the plugin's TRUSTED declaration
|
|
98
|
+
* (resolved in pluginRuntime.ts — the server manifest for a remote plugin, the
|
|
99
|
+
* registry entry for a host-owned one). A downloaded bundle cannot grant itself
|
|
100
|
+
* voice. `pluginId` is the trusted identity the returned facade is bound to.
|
|
101
|
+
*
|
|
102
|
+
* Reading the transcript of a live session is a privacy surface, not just an
|
|
103
|
+
* API: this gate is the only thing between a plugin and everything the user
|
|
104
|
+
* says. Two protections stack: (1) it is coupled to evidence — a live session
|
|
105
|
+
* shows the platform-owned, unsuppressable mic indicator, so there is no path to
|
|
106
|
+
* a transcript without visible proof the mic is/was live; and (2) it is scoped
|
|
107
|
+
* to the owner, so declaring `voice` lets a plugin run ITS OWN conversation, not
|
|
108
|
+
* eavesdrop on another solution's. A missing identity FAILS CLOSED.
|
|
109
|
+
*/
|
|
110
|
+
export function gateVoice(declared: boolean, pluginId: string): VoiceAPI {
|
|
111
|
+
if (!declared) {
|
|
112
|
+
return deniedVoice(
|
|
113
|
+
"not declared in this plugin's capabilities. Add `capabilities: { voice: true }` to use it.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!pluginId) {
|
|
117
|
+
return deniedVoice(
|
|
118
|
+
'no plugin identity — voice fails closed without a caller to scope the session to.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return _provider
|
|
122
|
+
? scopedVoice(_provider, pluginId)
|
|
123
|
+
: deniedVoice(
|
|
124
|
+
'no voice session in this build — the app has not mounted a VoiceSessionHost / called setVoiceProvider.',
|
|
125
|
+
);
|
|
126
|
+
}
|