@wireai/activation 0.13.6 → 0.14.1

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.
Files changed (47) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +227 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +15 -2
  5. package/dist/analytics/index.d.mts +9 -2
  6. package/dist/analytics/index.d.ts +9 -2
  7. package/dist/analytics/index.js +56 -12
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +56 -12
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DngW-QoD.d.mts → currentSession-CUvTOchb.d.mts} +35 -6
  12. package/dist/{currentSession-C5976akx.d.ts → currentSession-CW_5Mq4O.d.ts} +35 -6
  13. package/dist/index.d.mts +31 -7
  14. package/dist/index.d.ts +31 -7
  15. package/dist/index.js +643 -539
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +643 -539
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js +14 -6
  20. package/dist/questionnaire/index.js.map +1 -1
  21. package/dist/questionnaire/index.mjs +14 -6
  22. package/dist/questionnaire/index.mjs.map +1 -1
  23. package/dist/reviews/index.js +14 -6
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +14 -6
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/llms.txt +2 -2
  28. package/package.json +1 -1
  29. package/src/OnboardingFlow.tsx +4 -3
  30. package/src/WireOnboarding.tsx +48 -8
  31. package/src/activation/useWireActivation.ts +14 -1
  32. package/src/activation/wireActivation.ts +68 -2
  33. package/src/analytics/analyticsFacade.ts +24 -0
  34. package/src/analytics/currentSession.ts +3 -2
  35. package/src/analytics/eventQueue.ts +106 -11
  36. package/src/analytics/reportClientEvent.ts +31 -7
  37. package/src/analytics/useAnalytics.ts +17 -0
  38. package/src/context/deviceId.ts +10 -3
  39. package/src/permissions/permissionMemory.ts +41 -5
  40. package/src/reviews/runtime.ts +75 -23
  41. package/src/session/persistedSession.ts +32 -8
  42. package/src/session-analytics/lifecycle.ts +26 -5
  43. package/src/session-analytics/reportSessionStart.ts +14 -10
  44. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  45. package/src/session-analytics/useSessionStart.ts +57 -15
  46. package/src/types.ts +16 -6
  47. package/src/utils/readPlan.ts +8 -5
@@ -2,7 +2,10 @@
2
2
  * useSessionStart — the optional convenience hook that fires {@link reportSessionStart} for you.
3
3
  *
4
4
  * Two firing moments, mirroring the reference apps' session-counter semantics:
5
- * • ON MOUNT — the app opened (cold start or the provider first rendered).
5
+ * • ON MOUNT — the app opened (cold start or the provider first rendered). When the kit's AUTO
6
+ * device key is the one in play (no host `deviceKey`, `config.storage` present) the fire waits
7
+ * on one storage read, so the event carries the PERSISTED key rather than a fresh per-launch
8
+ * mint, and it refuses the key outright if that read settles non-durable (0.14.0).
6
9
  * • ON FOREGROUND after a real background — when AppState returns to `active` having been
7
10
  * backgrounded for at least {@link BACKGROUND_SESSION_MS} (30 min), that's a NEW open, so a
8
11
  * fresh session fires. A quick app-switch (under the threshold) does NOT count as a new open.
@@ -26,7 +29,7 @@ import { useEffect, useRef } from "react";
26
29
  import { AppState, Platform, type AppStateStatus } from "react-native";
27
30
 
28
31
  import type { ClientEventTarget } from "../analytics/reportClientEvent";
29
- import { resolveAutoDeviceKey } from "../context/deviceId";
32
+ import { hydrateDeviceIdentity } from "../context/deviceId";
30
33
  import { collectDeviceContext } from "../device/deviceContext";
31
34
  import type { WireOnboardingStorage } from "../session/persistedSession";
32
35
  import { reportSessionStart } from "./reportSessionStart";
@@ -44,8 +47,9 @@ export interface SessionStartConfig {
44
47
  appVersion?: string;
45
48
  /** Tenant/app id — namespaces the auto `device_key` fallback below. */
46
49
  appId?: string;
47
- /** Host storage (AsyncStorage subset). Present → `app.session_started` falls back to the kit's
48
- * persisted auto `device_key` when the host passes none. Absent no fallback (see below). */
50
+ /** Host storage (AsyncStorage subset). Present AND actually persisting → `app.session_started`
51
+ * falls back to the kit's persisted auto `device_key` when the host passes none. Absent, or an
52
+ * adapter that throws / rejects → no fallback (a per-launch key is worse than none, see below). */
49
53
  storage?: WireOnboardingStorage;
50
54
  }
51
55
 
