@graineai/inapp-react-native 0.23.0 → 0.24.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/INTEGRATION.md CHANGED
@@ -507,6 +507,7 @@ useGraineEvents((e) => {
507
507
  case "conversation_ended": analytics.track("agent_call_ended", { reason: e.reason }); break;
508
508
  case "agent_volunteered": analytics.track("agent_spoke_first"); break;
509
509
  case "action_requested": analytics.track("agent_action", { name: e.name }); break;
510
+ case "action_completed": analytics.track("agent_action_result", { name: e.name, status: e.status }); break;
510
511
  }
511
512
  });
512
513
  ```
@@ -765,6 +766,75 @@ and resamples in its output stage, which is where the artefacts came from.
765
766
 
766
767
  ---
767
768
 
769
+ ## Friction detection
770
+
771
+ The agent speaks up when the customer looks stuck — before they say so. The
772
+ SDK reads what your screens already report and turns it into five signals:
773
+
774
+ | Signal | Fires when |
775
+ |---|---|
776
+ | `stalled_on_step` | untouched for `idle.afterMs` (a stuck field is named) |
777
+ | `repeated_field_error` | the same field shows an error `count` times in `windowMs` |
778
+ | `looping_between_screens` | back on the same screen `visits` times in `windowMs` |
779
+ | `rage_taps` | a `useGraineTap`-tracked tap `taps` times in `windowMs` |
780
+ | `screen_error` | the screen reports an `error` banner |
781
+
782
+ Each is sent to the agent as an `app_event` (it still decides whether to
783
+ speak, within `nudge.cooldownSeconds` / `nudge.maxPerSession`) and to your app
784
+ as `friction_detected` on `useGraineEvents`.
785
+
786
+ **Configure it in the dashboard** — Agent → Embed & Widgets → Friction — and
787
+ every install picks it up with the session. Or pass rules from the app for the
788
+ screens only it knows about; the app's layer wins:
789
+
790
+ ```tsx
791
+ <GraineProvider client={client} friction={{
792
+ screens: {
793
+ terms: { ignore: true }, // reading is not stuck
794
+ otp: { idle: { afterMs: 20000 },
795
+ message: "The customer has been on the OTP screen {seconds}s without entering a code." },
796
+ home: { idle: false },
797
+ },
798
+ nudge: { openLauncher: true }, // your app decides what "open" means
799
+ }}>
800
+ ```
801
+
802
+ Every rule accepts `false` to switch it off; `message` templates take
803
+ `{title} {screen} {field} {error} {count} {seconds} {tap}`. The full document
804
+ with defaults is `DEFAULT_FRICTION`.
805
+
806
+ ```tsx
807
+ useGraineEvents((e) => {
808
+ if (e.type === "friction_detected" && client.friction.nudge.openLauncher) openAssistant();
809
+ });
810
+ ```
811
+
812
+ ## What Call History shows for an in-app conversation
813
+
814
+ Every conversation lands in the dashboard's Call History with, beside the
815
+ transcript:
816
+
817
+ - **What the agent did in the app** — every action it called, the arguments,
818
+ whether the app accepted or refused it, and the app's own answer
819
+ ("Opened the privacy screen.", "I can reach: settings, favourites, …").
820
+ - **What happened in the app** — the screens the customer moved through, the
821
+ taps you track with `useGraineTap`, and every friction signal, with whether
822
+ the agent spoke to it and, if not, why (it was mid-sentence, it had just
823
+ spoken, it had reached its limit).
824
+ - **Who it was** — from `identify()`.
825
+
826
+ All of it exports to CSV. Nothing needs to be instrumented beyond what the
827
+ screens already report.
828
+
829
+ ## Captions on WebRTC
830
+
831
+ Captions — the agent's line and what the customer is saying — reach the host
832
+ on both transports. On WebRTC the audio carries no text, so the runtime
833
+ mirrors the same caption frames over the control channel; a native bar using
834
+ `onCaption` sees `role: "customer"` and `role: "agent"` lines either way.
835
+
836
+ ---
837
+
768
838
  ## Coming from RevRag
769
839
 
770
840
  Same shape, different names. If you have a RevRag integration, this is the
@@ -791,6 +861,7 @@ whole translation:
791
861
  | `checkPermissions()` | `requestMicrophonePermission()` |
792
862
  | server `widget_config` | `appearance` from the session, via `resolveNativeAppearance` |
793
863
  | LiveKit native setup | none — see *Why there is no LiveKit step* |
864
+ | Friction detection (dashboard switch) | Per-screen JSON rules — `friction` on the provider, or Embed & Widgets → Friction; five named signals, `friction_detected` event |
794
865
 
795
866
  Two things RevRag has that are deliberately absent: a LiveKit install step
796
867
  (audio rides a WebView on a hosted page, so audio fixes ship without an app
package/dist/client.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol.js";
2
+ import { type FrictionConfig } from "./friction.js";
2
3
  export declare const SUBPROTOCOL = "graine.embed.v1";
3
4
  export interface GraineInAppOptions {
4
5
  baseUrl: string;
5
6
  publishableKey: string;
6
7
  variables?: Record<string, string>;
7
8
  stallAfterMs?: number;
9
+ friction?: FrictionConfig;
8
10
  voice?: boolean;
9
11
  WebSocketImpl?: any;
10
12
  fetchImpl?: typeof fetch;
@@ -22,6 +24,7 @@ export interface SessionConfig {
22
24
  toolName?: string;
23
25
  name?: string;
24
26
  }>;
27
+ friction?: FrictionConfig;
25
28
  }
26
29
  export interface ActionDiagnosis {
27
30
  usable: string[];
@@ -43,7 +46,10 @@ export declare class GraineInAppClient {
43
46
  private contextTimer;
44
47
  private pendingScreen;
45
48
  private stallTimer;
46
- private stallReportedFor;
49
+ private detector;
50
+ private serverFriction;
51
+ private appFriction;
52
+ private legacyStall;
47
53
  private pingTimer;
48
54
  private agentSpeaking;
49
55
  private bargedIn;
@@ -53,6 +59,9 @@ export declare class GraineInAppClient {
53
59
  private appVersion;
54
60
  private recentEvents;
55
61
  constructor(opts: GraineInAppOptions);
62
+ private effectiveFriction;
63
+ configureFriction(override: FrictionConfig | null | undefined): void;
64
+ get friction(): Required<FrictionConfig>;
56
65
  on(event: string, fn: Listener): () => void;
57
66
  emit(event: string, payload?: any): void;
58
67
  private get fetch();
@@ -67,6 +76,7 @@ export declare class GraineInAppClient {
67
76
  executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
68
77
  private runAction;
69
78
  private invokeAction;
79
+ private performAction;
70
80
  get isConnected(): boolean;
71
81
  getScreen(): ScreenContext | null;
72
82
  getIdentity(): Record<string, string>;
@@ -82,6 +92,7 @@ export declare class GraineInAppClient {
82
92
  registerAction(name: string, handler: ActionHandler): () => void;
83
93
  get availableActions(): string[];
84
94
  setScreen(context: ScreenContext | null): void;
95
+ private dispatchFriction;
85
96
  reportEvent(event: AppEvent): void;
86
97
  track(name: string, data?: Record<string, unknown>): void;
87
98
  private scheduleScreen;
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
2
2
  import { maskDeep } from "./mask.js";
3
+ import { FrictionDetector, resolveFriction } from "./friction.js";
3
4
  export const SUBPROTOCOL = "graine.embed.v1";
4
5
  const PING_MS = 25000;
5
6
  export class GraineInAppClient {
@@ -17,7 +18,8 @@ export class GraineInAppClient {
17
18
  this.contextTimer = null;
18
19
  this.pendingScreen = false;
19
20
  this.stallTimer = null;
20
- this.stallReportedFor = null;
21
+ this.serverFriction = null;
22
+ this.legacyStall = false;
21
23
  this.pingTimer = null;
22
24
  this.agentSpeaking = false;
23
25
  this.bargedIn = false;
@@ -31,6 +33,23 @@ export class GraineInAppClient {
31
33
  if (!opts?.baseUrl)
32
34
  throw new Error("[Graine] baseUrl is required.");
33
35
  this.opts = { stallAfterMs: 45000, ...opts };
36
+ this.appFriction = opts.friction;
37
+ this.legacyStall = "stallAfterMs" in opts;
38
+ this.detector = new FrictionDetector(this.effectiveFriction());
39
+ }
40
+ effectiveFriction() {
41
+ const legacy = this.legacyStall
42
+ ? { idle: this.opts.stallAfterMs && this.opts.stallAfterMs > 0 ? { afterMs: this.opts.stallAfterMs } : false }
43
+ : null;
44
+ return resolveFriction(this.serverFriction, legacy, this.appFriction);
45
+ }
46
+ configureFriction(override) {
47
+ this.appFriction = override ?? undefined;
48
+ this.detector.configure(this.effectiveFriction());
49
+ this.armStall();
50
+ }
51
+ get friction() {
52
+ return this.detector.settings;
34
53
  }
35
54
  on(event, fn) {
36
55
  if (!this.listeners.has(event))
@@ -69,6 +88,9 @@ export class GraineInAppClient {
69
88
  throw new Error(data?.error || `Embed rejected (${res.status})`);
70
89
  }
71
90
  this.config = data;
91
+ this.serverFriction = data && typeof data.friction === "object" ? data.friction : null;
92
+ this.detector.configure(this.effectiveFriction());
93
+ this.armStall();
72
94
  this.reportActionMismatch();
73
95
  return this.config;
74
96
  }
@@ -263,6 +285,15 @@ export class GraineInAppClient {
263
285
  this.send({ type: "app_action_result", action_id: request.action_id, ...result });
264
286
  }
265
287
  async invokeAction(request) {
288
+ const result = await this.performAction(request);
289
+ this.emit("action_result", {
290
+ name: request.name,
291
+ status: result.status,
292
+ ...(result.message ? { message: result.message } : {}),
293
+ });
294
+ return result;
295
+ }
296
+ async performAction(request) {
266
297
  const handler = this.actions.get(request.name);
267
298
  if (!handler) {
268
299
  return {
@@ -335,13 +366,25 @@ export class GraineInAppClient {
335
366
  setScreen(context) {
336
367
  const changedScreen = context?.screen !== this.screen?.screen;
337
368
  this.screen = context;
369
+ const now = Date.now();
338
370
  if (changedScreen) {
339
- this.screenSince = Date.now();
340
- this.stallReportedFor = null;
341
- this.armStall();
371
+ this.screenSince = now;
342
372
  }
373
+ this.dispatchFriction(this.detector.onScreen(context, now));
374
+ this.armStall();
343
375
  this.scheduleScreen();
344
376
  }
377
+ dispatchFriction(signals) {
378
+ for (const s of signals) {
379
+ this.emit("friction", s);
380
+ this.reportEvent({
381
+ name: s.name,
382
+ detail: s.detail,
383
+ cooldown_seconds: s.cooldown_seconds,
384
+ max_per_session: s.max_per_session,
385
+ });
386
+ }
387
+ }
345
388
  reportEvent(event) {
346
389
  this.emit("app_event", event);
347
390
  this.send({ type: "app_event", event });
@@ -352,6 +395,7 @@ export class GraineInAppClient {
352
395
  this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
353
396
  if (this.recentEvents.length > 10)
354
397
  this.recentEvents.shift();
398
+ this.dispatchFriction(this.detector.onTrack(name, Date.now()));
355
399
  if (this.screen)
356
400
  this.scheduleScreen();
357
401
  }
@@ -402,24 +446,14 @@ export class GraineInAppClient {
402
446
  }
403
447
  armStall() {
404
448
  clearTimeout(this.stallTimer);
405
- const after = this.opts.stallAfterMs ?? 0;
406
- if (!after || !this.screen)
449
+ const due = this.detector.idleDueIn(Date.now());
450
+ if (due === null)
407
451
  return;
408
- const screenId = this.screen.screen;
409
452
  this.stallTimer = setTimeout(() => {
410
- if (this.stallReportedFor === screenId)
411
- return;
412
- this.stallReportedFor = screenId;
413
- const title = this.screen?.title ?? screenId;
414
- const stuck = (this.screen?.fields ?? []).find((f) => f.status === "invalid" || f.error || f.status === "empty");
415
- this.reportEvent({
416
- name: "stalled_on_step",
417
- detail: stuck
418
- ? `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s. ` +
419
- `The "${stuck.label ?? stuck.name}" field is ${stuck.error ? `showing: ${stuck.error}` : stuck.status}.`
420
- : `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s.`,
421
- });
422
- }, after);
453
+ const signal = this.detector.onIdle(Date.now());
454
+ if (signal)
455
+ this.dispatchFriction([signal]);
456
+ }, due);
423
457
  }
424
458
  identify(user) {
425
459
  if (!user) {
@@ -436,7 +470,9 @@ export class GraineInAppClient {
436
470
  this.scheduleScreen();
437
471
  }
438
472
  noteInteraction() {
439
- this.screenSince = Date.now();
473
+ const now = Date.now();
474
+ this.screenSince = now;
475
+ this.detector.onInteraction(now);
440
476
  this.armStall();
441
477
  }
442
478
  say(text) {
@@ -0,0 +1,70 @@
1
+ import type { ScreenContext } from "./protocol.js";
2
+ export interface IdleRule {
3
+ afterMs: number;
4
+ }
5
+ export interface RepeatedErrorsRule {
6
+ count: number;
7
+ windowMs: number;
8
+ }
9
+ export interface BackAndForthRule {
10
+ visits: number;
11
+ windowMs: number;
12
+ }
13
+ export interface RageTapsRule {
14
+ taps: number;
15
+ windowMs: number;
16
+ }
17
+ export interface FrictionScreenRules {
18
+ ignore?: boolean;
19
+ idle?: IdleRule | false;
20
+ repeatedErrors?: RepeatedErrorsRule | false;
21
+ backAndForth?: BackAndForthRule | false;
22
+ rageTaps?: RageTapsRule | false;
23
+ screenError?: boolean;
24
+ message?: string;
25
+ }
26
+ export interface FrictionNudge {
27
+ cooldownSeconds?: number;
28
+ maxPerSession?: number;
29
+ openLauncher?: boolean;
30
+ }
31
+ export interface FrictionConfig extends FrictionScreenRules {
32
+ enabled?: boolean;
33
+ cooldownMs?: number;
34
+ nudge?: FrictionNudge;
35
+ screens?: Record<string, FrictionScreenRules>;
36
+ }
37
+ export declare const DEFAULT_FRICTION: Readonly<Required<Omit<FrictionConfig, "message" | "ignore">>>;
38
+ export declare function resolveFriction(...layers: Array<FrictionConfig | null | undefined>): Required<FrictionConfig>;
39
+ export declare function rulesFor(config: Required<FrictionConfig>, screenId: string | null | undefined): Required<FrictionScreenRules>;
40
+ export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "screen_error";
41
+ export interface FrictionSignal {
42
+ name: FrictionKind;
43
+ screen: string;
44
+ detail: string;
45
+ data: Record<string, unknown>;
46
+ cooldown_seconds: number;
47
+ max_per_session: number;
48
+ }
49
+ export declare class FrictionDetector {
50
+ private config;
51
+ private screen;
52
+ private screenSince;
53
+ private lastSignalAt;
54
+ private idleFiredFor;
55
+ private visits;
56
+ private errorHits;
57
+ private errorFiredFor;
58
+ private taps;
59
+ private screenErrorFiredFor;
60
+ constructor(config?: FrictionConfig | Required<FrictionConfig>);
61
+ configure(config: Required<FrictionConfig>): void;
62
+ get settings(): Required<FrictionConfig>;
63
+ private signal;
64
+ private active;
65
+ onScreen(ctx: ScreenContext | null, now: number): FrictionSignal[];
66
+ onInteraction(now: number): void;
67
+ onTrack(name: string, now: number): FrictionSignal[];
68
+ idleDueIn(now: number): number | null;
69
+ onIdle(now: number): FrictionSignal | null;
70
+ }
@@ -0,0 +1,258 @@
1
+ const DEFAULT_RULES = {
2
+ idle: { afterMs: 45000 },
3
+ repeatedErrors: { count: 2, windowMs: 60000 },
4
+ backAndForth: { visits: 3, windowMs: 45000 },
5
+ rageTaps: { taps: 4, windowMs: 1500 },
6
+ };
7
+ export const DEFAULT_FRICTION = Object.freeze({
8
+ enabled: true,
9
+ cooldownMs: 60000,
10
+ ...DEFAULT_RULES,
11
+ screenError: true,
12
+ nudge: { cooldownSeconds: 20, maxPerSession: 3, openLauncher: false },
13
+ screens: {},
14
+ });
15
+ function num(v) {
16
+ const n = typeof v === "string" ? Number(v) : v;
17
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
18
+ }
19
+ function rule(raw, shape) {
20
+ if (raw === false)
21
+ return false;
22
+ if (raw === undefined || raw === null)
23
+ return undefined;
24
+ if (typeof raw !== "object")
25
+ return undefined;
26
+ const out = {};
27
+ for (const [k, fallback] of Object.entries(shape)) {
28
+ const v = num(raw[k]);
29
+ out[k] = v ?? fallback;
30
+ }
31
+ return out;
32
+ }
33
+ function screenRules(raw, base) {
34
+ if (!raw || typeof raw !== "object")
35
+ return {};
36
+ const r = raw;
37
+ const out = {};
38
+ if (r.ignore === true)
39
+ out.ignore = true;
40
+ const idle = rule(r.idle, base.idle || DEFAULT_RULES.idle);
41
+ if (idle !== undefined)
42
+ out.idle = idle;
43
+ const rep = rule(r.repeatedErrors, base.repeatedErrors || DEFAULT_RULES.repeatedErrors);
44
+ if (rep !== undefined)
45
+ out.repeatedErrors = rep;
46
+ const bf = rule(r.backAndForth, base.backAndForth || DEFAULT_RULES.backAndForth);
47
+ if (bf !== undefined)
48
+ out.backAndForth = bf;
49
+ const rage = rule(r.rageTaps, base.rageTaps || DEFAULT_RULES.rageTaps);
50
+ if (rage !== undefined)
51
+ out.rageTaps = rage;
52
+ if (typeof r.screenError === "boolean")
53
+ out.screenError = r.screenError;
54
+ if (typeof r.message === "string" && r.message.trim())
55
+ out.message = r.message.trim();
56
+ return out;
57
+ }
58
+ export function resolveFriction(...layers) {
59
+ let out = {
60
+ ...DEFAULT_FRICTION,
61
+ nudge: { ...DEFAULT_FRICTION.nudge },
62
+ screens: {},
63
+ ignore: false,
64
+ message: "",
65
+ };
66
+ for (const layer of layers) {
67
+ if (!layer || typeof layer !== "object")
68
+ continue;
69
+ const top = screenRules(layer, out);
70
+ out = { ...out, ...top };
71
+ if (typeof layer.enabled === "boolean")
72
+ out.enabled = layer.enabled;
73
+ const cd = num(layer.cooldownMs);
74
+ if (cd !== null)
75
+ out.cooldownMs = cd;
76
+ if (layer.nudge && typeof layer.nudge === "object") {
77
+ const n = layer.nudge;
78
+ out.nudge = {
79
+ ...out.nudge,
80
+ ...(num(n.cooldownSeconds) !== null ? { cooldownSeconds: num(n.cooldownSeconds) } : {}),
81
+ ...(num(n.maxPerSession) !== null ? { maxPerSession: num(n.maxPerSession) } : {}),
82
+ ...(typeof n.openLauncher === "boolean" ? { openLauncher: n.openLauncher } : {}),
83
+ };
84
+ }
85
+ if (layer.screens && typeof layer.screens === "object") {
86
+ const screens = { ...out.screens };
87
+ for (const [id, raw] of Object.entries(layer.screens)) {
88
+ if (!id)
89
+ continue;
90
+ screens[id] = { ...(screens[id] || {}), ...screenRules(raw, out) };
91
+ }
92
+ out.screens = screens;
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ export function rulesFor(config, screenId) {
98
+ const over = (screenId && config.screens[screenId]) || {};
99
+ return {
100
+ ignore: over.ignore ?? false,
101
+ idle: over.idle ?? config.idle,
102
+ repeatedErrors: over.repeatedErrors ?? config.repeatedErrors,
103
+ backAndForth: over.backAndForth ?? config.backAndForth,
104
+ rageTaps: over.rageTaps ?? config.rageTaps,
105
+ screenError: over.screenError ?? config.screenError,
106
+ message: over.message ?? config.message ?? "",
107
+ };
108
+ }
109
+ function fill(template, vars) {
110
+ return template.replace(/\{(\w+)\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? "" : String(vars[k])));
111
+ }
112
+ const stuckField = (fields) => (fields ?? []).find((f) => f.status === "invalid" || f.error) ?? (fields ?? []).find((f) => f.status === "empty");
113
+ export class FrictionDetector {
114
+ constructor(config) {
115
+ this.screen = null;
116
+ this.screenSince = 0;
117
+ this.lastSignalAt = new Map();
118
+ this.idleFiredFor = null;
119
+ this.visits = [];
120
+ this.errorHits = new Map();
121
+ this.errorFiredFor = new Set();
122
+ this.taps = new Map();
123
+ this.screenErrorFiredFor = null;
124
+ this.config = resolveFriction(config);
125
+ }
126
+ configure(config) {
127
+ this.config = config;
128
+ }
129
+ get settings() {
130
+ return this.config;
131
+ }
132
+ signal(name, vars, fallback, now) {
133
+ const screen = this.screen?.screen ?? "";
134
+ const last = this.lastSignalAt.get(screen) ?? -Infinity;
135
+ if (now - last < this.config.cooldownMs)
136
+ return null;
137
+ this.lastSignalAt.set(screen, now);
138
+ const rules = rulesFor(this.config, screen);
139
+ const all = { title: this.screen?.title ?? screen, screen, ...vars };
140
+ return {
141
+ name,
142
+ screen,
143
+ detail: rules.message ? fill(rules.message, all) : fill(fallback, all),
144
+ data: vars,
145
+ cooldown_seconds: this.config.nudge.cooldownSeconds ?? 20,
146
+ max_per_session: this.config.nudge.maxPerSession ?? 3,
147
+ };
148
+ }
149
+ active() {
150
+ if (!this.config.enabled || !this.screen)
151
+ return null;
152
+ const r = rulesFor(this.config, this.screen.screen);
153
+ return r.ignore ? null : r;
154
+ }
155
+ onScreen(ctx, now) {
156
+ const prev = this.screen;
157
+ this.screen = ctx;
158
+ if (!ctx)
159
+ return [];
160
+ const changed = !prev || prev.screen !== ctx.screen;
161
+ if (changed) {
162
+ this.screenSince = now;
163
+ this.idleFiredFor = null;
164
+ this.screenErrorFiredFor = null;
165
+ this.errorFiredFor.clear();
166
+ this.visits.push({ screen: ctx.screen, at: now });
167
+ if (this.visits.length > 50)
168
+ this.visits.shift();
169
+ }
170
+ const rules = this.active();
171
+ if (!rules)
172
+ return [];
173
+ const out = [];
174
+ if (changed && rules.backAndForth) {
175
+ const { visits, windowMs } = rules.backAndForth;
176
+ const recent = this.visits.filter((v) => v.screen === ctx.screen && now - v.at <= windowMs).length;
177
+ if (recent >= visits) {
178
+ const s = this.signal("looping_between_screens", { count: recent, seconds: Math.round(windowMs / 1000) }, `The customer has come back to the "{title}" screen {count} times in the last {seconds}s without getting past it.`, now);
179
+ if (s)
180
+ out.push(s);
181
+ }
182
+ }
183
+ if (rules.repeatedErrors) {
184
+ const { count, windowMs } = rules.repeatedErrors;
185
+ for (const f of ctx.fields ?? []) {
186
+ if (!(f.status === "invalid" || f.error))
187
+ continue;
188
+ const key = `${ctx.screen}:${f.name}`;
189
+ const hits = (this.errorHits.get(key) ?? []).filter((t) => now - t <= windowMs);
190
+ const prevField = (prev?.fields ?? []).find((p) => p.name === f.name);
191
+ const same = prevField && !changed && (prevField.error ?? "") === (f.error ?? "") && prevField.status === f.status;
192
+ if (!same)
193
+ hits.push(now);
194
+ this.errorHits.set(key, hits);
195
+ if (hits.length >= count && !this.errorFiredFor.has(key)) {
196
+ this.errorFiredFor.add(key);
197
+ const s = this.signal("repeated_field_error", { field: f.label ?? f.name, error: f.error ?? f.status, count: hits.length }, `The "{field}" field on the "{title}" screen has rejected the customer {count} times — it is showing: {error}.`, now);
198
+ if (s)
199
+ out.push(s);
200
+ }
201
+ }
202
+ }
203
+ if (rules.screenError && ctx.error && this.screenErrorFiredFor !== ctx.screen) {
204
+ this.screenErrorFiredFor = ctx.screen;
205
+ const s = this.signal("screen_error", { error: ctx.error }, `The "{title}" screen is showing an error: {error}.`, now);
206
+ if (s)
207
+ out.push(s);
208
+ }
209
+ return out;
210
+ }
211
+ onInteraction(now) {
212
+ this.screenSince = now;
213
+ }
214
+ onTrack(name, now) {
215
+ const rules = this.active();
216
+ if (!rules || !rules.rageTaps || !this.screen)
217
+ return [];
218
+ if (!name.startsWith("tap:"))
219
+ return [];
220
+ const { taps, windowMs } = rules.rageTaps;
221
+ const key = `${this.screen.screen}:${name}`;
222
+ const times = (this.taps.get(key) ?? []).filter((t) => now - t <= windowMs);
223
+ times.push(now);
224
+ this.taps.set(key, times);
225
+ if (times.length < taps)
226
+ return [];
227
+ this.taps.set(key, []);
228
+ const s = this.signal("rage_taps", { tap: name.slice(4), count: times.length, seconds: (windowMs / 1000).toFixed(1) }, `The customer tapped "{tap}" {count} times in {seconds}s on the "{title}" screen — it may not be responding or not doing what they expect.`, now);
229
+ return s ? [s] : [];
230
+ }
231
+ idleDueIn(now) {
232
+ const rules = this.active();
233
+ if (!rules || !rules.idle || !this.screen)
234
+ return null;
235
+ if (this.idleFiredFor === this.screen.screen)
236
+ return null;
237
+ return Math.max(0, rules.idle.afterMs - (now - this.screenSince));
238
+ }
239
+ onIdle(now) {
240
+ const rules = this.active();
241
+ if (!rules || !rules.idle || !this.screen)
242
+ return null;
243
+ if (this.idleFiredFor === this.screen.screen)
244
+ return null;
245
+ if (now - this.screenSince < rules.idle.afterMs)
246
+ return null;
247
+ this.idleFiredFor = this.screen.screen;
248
+ const stuck = stuckField(this.screen.fields);
249
+ const seconds = Math.round(rules.idle.afterMs / 1000);
250
+ return this.signal("stalled_on_step", {
251
+ seconds,
252
+ field: stuck ? stuck.label ?? stuck.name : "",
253
+ error: stuck?.error ?? stuck?.status ?? "",
254
+ }, stuck
255
+ ? `The customer has not touched the "{title}" screen for {seconds}s. The "{field}" field is ${stuck.error ? "showing: {error}" : "{error}"}.`
256
+ : `The customer has not touched the "{title}" screen for {seconds}s.`, now);
257
+ }
258
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol.js";
2
2
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
3
+ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
3
4
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
4
5
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
5
6
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { GraineInAppClient, } from "./client.js";
2
+ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
2
3
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
3
4
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
4
5
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
3
  import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
4
  import type { ActionHandler, ScreenContext } from "../protocol.js";
5
+ import type { FrictionConfig } from "../friction.js";
5
6
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
6
7
  interface GraineContextValue {
7
8
  client: GraineInAppClient;
@@ -44,6 +45,7 @@ export type GraineEvent = {
44
45
  type: "action_completed";
45
46
  name: string;
46
47
  status: string;
48
+ message?: string;
47
49
  } | {
48
50
  type: "muted";
49
51
  muted: boolean;
@@ -56,6 +58,11 @@ export type GraineEvent = {
56
58
  } | {
57
59
  type: "launcher_shown";
58
60
  screen: string | null;
61
+ } | {
62
+ type: "friction_detected";
63
+ name: string;
64
+ screen: string;
65
+ detail: string;
59
66
  };
60
67
  export interface GraineWidget {
61
68
  id: string;
@@ -83,11 +90,12 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
83
90
  current: any;
84
91
  } | null;
85
92
  appVersion?: string;
93
+ friction?: FrictionConfig;
86
94
  includeScreens?: string[];
87
95
  launcherDelayMs?: number;
88
96
  visibility?: LauncherVisibility;
89
97
  }
90
- export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, appVersion, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
98
+ export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
91
99
  export declare function useGraineAgent(): GraineContextValue;
92
100
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
93
101
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
@@ -5,7 +5,7 @@ import { GraineInAppClient } from "../client.js";
5
5
  import { VoiceSession } from "../voice.js";
6
6
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
7
7
  const Ctx = createContext(null);
8
- export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, appVersion, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
8
+ export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
9
9
  const clientRef = useRef(null);
10
10
  if (!clientRef.current) {
11
11
  clientRef.current =
@@ -99,6 +99,8 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
99
99
  };
100
100
  }, [navigationRef, client]);
101
101
  useEffect(() => { client.setAppVersion(appVersion); }, [client, appVersion]);
102
+ useEffect(() => { if (friction !== undefined)
103
+ client.configureFriction(friction); }, [client, friction]);
102
104
  useEffect(() => {
103
105
  if (!navigationRef?.current)
104
106
  setLauncherVisible(true);
@@ -134,6 +136,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
134
136
  })),
135
137
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
136
138
  client.on("mic_denied", (p) => emitEvent({ type: "mic_denied", reason: String(p?.reason || "NotAllowedError") })),
139
+ client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
137
140
  client.on("muted", (m) => {
138
141
  setMutedState(m);
139
142
  emitEvent({ type: "muted", muted: m });
@@ -145,6 +148,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
145
148
  emitEvent({ type: "agent_speaking", speaking });
146
149
  }),
147
150
  client.on("action", (req) => emitEvent({ type: "action_requested", name: req?.name ?? "" })),
151
+ client.on("action_result", (r) => emitEvent({
152
+ type: "action_completed",
153
+ name: r?.name ?? "",
154
+ status: r?.status ?? "unknown",
155
+ ...(r?.message ? { message: String(r.message) } : {}),
156
+ })),
148
157
  client.on("transcript", ({ text, final }) => {
149
158
  if (text)
150
159
  setCaption({ role: "customer", text, live: !final });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",