@graineai/inapp-react-native 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Graine AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @graineai/inapp-react-native
2
+
3
+ A Graine AI agent that runs **inside** your React Native app — it sees the
4
+ screen your customer is on, and can act on it.
5
+
6
+ Android and iOS from one package.
7
+
8
+ ```bash
9
+ npm install @graineai/inapp-react-native
10
+ ```
11
+
12
+ Full documentation: **https://app.graine.ai/docs/in-app**
13
+
14
+ ---
15
+
16
+ ## Quick start
17
+
18
+ ```tsx
19
+ import { GraineProvider, GraineLauncher } from "@graineai/inapp-react-native";
20
+
21
+ export default function App() {
22
+ return (
23
+ <GraineProvider
24
+ baseUrl="https://app.graine.ai"
25
+ publishableKey="pk_live_…"
26
+ variables={{ customer_name: user.firstName }}
27
+ >
28
+ <RootNavigator />
29
+ <GraineLauncher accent="#318CE7" title="Assistant" />
30
+ </GraineProvider>
31
+ );
32
+ }
33
+ ```
34
+
35
+ Your publishable key is in the Graine dashboard under **Install & domains**.
36
+
37
+ ## Tell the agent what the customer can see
38
+
39
+ One hook, beside the state your screen already holds:
40
+
41
+ ```tsx
42
+ useGraineScreen({
43
+ screen: "kyc_pan",
44
+ title: "PAN verification",
45
+ journey: { name: "KYC", step: 2, of: 4 },
46
+ fields: [
47
+ { name: "pan", label: "PAN Number", value: pan,
48
+ status: error ? "invalid" : pan ? "filled" : "empty", error },
49
+ ],
50
+ });
51
+ ```
52
+
53
+ The description is withdrawn when the component unmounts, so the agent can never
54
+ discuss a screen the customer has left.
55
+
56
+ ## Let the agent act
57
+
58
+ Declare the action on your agent in the dashboard, then implement it where it
59
+ belongs:
60
+
61
+ ```tsx
62
+ useGraineAction("fill_pan_from_aadhaar", async () => {
63
+ const value = await lookupPanFromAadhaar();
64
+ setPan(value);
65
+ return { status: "ok", data: { pan: value } };
66
+ });
67
+ ```
68
+
69
+ Answer honestly — `ok` makes the agent tell the customer the change is done.
70
+ A refusal with a reason is often more useful:
71
+
72
+ ```tsx
73
+ return { status: "refused", message: "PAN is locked after submission." };
74
+ ```
75
+
76
+ Answer within about five seconds. For slower work, return immediately and report
77
+ the outcome through the next `useGraineScreen` update.
78
+
79
+ ## Voice
80
+
81
+ ```tsx
82
+ <GraineProvider baseUrl="…" publishableKey="pk_live_…" voice>
83
+ ```
84
+
85
+ ```tsx
86
+ const { listening, start, stop } = useGraineVoice(adapter, {
87
+ onTranscript: (text, final) => final && setHeard(text),
88
+ });
89
+ ```
90
+
91
+ Audio is a pluggable four-method `AudioAdapter`, so this package never forces a
92
+ particular native audio module into your build. `liveAudioStreamAdapter` wraps
93
+ `react-native-live-audio-stream`.
94
+
95
+ Capture must be **16 kHz mono PCM16**. Interruption is handled for you.
96
+
97
+ ## Privacy
98
+
99
+ Identifiers are masked on the device before any screen data is sent — PAN,
100
+ Aadhaar, card and account numbers, phone numbers and email addresses. The agent
101
+ is told whether a field is filled, empty or rejected, not what is in it.
102
+
103
+ Add your own formats once, at startup:
104
+
105
+ ```ts
106
+ import { addMaskRule } from "@graineai/inapp-react-native/mask";
107
+
108
+ addMaskRule(/POL-\d{6}/, "[POLICY]");
109
+ ```
110
+
111
+ The SDK never reads the screen by itself — it reports only the fields you pass
112
+ to `useGraineScreen`. There is no screen recording and no accessibility
113
+ scraping. It never opens the microphone until you call `start()`, and never
114
+ requests a permission on your behalf.
115
+
116
+ > **A publishable key inside a mobile app is public.** Anyone can extract it
117
+ > from an APK or IPA, and the web domain allowlist does not apply to mobile
118
+ > apps. Rate limits, single-use connection tickets and your account's credit
119
+ > limits are what bound it. See the
120
+ > [security notes](https://app.graine.ai/docs/in-app/privacy) before launch.
121
+
122
+ ## Requirements
123
+
124
+ - React Native 0.68+
125
+ - React 17+
126
+
127
+ ## API
128
+
129
+ | | |
130
+ |---|---|
131
+ | `GraineProvider` | Connects the agent. Set `voice` for spoken conversations. |
132
+ | `useGraineAgent()` | Connection state, transcript, and `send()`. |
133
+ | `useGraineScreen(context)` | Report what this screen shows. |
134
+ | `useGraineAction(name, handler)` | Implement one action the agent may call. |
135
+ | `useGraineVoice(adapter, opts)` | Microphone, playback and barge-in. |
136
+ | `GraineLauncher` | Optional default UI. Replace it with your own. |
137
+ | `addMaskRule(pattern, token)` | Mask an app-specific identifier format. |
138
+
139
+ `GraineInAppClient` is exported for apps that want the transport without the
140
+ React layer.
141
+
142
+ ## Support
143
+
144
+ https://app.graine.ai/docs/in-app
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,64 @@
1
+ import { type ActionHandler, type AppEvent, type ScreenContext } from "./protocol";
2
+ export declare const SUBPROTOCOL = "graine.embed.v1";
3
+ export interface GraineInAppOptions {
4
+ baseUrl: string;
5
+ publishableKey: string;
6
+ variables?: Record<string, string>;
7
+ stallAfterMs?: number;
8
+ voice?: boolean;
9
+ WebSocketImpl?: any;
10
+ fetchImpl?: typeof fetch;
11
+ }
12
+ type Listener = (payload: any) => void;
13
+ export interface SessionConfig {
14
+ agentId: string;
15
+ webcallAgentId?: string;
16
+ wsHost?: string;
17
+ appearance?: Record<string, any>;
18
+ variables?: Record<string, string>;
19
+ components?: any[];
20
+ }
21
+ export declare class GraineInAppClient {
22
+ readonly opts: GraineInAppOptions;
23
+ config: SessionConfig | null;
24
+ transport: "gateway" | "direct" | null;
25
+ private ws;
26
+ private listeners;
27
+ private actions;
28
+ private screen;
29
+ private screenSeq;
30
+ private screenSince;
31
+ private contextTimer;
32
+ private pendingScreen;
33
+ private stallTimer;
34
+ private stallReportedFor;
35
+ private pingTimer;
36
+ private agentSpeaking;
37
+ private bargedIn;
38
+ constructor(opts: GraineInAppOptions);
39
+ on(event: string, fn: Listener): () => void;
40
+ private emit;
41
+ private get fetch();
42
+ session(): Promise<SessionConfig>;
43
+ private ticket;
44
+ connect(): Promise<void>;
45
+ private send;
46
+ private onFrame;
47
+ private runAction;
48
+ registerAction(name: string, handler: ActionHandler): () => void;
49
+ get availableActions(): string[];
50
+ setScreen(context: ScreenContext | null): void;
51
+ reportEvent(event: AppEvent): void;
52
+ private scheduleScreen;
53
+ private flushScreen;
54
+ private armStall;
55
+ noteInteraction(): void;
56
+ say(text: string): boolean;
57
+ sendAudio(base64: string, sampleRate?: number): boolean;
58
+ endUtterance(): boolean;
59
+ get isAgentSpeaking(): boolean;
60
+ submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
61
+ private stopTimers;
62
+ close(): void;
63
+ }
64
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,330 @@
1
+ import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol";
2
+ import { maskDeep } from "./mask";
3
+ export const SUBPROTOCOL = "graine.embed.v1";
4
+ const PING_MS = 25000;
5
+ export class GraineInAppClient {
6
+ constructor(opts) {
7
+ this.config = null;
8
+ this.transport = null;
9
+ this.ws = null;
10
+ this.listeners = new Map();
11
+ this.actions = new Map();
12
+ this.screen = null;
13
+ this.screenSeq = 0;
14
+ this.screenSince = 0;
15
+ this.contextTimer = null;
16
+ this.pendingScreen = false;
17
+ this.stallTimer = null;
18
+ this.stallReportedFor = null;
19
+ this.pingTimer = null;
20
+ this.agentSpeaking = false;
21
+ this.bargedIn = false;
22
+ if (!opts?.publishableKey)
23
+ throw new Error("[Graine] publishableKey is required.");
24
+ if (!opts?.baseUrl)
25
+ throw new Error("[Graine] baseUrl is required.");
26
+ this.opts = { stallAfterMs: 45000, ...opts };
27
+ }
28
+ on(event, fn) {
29
+ if (!this.listeners.has(event))
30
+ this.listeners.set(event, new Set());
31
+ this.listeners.get(event).add(fn);
32
+ return () => this.listeners.get(event)?.delete(fn);
33
+ }
34
+ emit(event, payload) {
35
+ for (const fn of this.listeners.get(event) ?? []) {
36
+ try {
37
+ fn(payload);
38
+ }
39
+ catch (err) {
40
+ console.error(`[Graine] listener for "${event}" threw`, err);
41
+ }
42
+ }
43
+ }
44
+ get fetch() {
45
+ return this.opts.fetchImpl ?? globalThis.fetch;
46
+ }
47
+ async session() {
48
+ const res = await this.fetch(`${this.opts.baseUrl}/api/embed/session`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
52
+ });
53
+ const data = await res.json().catch(() => ({}));
54
+ if (!res.ok)
55
+ throw new Error(data?.error || `Embed rejected (${res.status})`);
56
+ this.config = data;
57
+ return this.config;
58
+ }
59
+ async ticket() {
60
+ try {
61
+ const res = await this.fetch(`${this.opts.baseUrl}/api/embed/ticket`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
65
+ });
66
+ if (!res.ok)
67
+ return null;
68
+ return await res.json();
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async connect() {
75
+ if (!this.config)
76
+ await this.session();
77
+ const agentId = this.config.webcallAgentId || this.config.agentId;
78
+ const t = await this.ticket();
79
+ let url;
80
+ let protocols;
81
+ if (t?.wsUrl && t.ticket) {
82
+ url = t.wsUrl;
83
+ protocols = [SUBPROTOCOL, t.ticket];
84
+ this.transport = "gateway";
85
+ }
86
+ else {
87
+ const host = (this.config.wsHost || "wss://ws.graine.ai").replace(/\/$/, "");
88
+ const mode = this.opts.voice ? "webcall=1" : "text_chat=1";
89
+ url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
90
+ this.transport = "direct";
91
+ }
92
+ const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
93
+ const ws = new WS(url, protocols);
94
+ this.ws = ws;
95
+ await new Promise((resolve, reject) => {
96
+ const timer = setTimeout(() => reject(new Error("Socket did not open in time")), 15000);
97
+ ws.onopen = () => {
98
+ clearTimeout(timer);
99
+ resolve();
100
+ };
101
+ ws.onerror = (e) => {
102
+ clearTimeout(timer);
103
+ reject(new Error(e?.message || "Socket error"));
104
+ };
105
+ });
106
+ ws.onmessage = (evt) => this.onFrame(evt);
107
+ ws.onclose = (e) => {
108
+ this.stopTimers();
109
+ this.emit("close", { code: e?.code, reason: e?.reason });
110
+ };
111
+ this.send({
112
+ type: "init",
113
+ meta_data: { context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) } },
114
+ });
115
+ this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
116
+ if (this.screen)
117
+ this.flushScreen();
118
+ this.emit("open", { transport: this.transport });
119
+ }
120
+ send(obj) {
121
+ if (!this.ws || this.ws.readyState !== 1)
122
+ return false;
123
+ try {
124
+ this.ws.send(JSON.stringify(obj));
125
+ return true;
126
+ }
127
+ catch (err) {
128
+ console.error("[Graine] send failed", err);
129
+ return false;
130
+ }
131
+ }
132
+ onFrame(evt) {
133
+ let msg;
134
+ try {
135
+ msg = JSON.parse(typeof evt?.data === "string" ? evt.data : String(evt?.data));
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ switch (msg.type) {
141
+ case "text":
142
+ this.emit("chunk", msg.data);
143
+ break;
144
+ case "widget":
145
+ this.emit("widget", msg.widget);
146
+ break;
147
+ case "app_action":
148
+ void this.runAction(msg.action);
149
+ break;
150
+ case "audio":
151
+ this.emit("audio", {
152
+ data: msg.data,
153
+ sampleRate: msg.meta_info?.sample_rate ?? 16000,
154
+ });
155
+ break;
156
+ case "beginning_of_stream":
157
+ this.agentSpeaking = true;
158
+ this.emit("agent_speaking", true);
159
+ break;
160
+ case "end_of_stream":
161
+ this.agentSpeaking = false;
162
+ this.emit("agent_speaking", false);
163
+ break;
164
+ case "clear":
165
+ case "interruption":
166
+ this.agentSpeaking = false;
167
+ this.emit("clear");
168
+ break;
169
+ case "interim_transcript_received":
170
+ this.emit("transcript", { text: msg.data ?? "", final: false });
171
+ break;
172
+ case "transcript":
173
+ this.emit("transcript", { text: msg.data ?? "", final: true });
174
+ break;
175
+ case "pong":
176
+ case "ack":
177
+ break;
178
+ default:
179
+ this.emit("frame", msg);
180
+ }
181
+ }
182
+ async runAction(request) {
183
+ const reply = (result) => this.send({ type: "app_action_result", action_id: request.action_id, ...result });
184
+ const handler = this.actions.get(request.name);
185
+ if (!handler) {
186
+ reply({
187
+ status: "refused",
188
+ message: `This app build does not implement "${request.name}".`,
189
+ });
190
+ return;
191
+ }
192
+ this.emit("action", request);
193
+ try {
194
+ const result = await Promise.race([
195
+ Promise.resolve(handler(request.arguments ?? {})),
196
+ new Promise((_, rej) => setTimeout(() => rej(new Error("handler_timeout")), ACTION_DEADLINE_MS - 500)),
197
+ ]);
198
+ reply(result && typeof result === "object" ? result : { status: "ok" });
199
+ }
200
+ catch (err) {
201
+ const timedOut = err?.message === "handler_timeout";
202
+ if (!timedOut)
203
+ console.error(`[Graine] action "${request.name}" threw`, err);
204
+ reply({
205
+ status: "error",
206
+ message: timedOut
207
+ ? `"${request.name}" did not finish in time.`
208
+ : `"${request.name}" failed: ${err?.message ?? "unknown error"}`,
209
+ });
210
+ }
211
+ }
212
+ registerAction(name, handler) {
213
+ this.actions.set(name, handler);
214
+ this.scheduleScreen();
215
+ return () => {
216
+ this.actions.delete(name);
217
+ this.scheduleScreen();
218
+ };
219
+ }
220
+ get availableActions() {
221
+ return [...this.actions.keys()];
222
+ }
223
+ setScreen(context) {
224
+ const changedScreen = context?.screen !== this.screen?.screen;
225
+ this.screen = context;
226
+ if (changedScreen) {
227
+ this.screenSince = Date.now();
228
+ this.stallReportedFor = null;
229
+ this.armStall();
230
+ }
231
+ this.scheduleScreen();
232
+ }
233
+ reportEvent(event) {
234
+ this.send({ type: "app_event", event });
235
+ }
236
+ scheduleScreen() {
237
+ if (this.contextTimer) {
238
+ this.pendingScreen = true;
239
+ return;
240
+ }
241
+ if (!this.flushScreen())
242
+ return;
243
+ this.contextTimer = setTimeout(() => {
244
+ this.contextTimer = null;
245
+ if (this.pendingScreen) {
246
+ this.pendingScreen = false;
247
+ this.scheduleScreen();
248
+ }
249
+ }, CONTEXT_THROTTLE_MS);
250
+ }
251
+ flushScreen() {
252
+ if (!this.screen)
253
+ return false;
254
+ const { availableActions, ...rest } = this.screen;
255
+ return this.send({
256
+ type: "app_context",
257
+ context: maskDeep({ ...rest, idle_ms: Date.now() - this.screenSince }),
258
+ available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
259
+ seq: ++this.screenSeq,
260
+ });
261
+ }
262
+ armStall() {
263
+ clearTimeout(this.stallTimer);
264
+ const after = this.opts.stallAfterMs ?? 0;
265
+ if (!after || !this.screen)
266
+ return;
267
+ const screenId = this.screen.screen;
268
+ this.stallTimer = setTimeout(() => {
269
+ if (this.stallReportedFor === screenId)
270
+ return;
271
+ this.stallReportedFor = screenId;
272
+ const title = this.screen?.title ?? screenId;
273
+ const stuck = (this.screen?.fields ?? []).find((f) => f.status === "invalid" || f.error || f.status === "empty");
274
+ this.reportEvent({
275
+ name: "stalled_on_step",
276
+ detail: stuck
277
+ ? `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s. ` +
278
+ `The "${stuck.label ?? stuck.name}" field is ${stuck.error ? `showing: ${stuck.error}` : stuck.status}.`
279
+ : `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s.`,
280
+ });
281
+ }, after);
282
+ }
283
+ noteInteraction() {
284
+ this.screenSince = Date.now();
285
+ this.armStall();
286
+ }
287
+ say(text) {
288
+ return this.send({ type: "text", data: text });
289
+ }
290
+ sendAudio(base64, sampleRate = 16000) {
291
+ if (this.agentSpeaking && !this.bargedIn) {
292
+ this.bargedIn = true;
293
+ this.send({ type: "barge_in" });
294
+ }
295
+ else if (!this.agentSpeaking) {
296
+ this.bargedIn = false;
297
+ }
298
+ return this.send({
299
+ type: "audio",
300
+ data: base64,
301
+ meta_info: { format: "pcm", sample_rate: sampleRate, encoding: "base64" },
302
+ });
303
+ }
304
+ endUtterance() {
305
+ return this.send({ type: "utterance_end" });
306
+ }
307
+ get isAgentSpeaking() {
308
+ return this.agentSpeaking;
309
+ }
310
+ submitWidget(widgetId, data, summary) {
311
+ this.send({ type: "widget_result", widget_id: widgetId, data });
312
+ if (summary)
313
+ this.say(summary);
314
+ }
315
+ stopTimers() {
316
+ clearInterval(this.pingTimer);
317
+ clearTimeout(this.stallTimer);
318
+ clearTimeout(this.contextTimer);
319
+ this.pingTimer = this.stallTimer = this.contextTimer = null;
320
+ }
321
+ close() {
322
+ this.stopTimers();
323
+ try {
324
+ this.ws?.close();
325
+ }
326
+ catch {
327
+ }
328
+ this.ws = null;
329
+ }
330
+ }
@@ -0,0 +1,6 @@
1
+ export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
2
+ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
3
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
+ export { addMaskRule, maskDeep, maskString } from "./mask";
5
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, } from "./react-native/index";
6
+ export { GraineLauncher, type GraineLauncherProps } from "./react-native/ui";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { GraineInAppClient } from "./client";
2
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
+ export { addMaskRule, maskDeep, maskString } from "./mask";
4
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
5
+ export { GraineLauncher } from "./react-native/ui";
package/dist/mask.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function maskString(input: string): string;
2
+ export declare function addMaskRule(pattern: RegExp, token?: string): void;
3
+ export declare function clearMaskRules(): void;
4
+ export declare function maskDeep<T>(value: T): T;
package/dist/mask.js ADDED
@@ -0,0 +1,38 @@
1
+ const MASKS = [
2
+ [/\b[A-Z]{5}[0-9]{4}[A-Z]\b/g, "[PAN]"],
3
+ [/[\w.+-]+@[\w-]+\.[\w.]{2,}/g, "[EMAIL]"],
4
+ [/(^|[^\d-])((?:\d[ -]?){13,19})(?![\d-])/g, "$1[CARD]"],
5
+ [/(^|[^\d+])((?:\+91[-\s]?)?[6-9]\d{9})(?!\d)/g, "$1[PHONE]"],
6
+ [/(^|[^\d+])(\d{4}\s?\d{4}\s?\d{4})(?!\d)/g, "$1[AADHAAR]"],
7
+ ];
8
+ export function maskString(input) {
9
+ let out = input;
10
+ for (const [pattern, token] of MASKS)
11
+ out = out.replace(pattern, token);
12
+ return out;
13
+ }
14
+ const custom = [];
15
+ export function addMaskRule(pattern, token = "[REDACTED]") {
16
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
17
+ custom.push([new RegExp(pattern.source, flags), token]);
18
+ }
19
+ export function clearMaskRules() {
20
+ custom.length = 0;
21
+ }
22
+ export function maskDeep(value) {
23
+ if (typeof value === "string") {
24
+ let out = maskString(value);
25
+ for (const [pattern, token] of custom)
26
+ out = out.replace(pattern, token);
27
+ return out;
28
+ }
29
+ if (Array.isArray(value))
30
+ return value.map(maskDeep);
31
+ if (value && typeof value === "object") {
32
+ const out = {};
33
+ for (const [k, v] of Object.entries(value))
34
+ out[k] = maskDeep(v);
35
+ return out;
36
+ }
37
+ return value;
38
+ }
@@ -0,0 +1,60 @@
1
+ export interface ScreenField {
2
+ name: string;
3
+ label?: string;
4
+ value?: string | number | null;
5
+ status?: "empty" | "filled" | "invalid" | "pending" | "locked";
6
+ error?: string;
7
+ }
8
+ export interface ScreenContext {
9
+ screen: string;
10
+ title?: string;
11
+ journey?: {
12
+ name: string;
13
+ step?: number;
14
+ of?: number;
15
+ };
16
+ fields?: ScreenField[];
17
+ error?: string;
18
+ data?: Record<string, unknown>;
19
+ availableActions?: string[];
20
+ }
21
+ export interface AppContextFrame {
22
+ type: "app_context";
23
+ context: ScreenContext & {
24
+ idle_ms?: number;
25
+ };
26
+ available_actions?: string[];
27
+ seq: number;
28
+ }
29
+ export interface AppEvent {
30
+ name: string;
31
+ detail?: string;
32
+ cooldown_seconds?: number;
33
+ max_per_session?: number;
34
+ }
35
+ export interface AppEventFrame {
36
+ type: "app_event";
37
+ event: AppEvent;
38
+ }
39
+ export interface AppActionRequest {
40
+ action_id: string;
41
+ name: string;
42
+ kind?: string;
43
+ arguments: Record<string, unknown>;
44
+ }
45
+ export interface AppActionFrame {
46
+ type: "app_action";
47
+ action: AppActionRequest;
48
+ }
49
+ export interface AppActionResult {
50
+ status: "ok" | "error" | "refused";
51
+ data?: Record<string, unknown>;
52
+ message?: string;
53
+ }
54
+ export interface AppActionResultFrame extends AppActionResult {
55
+ type: "app_action_result";
56
+ action_id: string;
57
+ }
58
+ export type ActionHandler = (args: Record<string, any>) => AppActionResult | void | Promise<AppActionResult | void>;
59
+ export declare const ACTION_DEADLINE_MS = 6000;
60
+ export declare const CONTEXT_THROTTLE_MS = 400;
@@ -0,0 +1,2 @@
1
+ export const ACTION_DEADLINE_MS = 6000;
2
+ export const CONTEXT_THROTTLE_MS = 400;
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { GraineInAppClient, type GraineInAppOptions } from "../client";
3
+ import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
4
+ import type { ActionHandler, ScreenContext } from "../protocol";
5
+ interface GraineContextValue {
6
+ client: GraineInAppClient;
7
+ connected: boolean;
8
+ connecting: boolean;
9
+ error: string | null;
10
+ open: boolean;
11
+ setOpen: (open: boolean) => void;
12
+ messages: Turn[];
13
+ widgets: any[];
14
+ send: (text: string) => void;
15
+ }
16
+ export interface Turn {
17
+ role: "agent" | "customer";
18
+ text: string;
19
+ live?: boolean;
20
+ }
21
+ export interface GraineProviderProps extends GraineInAppOptions {
22
+ children: React.ReactNode;
23
+ autoConnect?: boolean;
24
+ onProactive?: (text: string) => void;
25
+ }
26
+ export declare function GraineProvider({ children, autoConnect, onProactive, ...options }: GraineProviderProps): React.JSX.Element;
27
+ export declare function useGraineAgent(): GraineContextValue;
28
+ export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
29
+ listening: boolean;
30
+ agentSpeaking: boolean;
31
+ start: () => Promise<void>;
32
+ stop: () => Promise<void>;
33
+ };
34
+ export declare function useGraineScreen(context: ScreenContext | null): void;
35
+ export declare function useGraineAction(name: string, handler: ActionHandler): void;
36
+ export {};
@@ -0,0 +1,132 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { GraineInAppClient } from "../client";
4
+ import { VoiceSession } from "../voice";
5
+ const Ctx = createContext(null);
6
+ export function GraineProvider({ children, autoConnect = true, onProactive, ...options }) {
7
+ const clientRef = useRef(null);
8
+ if (!clientRef.current)
9
+ clientRef.current = new GraineInAppClient(options);
10
+ const client = clientRef.current;
11
+ const [connected, setConnected] = useState(false);
12
+ const [connecting, setConnecting] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ const [open, setOpen] = useState(false);
15
+ const [messages, setMessages] = useState([]);
16
+ const [widgets, setWidgets] = useState([]);
17
+ const proactiveRef = useRef(onProactive);
18
+ proactiveRef.current = onProactive;
19
+ const openRef = useRef(open);
20
+ openRef.current = open;
21
+ useEffect(() => {
22
+ const offs = [
23
+ client.on("open", () => {
24
+ setConnected(true);
25
+ setConnecting(false);
26
+ setError(null);
27
+ }),
28
+ client.on("close", () => setConnected(false)),
29
+ client.on("chunk", (text) => setMessages((prev) => {
30
+ const last = prev[prev.length - 1];
31
+ if (last?.role === "agent" && last.live) {
32
+ return [...prev.slice(0, -1), { ...last, text: last.text + text }];
33
+ }
34
+ if (prev[prev.length - 1]?.role !== "customer") {
35
+ proactiveRef.current?.(text);
36
+ if (!openRef.current)
37
+ setOpen(true);
38
+ }
39
+ return [...prev, { role: "agent", text, live: true }];
40
+ })),
41
+ client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
42
+ ];
43
+ if (autoConnect) {
44
+ setConnecting(true);
45
+ client.connect().catch((err) => {
46
+ setConnecting(false);
47
+ console.error("[Graine]", err?.message ?? err);
48
+ setError(err?.message ?? "Could not start the agent.");
49
+ });
50
+ }
51
+ return () => {
52
+ offs.forEach((off) => off());
53
+ client.close();
54
+ };
55
+ }, [client, autoConnect]);
56
+ const value = useMemo(() => ({
57
+ client,
58
+ connected,
59
+ connecting,
60
+ error,
61
+ open,
62
+ setOpen,
63
+ messages,
64
+ widgets,
65
+ send: (text) => {
66
+ if (!text.trim())
67
+ return;
68
+ setMessages((prev) => [
69
+ ...prev.map((m) => (m.live ? { ...m, live: false } : m)),
70
+ { role: "customer", text },
71
+ ]);
72
+ client.noteInteraction();
73
+ client.say(text);
74
+ },
75
+ }), [client, connected, connecting, error, open, messages, widgets]);
76
+ return _jsx(Ctx.Provider, { value: value, children: children });
77
+ }
78
+ function useGraine() {
79
+ const ctx = useContext(Ctx);
80
+ if (!ctx)
81
+ throw new Error("[Graine] useGraine* must be used inside <GraineProvider>.");
82
+ return ctx;
83
+ }
84
+ export function useGraineAgent() {
85
+ return useGraine();
86
+ }
87
+ export function useGraineVoice(adapter, options = {}) {
88
+ const { client } = useGraine();
89
+ const [listening, setListening] = useState(false);
90
+ const [agentSpeaking, setAgentSpeaking] = useState(false);
91
+ const optionsRef = useRef(options);
92
+ optionsRef.current = options;
93
+ const sessionRef = useRef(null);
94
+ if (adapter && !sessionRef.current) {
95
+ sessionRef.current = new VoiceSession(client, adapter, {
96
+ onTranscript: (t, f) => optionsRef.current.onTranscript?.(t, f),
97
+ onAgentSpeaking: (speaking) => {
98
+ setAgentSpeaking(speaking);
99
+ optionsRef.current.onAgentSpeaking?.(speaking);
100
+ },
101
+ });
102
+ }
103
+ useEffect(() => () => { void sessionRef.current?.stop(); }, []);
104
+ return {
105
+ listening,
106
+ agentSpeaking,
107
+ start: async () => {
108
+ if (!sessionRef.current)
109
+ throw new Error("[Graine] useGraineVoice needs an audio adapter.");
110
+ await sessionRef.current.start();
111
+ setListening(true);
112
+ },
113
+ stop: async () => {
114
+ await sessionRef.current?.stop();
115
+ setListening(false);
116
+ },
117
+ };
118
+ }
119
+ export function useGraineScreen(context) {
120
+ const { client } = useGraine();
121
+ const serialised = JSON.stringify(context ?? null);
122
+ useEffect(() => {
123
+ client.setScreen(context);
124
+ return () => client.setScreen(null);
125
+ }, [client, serialised]);
126
+ }
127
+ export function useGraineAction(name, handler) {
128
+ const { client } = useGraine();
129
+ const handlerRef = useRef(handler);
130
+ handlerRef.current = handler;
131
+ useEffect(() => client.registerAction(name, (args) => handlerRef.current(args)), [client, name]);
132
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ export interface GraineLauncherProps {
3
+ accent?: string;
4
+ title?: string;
5
+ bottom?: number;
6
+ }
7
+ export declare function GraineLauncher({ accent, title, bottom, }: GraineLauncherProps): React.JSX.Element | null;
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { ActivityIndicator, Animated, Easing, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
4
+ import { useGraineAgent } from "./index";
5
+ export function GraineLauncher({ accent = "#318CE7", title = "Assistant", bottom = 24, }) {
6
+ const { connected, connecting, error, open, setOpen, messages, send } = useGraineAgent();
7
+ const [draft, setDraft] = useState("");
8
+ const scrollRef = useRef(null);
9
+ const [unread, setUnread] = useState(0);
10
+ useEffect(() => {
11
+ if (!open && messages[messages.length - 1]?.role === "agent")
12
+ setUnread((n) => n + 1);
13
+ if (open)
14
+ setUnread(0);
15
+ }, [messages, open]);
16
+ useEffect(() => {
17
+ if (open)
18
+ requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
19
+ }, [messages, open]);
20
+ const slide = useRef(new Animated.Value(0)).current;
21
+ useEffect(() => {
22
+ Animated.timing(slide, {
23
+ toValue: open ? 1 : 0,
24
+ duration: 180,
25
+ easing: Easing.out(Easing.quad),
26
+ useNativeDriver: true,
27
+ }).start();
28
+ }, [open, slide]);
29
+ if (error)
30
+ return null;
31
+ const submit = () => {
32
+ const text = draft.trim();
33
+ if (!text)
34
+ return;
35
+ setDraft("");
36
+ send(text);
37
+ };
38
+ return (_jsxs(_Fragment, { children: [open && (_jsxs(Animated.View, { style: [
39
+ styles.panel,
40
+ {
41
+ bottom: bottom + 68,
42
+ opacity: slide,
43
+ transform: [{ translateY: slide.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }) }],
44
+ },
45
+ ], children: [_jsxs(View, { style: [styles.header, { backgroundColor: accent }], children: [_jsx(Text, { style: styles.headerTitle, children: title }), _jsxs(View, { style: styles.headerRight, children: [connected ? _jsx(View, { style: styles.liveDot }) : connecting ? _jsx(ActivityIndicator, { size: "small", color: "#fff" }) : null, _jsx(Pressable, { onPress: () => setOpen(false), hitSlop: 12, accessibilityLabel: "Close assistant", children: _jsx(Text, { style: styles.close, children: "\u2715" }) })] })] }), _jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
46
+ styles.bubble,
47
+ m.role === "agent"
48
+ ? styles.agentBubble
49
+ : [styles.customerBubble, { backgroundColor: accent }],
50
+ ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: "#9aa0a6", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), style: [styles.sendButton, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], accessibilityLabel: "Send", children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), _jsxs(Pressable, { onPress: () => setOpen(!open), style: [styles.launcher, { backgroundColor: accent, bottom }], accessibilityLabel: open ? "Close assistant" : "Open assistant", accessibilityRole: "button", children: [_jsx(Text, { style: styles.launcherIcon, children: open ? "✕" : "✦" }), !open && unread > 0 && _jsx(View, { style: styles.badge })] })] }));
51
+ }
52
+ const styles = StyleSheet.create({
53
+ launcher: {
54
+ position: "absolute",
55
+ right: 20,
56
+ width: 56,
57
+ height: 56,
58
+ borderRadius: 28,
59
+ alignItems: "center",
60
+ justifyContent: "center",
61
+ elevation: 8,
62
+ shadowColor: "#000",
63
+ shadowOpacity: 0.22,
64
+ shadowRadius: 10,
65
+ shadowOffset: { width: 0, height: 4 },
66
+ },
67
+ launcherIcon: { color: "#fff", fontSize: 22, lineHeight: 26 },
68
+ badge: {
69
+ position: "absolute",
70
+ top: 12,
71
+ right: 12,
72
+ width: 10,
73
+ height: 10,
74
+ borderRadius: 5,
75
+ backgroundColor: "#ff3b30",
76
+ borderWidth: 2,
77
+ borderColor: "#fff",
78
+ },
79
+ panel: {
80
+ position: "absolute",
81
+ right: 20,
82
+ left: 20,
83
+ maxHeight: 460,
84
+ backgroundColor: "#fff",
85
+ borderRadius: 18,
86
+ overflow: "hidden",
87
+ elevation: 12,
88
+ shadowColor: "#000",
89
+ shadowOpacity: 0.18,
90
+ shadowRadius: 24,
91
+ shadowOffset: { width: 0, height: 10 },
92
+ },
93
+ header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 14 },
94
+ headerTitle: { color: "#fff", fontWeight: "600", fontSize: 15 },
95
+ headerRight: { flexDirection: "row", alignItems: "center", gap: 12 },
96
+ liveDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#34d399" },
97
+ close: { color: "#fff", fontSize: 16 },
98
+ transcript: { maxHeight: 320 },
99
+ transcriptInner: { padding: 12, gap: 8 },
100
+ empty: { color: "#9aa0a6", fontSize: 13, lineHeight: 19, padding: 8 },
101
+ bubble: { maxWidth: "86%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
102
+ agentBubble: { alignSelf: "flex-start", backgroundColor: "#f1f3f4", borderBottomLeftRadius: 5 },
103
+ customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: 5 },
104
+ agentText: { color: "#202124", fontSize: 14, lineHeight: 20 },
105
+ customerText: { color: "#fff", fontSize: 14, lineHeight: 20 },
106
+ composer: {
107
+ flexDirection: "row",
108
+ alignItems: "center",
109
+ gap: 8,
110
+ padding: 10,
111
+ borderTopWidth: StyleSheet.hairlineWidth,
112
+ borderTopColor: "#e8eaed",
113
+ },
114
+ input: {
115
+ flex: 1,
116
+ paddingHorizontal: 12,
117
+ paddingVertical: Platform.OS === "ios" ? 10 : 6,
118
+ backgroundColor: "#f1f3f4",
119
+ borderRadius: 20,
120
+ fontSize: 14,
121
+ color: "#202124",
122
+ },
123
+ sendButton: { width: 36, height: 36, borderRadius: 18, alignItems: "center", justifyContent: "center" },
124
+ sendLabel: { color: "#fff", fontSize: 17, lineHeight: 20 },
125
+ });
@@ -0,0 +1,25 @@
1
+ import type { GraineInAppClient } from "./client";
2
+ export declare const CAPTURE_SAMPLE_RATE = 16000;
3
+ export interface AudioAdapter {
4
+ startCapture(onChunk: (base64: string) => void): Promise<void> | void;
5
+ stopCapture(): Promise<void> | void;
6
+ play(pcm16: Int16Array, sampleRate: number): void;
7
+ clear(): void;
8
+ }
9
+ export declare function base64ToPcm16(b64: string): Int16Array;
10
+ export interface VoiceSessionOptions {
11
+ onTranscript?: (text: string, final: boolean) => void;
12
+ onAgentSpeaking?: (speaking: boolean) => void;
13
+ }
14
+ export declare class VoiceSession {
15
+ private client;
16
+ private adapter;
17
+ private options;
18
+ private offs;
19
+ private capturing;
20
+ constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
21
+ get active(): boolean;
22
+ start(): Promise<void>;
23
+ stop(): Promise<void>;
24
+ }
25
+ export declare function liveAudioStreamAdapter(LiveAudioStream: any, sink: Pick<AudioAdapter, "play" | "clear">): AudioAdapter;
package/dist/voice.js ADDED
@@ -0,0 +1,84 @@
1
+ export const CAPTURE_SAMPLE_RATE = 16000;
2
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ export function base64ToPcm16(b64) {
4
+ const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
5
+ const bytes = new Uint8Array((clean.length * 3) >> 2);
6
+ let byte = 0;
7
+ let acc = 0;
8
+ let bits = 0;
9
+ for (let i = 0; i < clean.length; i++) {
10
+ acc = (acc << 6) | B64.indexOf(clean[i]);
11
+ bits += 6;
12
+ if (bits >= 8) {
13
+ bits -= 8;
14
+ bytes[byte++] = (acc >> bits) & 0xff;
15
+ }
16
+ }
17
+ const samples = new Int16Array(byte >> 1);
18
+ for (let i = 0; i < samples.length; i++) {
19
+ samples[i] = (bytes[i * 2] | (bytes[i * 2 + 1] << 8)) << 16 >> 16;
20
+ }
21
+ return samples;
22
+ }
23
+ export class VoiceSession {
24
+ constructor(client, adapter, options = {}) {
25
+ this.client = client;
26
+ this.adapter = adapter;
27
+ this.options = options;
28
+ this.offs = [];
29
+ this.capturing = false;
30
+ }
31
+ get active() {
32
+ return this.capturing;
33
+ }
34
+ async start() {
35
+ if (this.capturing)
36
+ return;
37
+ this.capturing = true;
38
+ this.offs = [
39
+ this.client.on("audio", ({ data, sampleRate }) => {
40
+ this.adapter.play(base64ToPcm16(data), sampleRate);
41
+ }),
42
+ this.client.on("clear", () => {
43
+ this.adapter.clear();
44
+ this.options.onAgentSpeaking?.(false);
45
+ }),
46
+ this.client.on("agent_speaking", (speaking) => this.options.onAgentSpeaking?.(speaking)),
47
+ this.client.on("transcript", ({ text, final }) => {
48
+ if (final)
49
+ this.client.noteInteraction();
50
+ this.options.onTranscript?.(text, final);
51
+ }),
52
+ ];
53
+ await this.adapter.startCapture((base64) => this.client.sendAudio(base64, CAPTURE_SAMPLE_RATE));
54
+ }
55
+ async stop() {
56
+ if (!this.capturing)
57
+ return;
58
+ this.capturing = false;
59
+ this.offs.forEach((off) => off());
60
+ this.offs = [];
61
+ this.adapter.clear();
62
+ await this.adapter.stopCapture();
63
+ }
64
+ }
65
+ export function liveAudioStreamAdapter(LiveAudioStream, sink) {
66
+ return {
67
+ async startCapture(onChunk) {
68
+ LiveAudioStream.init({
69
+ sampleRate: CAPTURE_SAMPLE_RATE,
70
+ channels: 1,
71
+ bitsPerSample: 16,
72
+ audioSource: 6,
73
+ bufferSize: 1280,
74
+ });
75
+ LiveAudioStream.on("data", onChunk);
76
+ LiveAudioStream.start();
77
+ },
78
+ stopCapture() {
79
+ LiveAudioStream.stop();
80
+ },
81
+ play: sink.play.bind(sink),
82
+ clear: sink.clear.bind(sink),
83
+ };
84
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@graineai/inapp-react-native",
3
+ "version": "0.1.0",
4
+ "description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./mask": {
17
+ "types": "./dist/mask.d.ts",
18
+ "default": "./dist/mask.js"
19
+ },
20
+ "./client": {
21
+ "types": "./dist/client.d.ts",
22
+ "default": "./dist/client.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
32
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
33
+ "prepublishOnly": "npm run build && node scripts/verify-package-clean.mjs"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=17",
37
+ "react-native": ">=0.68"
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "^18.2.0",
41
+ "react": "^18.2.0",
42
+ "react-native": "^0.74.0",
43
+ "typescript": "^5.4.0"
44
+ },
45
+ "keywords": [
46
+ "graine",
47
+ "voice-ai",
48
+ "in-app-agent",
49
+ "react-native",
50
+ "android",
51
+ "ios"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=18"
58
+ },
59
+ "homepage": "https://app.graine.ai/docs/in-app",
60
+ "bugs": {
61
+ "url": "https://app.graine.ai/docs/in-app"
62
+ }
63
+ }