@@ -78,8 +82,13 @@ export const useSessionStart = (
78
82
  latest.current = { config, options };
79
83
 
80
84
  useEffect(() => {
85
+ // Set by the cleanup below: an in-flight storage read must not fire an app-open for a mount that
86
+ // is already gone. Declared first because the async fire path closes over it.
87
+ let cancelled = false;
88
+
81
89
  /**
82
- * The `device_key` this open rides under. A host-supplied id always wins.
90
+ * The `device_key` this open rides under. A host-supplied id always wins; the auto id arrives
91
+ * already vetted from `openAutoDeviceKey` below (a non-durable one never gets here).
83
92
  *
84
93
  * BACKPORTED FROM `useLifecycleEvents` (which had this and this hook did not): `min_sessions` is
85
94
  * computed server-side by counting distinct `app.session_started` grouped by
@@ -88,21 +97,50 @@ export const useSessionStart = (
88
97
  * spaces, and a counter that could never increase. The two session-start paths must not diverge
89
98
  * on the thing the firing rule reads.
90
99
  *
91
- * ONLY WITH `storage`, for the same reason as the sibling hook: without persistence the auto id
92
- * is per-LAUNCH, and a per-launch key makes every open look like a new device, corrupting
93
- * `min_sessions` in the other direction.
100
+ * ONLY WITH `storage` THAT ACTUALLY WORKED, for the same reason as the sibling hook: without
101
+ * persistence or with an adapter that threw / rejected the auto id is per-LAUNCH, and a
102
+ * per-launch key makes every open look like a new device, corrupting `min_sessions` in the other
103
+ * direction. Both cases arrive here as `undefined`; this function never mints.
94
104
  */
95
105
  const resolveDeviceKey = (
96
- cfg: SessionStartConfig | undefined,
97
106
  opts: UseSessionStartOptions,
107
+ autoDeviceKey: string | undefined,
98
108
  ): string | undefined => {
99
109
  const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
100
110
  if (host) return host;
101
- if (!cfg?.storage) return undefined;
102
- return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
111
+ return autoDeviceKey;
112
+ };
113
+
114
+ /**
115
+ * Resolve the auto `device_key` for ONE app-open, then fire — the fix this hook never got.
116
+ *
117
+ * IT USED TO FIRE SYNCHRONOUSLY. `resolveAutoDeviceKey` is synchronous by contract: it returns a
118
+ * freshly minted id and adopts the persisted one a storage read later. So on a perfectly HEALTHY
119
+ * store this hook stamped a brand-new `wdev_*` on `app.session_started` every single launch,
120
+ * while every other kit surface adopted the persisted id milliseconds afterwards — the exact
121
+ * pre-fix behaviour the sibling hook's mount comment describes. `min_sessions` counts distinct
122
+ * opens grouped by that key, so it was structurally incapable of exceeding 1.
123
+ *
124
+ * The auto path now awaits `hydrateDeviceIdentity` (the provenance-carrying read) and honours the
125
+ * rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
126
+ * wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
127
+ * config with no `storage` still fires synchronously — there is nothing to read.
128
+ */
129
+ const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
130
+ const { config: cfg, options: opts } = latest.current;
131
+ const hostKey =
132
+ typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
133
+ if (hostKey || !cfg?.storage) {
134
+ fireOpen(undefined);
135
+ return;
136
+ }
137
+ void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
138
+ if (cancelled) return;
139
+ fireOpen(identity?.durable ? identity.value : undefined);
140
+ });
103
141
  };
104
142
 
