@wireai/activation 0.13.5 → 0.13.6
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/AGENTS.md +1 -1
- package/CHANGELOG.md +87 -3
- package/README.md +163 -34
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/{currentSession-EOHU64QD.d.ts → currentSession-C5976akx.d.ts} +40 -1
- package/dist/{currentSession-DgJf0fRz.d.mts → currentSession-DngW-QoD.d.mts} +40 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +35 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +35 -4
- package/dist/index.mjs.map +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +27 -3
- package/src/types.ts +40 -2
- package/src/utils/readPlan.ts +87 -0
- package/src/utils/readProgress.ts +5 -0
package/llms.txt
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
- **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
|
|
15
15
|
- **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
|
|
16
16
|
- **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
|
|
17
|
-
- **Capture is deterministic.** `onComplete` returns `
|
|
17
|
+
- **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
|
|
18
18
|
- **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
|
|
19
19
|
|
|
20
20
|
## Helpers (the reusable substrate)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { ErrorBlock } from "./components/ErrorBlock";
|
|
|
33
33
|
import { CompletionView } from "./components/CompletionView";
|
|
34
34
|
import { CardHandoff } from "./components/CardHandoff";
|
|
35
35
|
import { deriveAnswers } from "./utils/deriveAnswers";
|
|
36
|
+
import { readPlan } from "./utils/readPlan";
|
|
36
37
|
import { readProgress } from "./utils/readProgress";
|
|
37
38
|
import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
38
39
|
import { sendPreview } from "./analytics/sendPreview";
|
|
@@ -176,6 +177,12 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
176
177
|
// Last 0-based screen index we showed the user (matches the server's `screen_shown`
|
|
177
178
|
// index). Used as the `dropped` event's screen_index. -1 = nothing shown yet.
|
|
178
179
|
const lastScreenIndexRef = useRef(-1);
|
|
180
|
+
// The backend's experiment arm for this session, LATCHED at first sight and never cleared.
|
|
181
|
+
// The server's assignment is sticky per session but it only rides the render envelope, so a
|
|
182
|
+
// later card that omits `progress.variant` must not un-assign what an earlier card declared.
|
|
183
|
+
// `undefined` = no arm seen, which is every turn of every tenant running no experiment.
|
|
184
|
+
// The kit stores the string and nothing else: it is not parsed, branded, defaulted or logged.
|
|
185
|
+
const variantRef = useRef<string | undefined>(undefined);
|
|
179
186
|
// Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
|
|
180
187
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
181
188
|
|
|
@@ -268,15 +275,22 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
268
275
|
// A new card arrived → clear any prior inline validation error + emit a turn event.
|
|
269
276
|
useEffect(() => {
|
|
270
277
|
setValidationError(undefined);
|
|
278
|
+
// Latch the arm BEFORE the turn event fires, so the card that first declares one already
|
|
279
|
+
// carries it. An empty string is not an assignment, so it is ignored like an absent key.
|
|
280
|
+
if (variantRef.current === undefined && progress.variant) variantRef.current = progress.variant;
|
|
271
281
|
if (!lastCard?.id) return;
|
|
272
282
|
const step = progress.step ?? renderedCount;
|
|
273
283
|
// Remember the latest screen we showed (0-based) for a possible `dropped` event.
|
|
274
284
|
lastScreenIndexRef.current = Math.max(0, step - 1);
|
|
275
|
-
|
|
285
|
+
const turn: Extract<OnboardingEvent, { type: "turn" }> = {
|
|
276
286
|
type: "turn",
|
|
277
287
|
step,
|
|
278
288
|
component: lastCard.response?.component,
|
|
279
|
-
}
|
|
289
|
+
};
|
|
290
|
+
// Set the key ONLY when an arm was seen, for the same reason `handleFinish` does it on the
|
|
291
|
+
// result: a host reading `Object.keys(event)` sees no change when no experiment is running.
|
|
292
|
+
if (variantRef.current !== undefined) turn.variant = variantRef.current;
|
|
293
|
+
onEventRef.current?.(turn);
|
|
280
294
|
// Prefetch: an InterstitialCard's answer is always "continue", so we can warm the next
|
|
281
295
|
// turn's cache during the momentum window — by the time the user taps Continue the following
|
|
282
296
|
// card is already generated. The preview MUST use the exact text the real Continue tap sends,
|
|
@@ -366,7 +380,17 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
366
380
|
const handleFinish = useCallback(() => {
|
|
367
381
|
if (finished.current) return;
|
|
368
382
|
finished.current = true;
|
|
369
|
-
|
|
383
|
+
const result: OnboardingResult = { answers: deriveAnswers(messages), raw: messages };
|
|
384
|
+
// The plan rides a second DataPart on the AI path only; the static flow carries none. Setting
|
|
385
|
+
// the key ONLY when there is one keeps the no-plan result byte-identical to the pre-plan
|
|
386
|
+
// object — a host that inspects `Object.keys(result)` or `"plan" in result` sees no change.
|
|
387
|
+
const plan = readPlan(messages);
|
|
388
|
+
if (plan !== undefined) result.plan = plan;
|
|
389
|
+
// The experiment arm, latched off the render envelopes during the flow. Set under exactly the
|
|
390
|
+
// same rule as the plan: no arm seen → no key, so the result stays byte-identical for the
|
|
391
|
+
// tenants (most of them) that run no experiment.
|
|
392
|
+
if (variantRef.current !== undefined) result.variant = variantRef.current;
|
|
393
|
+
onCompleteRef.current(result);
|
|
370
394
|
}, [messages]);
|
|
371
395
|
|
|
372
396
|
// Manual retry (the no-fallback ErrorBlock button): clear the degrade + counter so
|
package/src/types.ts
CHANGED
|
@@ -53,6 +53,29 @@ export type OnboardingResult = {
|
|
|
53
53
|
answers: Record<string, unknown>;
|
|
54
54
|
/** The raw message thread, for custom downstream parsing. */
|
|
55
55
|
raw: Message[];
|
|
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.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ The kit does NOT interpret this and does NOT validate it. It checks one structural fact (a
|
|
63
|
+
* plan is an object) and hands the payload straight through, unread, unlogged, and never attached
|
|
64
|
+
* to an analytics event. THE HOST MUST VALIDATE IT before applying it: the fields are
|
|
65
|
+
* backend-authored, they may gain new ones without a kit release, and what any of them mean is
|
|
66
|
+
* the host's decision, not the kit's.
|
|
67
|
+
*/
|
|
68
|
+
plan?: unknown;
|
|
69
|
+
/**
|
|
70
|
+
* The experiment ARM the backend assigned this session, when it is running one. Absent when
|
|
71
|
+
* the tenant runs no experiment, which is the common case, and absent is NOT an error.
|
|
72
|
+
* The kit does NOT interpret it, does not log it, does not brand it and never attaches it
|
|
73
|
+
* to an analytics event. What an arm key MEANS is the host's decision.
|
|
74
|
+
*
|
|
75
|
+
* Like {@link plan}, the KEY is set only when an arm was actually seen, so a run with no
|
|
76
|
+
* experiment leaves the result byte-identical to what every host already reads.
|
|
77
|
+
*/
|
|
78
|
+
variant?: string;
|
|
56
79
|
};
|
|
57
80
|
|
|
58
81
|
/**
|
|
@@ -65,7 +88,11 @@ export type OnboardingResult = {
|
|
|
65
88
|
* - `resumed`: a persisted session was restored after an app kill (fires INSTEAD of
|
|
66
89
|
* `started`, so host funnels don't double-count the same session). Also carries
|
|
67
90
|
* `contextId`. Requires the `storage` prop.
|
|
68
|
-
* - `turn`: a new assistant card arrived (`step` = 1-based index of cards seen).
|
|
91
|
+
* - `turn`: a new assistant card arrived (`step` = 1-based index of cards seen). Carries
|
|
92
|
+
* `variant`, the backend's experiment arm for this session, from the first card that
|
|
93
|
+
* declared one onwards — this is how the arm is available DURING the flow rather than
|
|
94
|
+
* only at completion. The key is absent whenever no arm has been seen, which is every
|
|
95
|
+
* turn of every tenant running no experiment.
|
|
69
96
|
* - `error`: the backend errored or the first-card watchdog timed out.
|
|
70
97
|
* - `retry`: a transient failure is being auto-retried (`attempt` = 1-based).
|
|
71
98
|
* - `fallback`: retries are exhausted; the kit degraded to the static `fallbackFlow`
|
|
@@ -79,7 +106,7 @@ export type OnboardingResult = {
|
|
|
79
106
|
export type OnboardingEvent =
|
|
80
107
|
| { type: "started"; contextId: string }
|
|
81
108
|
| { type: "resumed"; contextId: string }
|
|
82
|
-
| { type: "turn"; step: number; component?: string }
|
|
109
|
+
| { type: "turn"; step: number; component?: string; variant?: string }
|
|
83
110
|
| { type: "error"; reason: "backend" | "timeout" }
|
|
84
111
|
| { type: "retry"; reason: "backend" | "timeout"; attempt: number }
|
|
85
112
|
| { type: "fallback"; reason: "backend" | "timeout" }
|
|
@@ -350,4 +377,15 @@ export type OnboardingProgress = {
|
|
|
350
377
|
slot_id?: string;
|
|
351
378
|
/** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
|
|
352
379
|
skippable?: boolean;
|
|
380
|
+
/**
|
|
381
|
+
* The EXPERIMENT ARM the backend assigned this session, when the tenant is running one. Rides the
|
|
382
|
+
* render envelope as a sibling of {@link step}/{@link total}/{@link key}/{@link slot_id}, and is
|
|
383
|
+
* OMITTED entirely for a tenant running no experiment — which is the common case, and is not an
|
|
384
|
+
* error. The assignment is sticky for the session, so a later card that omits it does not
|
|
385
|
+
* un-assign it.
|
|
386
|
+
*
|
|
387
|
+
* The kit does NOT interpret this. It is whitelisted, latched and handed to the host exactly as
|
|
388
|
+
* received; what an arm key MEANS is the host's decision. See {@link OnboardingResult.variant}.
|
|
389
|
+
*/
|
|
390
|
+
variant?: string;
|
|
353
391
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readPlan — lift the backend's onboarding plan off the message thread.
|
|
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
|
|
8
|
+
* attach it to any event — the plan is user-derived content, and deciding what it MEANS is the
|
|
9
|
+
* host's job (the kit/host boundary in `ai_rules/context_map.md`: the kit ends at the completion
|
|
10
|
+
* CTA).
|
|
11
|
+
*
|
|
12
|
+
* THE ONE THING IT IS ALLOWED TO REJECT is a structural fact: a plan is an object. A scalar, an
|
|
13
|
+
* array, or a missing payload is not a plan, and yields `undefined` so the host falls back to
|
|
14
|
+
* exactly its pre-plan behavior. Nothing below that line is checked: no field is required and no
|
|
15
|
+
* unknown field is stripped (the server's lenient path adds its own markers), because a kit-side
|
|
16
|
+
* schema would make the kit the thing that rejects a plan the host could have used.
|
|
17
|
+
*
|
|
18
|
+
* MALFORMED INPUT NEVER THROWS. This runs inside `handleFinish`, the single path to `onComplete`,
|
|
19
|
+
* so a throw here would cost the user the completion of an onboarding they already finished. Every
|
|
20
|
+
* step below is a runtime-guarded read.
|
|
21
|
+
*
|
|
22
|
+
* The static (non-AI) flow carries no plan at all. That path is unchanged and fully supported.
|
|
23
|
+
*/
|
|
24
|
+
import type { Message } from "wireai-rn";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The read-back member the SDK surfaces for every A2A DataPart past the first, uninterpreted.
|
|
28
|
+
*
|
|
29
|
+
* NOT in wire order, and nothing here may assume it is. The SDK FLATTENS a task's parts across
|
|
30
|
+
* agent messages LATEST-FIRST (and then artifacts latest-first) before dropping the first one, so
|
|
31
|
+
* this array is a cross-message collection, not one turn's parts in the order they arrived. On a
|
|
32
|
+
* task carrying history, a previous turn's component envelope lands here beside this turn's plan.
|
|
33
|
+
* `readPlan` is safe from that because it matches the `kind` MARKER below, never a position.
|
|
34
|
+
*
|
|
35
|
+
* Declared HERE, structurally, because the installed `wireai-rn` peer (0.2.4) does not declare it
|
|
36
|
+
* on `Message` yet.
|
|
37
|
+
*
|
|
38
|
+
* TODO(re-pin): delete this local type and read the member straight off `Message` once a
|
|
39
|
+
* `wireai-rn` that declares it is published and the peer range is re-pinned. Until then the two
|
|
40
|
+
* shapes are equal by CONVENTION, not by the compiler, and `hasDataParts` below is what makes that
|
|
41
|
+
* safe at runtime. Deliberately NOT an `as` cast: a cast silences the compiler in BOTH directions,
|
|
42
|
+
* including the day the published type disagrees with this one.
|
|
43
|
+
*/
|
|
44
|
+
type WithDataParts = { dataParts: readonly unknown[] };
|
|
45
|
+
|
|
46
|
+
/** The marker the server stamps on the plan DataPart. */
|
|
47
|
+
const PLAN_PART_KIND = "onboarding_plan";
|
|
48
|
+
|
|
49
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
50
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
|
|
52
|
+
/** RUNTIME proof that a value carries a `dataParts` ARRAY, before anything indexes into one. */
|
|
53
|
+
const hasDataParts = (value: unknown): value is WithDataParts =>
|
|
54
|
+
isRecord(value) && Array.isArray(value.dataParts);
|
|
55
|
+
|
|
56
|
+
/** The plan a single DataPart carries, or `undefined` when that part is not a well-formed plan. */
|
|
57
|
+
const planFromPart = (part: unknown): unknown => {
|
|
58
|
+
if (!isRecord(part) || part.kind !== PLAN_PART_KIND) return undefined;
|
|
59
|
+
return isRecord(part.plan) ? part.plan : undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The plan the backend sent, or `undefined` when it sent none (or sent something that is not a
|
|
64
|
+
* plan). Never throws.
|
|
65
|
+
*
|
|
66
|
+
* ORDERING, stated so it is not undefined behavior: the thread is scanned NEWEST TURN FIRST, so a
|
|
67
|
+
* thread carrying two plans resolves to the LAST one — the same turn `handleFinish` completes on.
|
|
68
|
+
*
|
|
69
|
+
* Inside one message's `dataParts` the first WELL-FORMED plan part wins, and that is a scan order,
|
|
70
|
+
* NOT a wire-order guarantee. Wire order does not hold even within a single turn: `dataParts` is
|
|
71
|
+
* the SDK's flattened, cross-agent-message collection (latest-first, see `WithDataParts` above), so
|
|
72
|
+
* on a task carrying history a previous turn's parts sit in the same array. This is exactly why the
|
|
73
|
+
* match is on the `onboarding_plan` MARKER and never on a position. A part that is not a
|
|
74
|
+
* well-formed plan is skipped rather than treated as a terminator, so neither a stale envelope nor
|
|
75
|
+
* a broken payload can shadow a good plan.
|
|
76
|
+
*/
|
|
77
|
+
export const readPlan = (messages: readonly Message[]): unknown => {
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
79
|
+
const message = messages[i];
|
|
80
|
+
if (!hasDataParts(message)) continue;
|
|
81
|
+
for (const part of message.dataParts) {
|
|
82
|
+
const plan = planFromPart(part);
|
|
83
|
+
if (plan !== undefined) return plan;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
@@ -18,6 +18,8 @@ type PartialProgress = {
|
|
|
18
18
|
/** The stable per-slot identity, when the backend sends one. See `OnboardingProgress.slot_id`. */
|
|
19
19
|
slot_id?: string;
|
|
20
20
|
skippable?: boolean;
|
|
21
|
+
/** The experiment arm assigned to this session, when the tenant runs one. See `OnboardingProgress.variant`. */
|
|
22
|
+
variant?: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export const readProgress = (response?: WireAIResponse): PartialProgress => {
|
|
@@ -33,5 +35,8 @@ export const readProgress = (response?: WireAIResponse): PartialProgress => {
|
|
|
33
35
|
// Whitelisted the same way as every other field: an old backend simply omits it.
|
|
34
36
|
slot_id: typeof p.slot_id === "string" ? p.slot_id : undefined,
|
|
35
37
|
skippable: typeof p.skippable === "boolean" ? p.skippable : undefined,
|
|
38
|
+
// Whitelisted like the rest: a non-string (or absent) arm key reads as "no experiment", never
|
|
39
|
+
// as an error. The kit does not interpret the value — see `OnboardingProgress.variant`.
|
|
40
|
+
variant: typeof p.variant === "string" ? p.variant : undefined,
|
|
36
41
|
};
|
|
37
42
|
};
|