105
- const fire = () => {
143
+ const fire = (autoDeviceKey: string | undefined) => {
106
144
  const { config: cfg, options: opts } = latest.current;
107
145
  if (!cfg?.serverUrl) return;
108
146
  if (opts.enabled === false) return;
@@ -114,7 +152,7 @@ export const useSessionStart = (
114
152
  target,
115
153
  // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
116
154
  userId: opts.userId,
117
- deviceKey: resolveDeviceKey(cfg, opts),
155
+ deviceKey: resolveDeviceKey(opts, autoDeviceKey),
118
156
  sessionCount: opts.sessionCount,
119
157
  appVersion: cfg.appVersion ?? device.appVersion,
120
158
  platform: Platform.OS,
@@ -124,7 +162,7 @@ export const useSessionStart = (
124
162
  };
125
163
 
126
164
  // 1) Mount = an app-open.
127
- fire();
165
+ openAutoDeviceKey(fire);
128
166
 
129
167
  // 2) Foreground after a real background = a new app-open.
130
168
  let backgroundedAt: number | null = null;
@@ -137,11 +175,15 @@ export const useSessionStart = (
137
175
  if (state === "active") {
138
176
  const since = backgroundedAt;
139
177
  backgroundedAt = null;
140
- if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) fire();
178
+ // A new open re-runs the same vetting: the auto key must still be a durable one, and a
179
+ // degraded read that released its latches gets a fresh attempt rather than a cached verdict.
180
+ if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) openAutoDeviceKey(fire);
141
181
  }
142
182
  };
143
183
  const sub = AppState.addEventListener("change", onChange);
144
184
  return () => {
185
+ // A pending hydration must not fire an app-open for a mount that is already gone.
186
+ cancelled = true;
145
187
  // RN >= 0.65 returns a subscription with remove(); guard for older shims.
146
188
  if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
147
189
  };
package/src/types.ts CHANGED
@@ -54,10 +54,11 @@ export type OnboardingResult = {
54
54
  /** The raw message thread, for custom downstream parsing. */
55
55
  raw: Message[];
56
56
  /**
57
- * The backend's onboarding plan, when it sent one. Present ONLY on the AI path: a tenant running
58
- * the static flow, or any run the server finished without a plan, leaves this `undefined` AND
59
- * leaves the key off the result object entirely so a host written before plans existed sees
60
- * byte-identically what it always saw.
57
+ * The backend's onboarding plan, present when the backend sent one the kit never infers it
58
+ * from which flow ran. Whether a plan arrives is the backend's configuration, not the kit's. Any
59
+ * run the server finished without a plan leaves this `undefined` AND leaves the key off the
60
+ * result object entirely — so a host written before plans existed sees byte-identically what it
61
+ * always saw.
61
62
  *
62
63
  * ⚠️ The kit does NOT interpret this and does NOT validate it. It checks one structural fact (a
63
64
  * plan is an object) and hands the payload straight through, unread, unlogged, and never attached
@@ -240,10 +241,19 @@ export type WireOnboardingProps = {
240
241
  *
241
242
  * ```tsx
242
243
  * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
243
- * // no device id of your own? read the kit's:
244
- * <WireOnboarding userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} ... />
244
+ * // no device id of your own? pass `storage` and leave this prop alone — the kit injects its
245
+ * // own key, and only after it has confirmed the key actually persists (see `autoJoinKey`).
246
+ * <WireOnboarding config={{ ...config, storage }} ... />
245
247
  * ```
246
248
  *
249
+ * ⛔ Do NOT hand-build the auto key with `activationJoinContext(resolveAutoDeviceKey({...}))`.
250
+ * `resolveAutoDeviceKey` is SYNCHRONOUS by contract: it hands back a freshly minted id and adopts
251
+ * the persisted one a storage read later, and it cannot tell you whether the id survives the
252
+ * launch at all. A key that differs on every launch corrupts `min_sessions` rather than merely
253
+ * leaving the join empty. The auto-join path does the awaited, durability-checked read for you;
254
+ * a host that genuinely wants the value in hand should await `hydrateDeviceIdentity` and refuse a
255
+ * `durable: false` record, which is exactly what the kit does internally.
256
+ *
247
257
  * SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
248
258
  * this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
249
259
  * analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * readPlan — lift the backend's onboarding plan off the message thread.
3
3
  *
4
- * WHY IT EXISTS. On the AI path the server appends a SECOND A2A DataPart to the turn it finishes
5
- * on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer
6
- * already consumes. The kit carries that payload out through `OnboardingResult.plan` and stops
7
- * there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
4
+ * WHY IT EXISTS. When it sends a plan, the server appends a SECOND A2A DataPart to the turn it
5
+ * finishes on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the
6
+ * renderer already consumes. The kit carries that payload out through `OnboardingResult.plan` and
7
+ * stops there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
8
8
  * attach it to any event — the plan is user-derived content, and deciding what it MEANS is the
9
9
  * host's job (the kit/host boundary in `ai_rules/context_map.md`: the kit ends at the completion
10
10
  * CTA).
@@ -19,7 +19,10 @@
19
19
  * so a throw here would cost the user the completion of an onboarding they already finished. Every
20
20
  * step below is a runtime-guarded read.
21
21
  *
22
- * The static (non-AI) flow carries no plan at all. That path is unchanged and fully supported.
22
+ * WHICH RUNS CARRY A PLAN IS THE BACKEND'S CONFIGURATION, NOT THE KIT'S. This reader matches the
23
+ * `kind` MARKER below and never the flow that produced the turn, so it needs no knowledge of how
24
+ * the tenant is configured. A run that carries no plan yields `undefined`: unchanged, and fully
25
+ * supported.
23
26
  */
24
27
  import type { Message } from "wireai-rn";
25
28