@whisperr/wizard 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +513 -67
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -113,7 +113,7 @@ var target2 = {
|
|
|
113
113
|
id: "nextjs",
|
|
114
114
|
displayName: "Next.js",
|
|
115
115
|
language: "typescript",
|
|
116
|
-
availability: "
|
|
116
|
+
availability: "available"
|
|
117
117
|
};
|
|
118
118
|
async function detect2(ctx) {
|
|
119
119
|
if (!await ctx.exists("package.json")) return null;
|
|
@@ -140,34 +140,37 @@ async function detect2(ctx) {
|
|
|
140
140
|
return { target: target2, confidence: Math.min(confidence, 1), evidence };
|
|
141
141
|
}
|
|
142
142
|
var systemPrompt2 = `
|
|
143
|
-
## SDK: Whisperr for
|
|
144
|
-
|
|
145
|
-
Same SDK as plain web, with Next-specific wiring.
|
|
143
|
+
## SDK: Whisperr for Next.js \u2014 package \`@whisperr/next\` (+ \`@whisperr/web\`)
|
|
146
144
|
|
|
147
|
-
1) Install
|
|
148
|
-
.env.local (and add it to .env.example).
|
|
145
|
+
1) Install: \`<pm> add @whisperr/next @whisperr/web\`. Put the key in
|
|
146
|
+
NEXT_PUBLIC_WHISPERR_KEY in .env.local (and add it to .env.example).
|
|
149
147
|
|
|
150
|
-
2)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
2) Mount the provider in app/layout.tsx. It is already a 'use client' boundary,
|
|
149
|
+
so it drops straight into the server-component layout \u2014 no wrapper file needed:
|
|
150
|
+
import { WhisperrProvider } from '@whisperr/next';
|
|
151
|
+
// inside <body>:
|
|
152
|
+
<WhisperrProvider apiKey={process.env.NEXT_PUBLIC_WHISPERR_KEY!}>{children}</WhisperrProvider>
|
|
153
|
+
Pages Router: wrap the tree in pages/_app.tsx with <WhisperrProvider apiKey=...>.
|
|
155
154
|
|
|
156
|
-
3) identify() after auth resolves on the client (
|
|
157
|
-
|
|
155
|
+
3) identify() after auth resolves on the client (in your session hook/effect);
|
|
156
|
+
reset() on sign-out. Use the useWhisperr() hook inside 'use client' components.
|
|
158
157
|
|
|
159
|
-
4) track() in client components / event handlers / mutation callbacks
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
4) track() in client components / event handlers / mutation callbacks:
|
|
159
|
+
'use client';
|
|
160
|
+
import { useWhisperr } from '@whisperr/next';
|
|
161
|
+
const whisperr = useWhisperr();
|
|
162
|
+
whisperr.track('event_type_from_manifest', { ... });
|
|
163
|
+
Do NOT call track from server components, route handlers, or getServerSideProps \u2014
|
|
164
|
+
it is a browser client. Copy event_type verbatim (snake_case) from the manifest.
|
|
162
165
|
|
|
163
166
|
Notes:
|
|
164
|
-
-
|
|
165
|
-
|
|
167
|
+
- Pageviews are auto-captured via the History API (covers Next client navigation).
|
|
168
|
+
- Provider init is idempotent (safe under React Strict Mode).
|
|
166
169
|
`.trim();
|
|
167
170
|
var nextjsPlaybook = {
|
|
168
171
|
target: target2,
|
|
169
172
|
detect: detect2,
|
|
170
|
-
packageRef: "@whisperr/
|
|
173
|
+
packageRef: "@whisperr/next (npm)",
|
|
171
174
|
systemPrompt: systemPrompt2
|
|
172
175
|
};
|
|
173
176
|
|
|
@@ -176,8 +179,7 @@ var target3 = {
|
|
|
176
179
|
id: "web-js",
|
|
177
180
|
displayName: "Web (JavaScript / TypeScript)",
|
|
178
181
|
language: "typescript",
|
|
179
|
-
|
|
180
|
-
availability: "planned"
|
|
182
|
+
availability: "available"
|
|
181
183
|
};
|
|
182
184
|
function dependsOn(pkgJson, names) {
|
|
183
185
|
try {
|
|
@@ -198,6 +200,19 @@ async function detect3(ctx) {
|
|
|
198
200
|
let confidence = 0.4;
|
|
199
201
|
if (dependsOn(pkgJson, ["next"])) return null;
|
|
200
202
|
if (dependsOn(pkgJson, ["react-native", "expo"])) return null;
|
|
203
|
+
const hasUi = dependsOn(pkgJson, ["react", "vue", "svelte", "@angular/core"]) || await ctx.exists("index.html");
|
|
204
|
+
const isBackend = dependsOn(pkgJson, [
|
|
205
|
+
"express",
|
|
206
|
+
"fastify",
|
|
207
|
+
"koa",
|
|
208
|
+
"@nestjs/core",
|
|
209
|
+
"hapi",
|
|
210
|
+
"@hapi/hapi",
|
|
211
|
+
"restify",
|
|
212
|
+
"h3",
|
|
213
|
+
"@adonisjs/core"
|
|
214
|
+
]);
|
|
215
|
+
if (isBackend && !hasUi) return null;
|
|
201
216
|
if (dependsOn(pkgJson, ["react", "vue", "svelte", "@angular/core"])) {
|
|
202
217
|
evidence.push("frontend framework dependency");
|
|
203
218
|
confidence += 0.4;
|
|
@@ -209,41 +224,41 @@ async function detect3(ctx) {
|
|
|
209
224
|
return { target: target3, confidence: Math.min(confidence, 0.95), evidence };
|
|
210
225
|
}
|
|
211
226
|
var systemPrompt3 = `
|
|
212
|
-
## SDK: Whisperr for Web
|
|
227
|
+
## SDK: Whisperr for Web \u2014 package \`@whisperr/web\` (+ \`@whisperr/react\` for React)
|
|
213
228
|
|
|
214
|
-
1)
|
|
215
|
-
|
|
229
|
+
1) Install (detect pnpm/yarn/npm from the lockfile):
|
|
230
|
+
- Vanilla / Vue / Svelte / Angular / plain TS: \`<pm> add @whisperr/web\`
|
|
231
|
+
- React (not Next.js): \`<pm> add @whisperr/react @whisperr/web\`
|
|
216
232
|
|
|
217
|
-
2) Initialize once at app entry
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
233
|
+
2) Initialize once at app entry, using the app's PUBLIC env var (VITE_*,
|
|
234
|
+
REACT_APP_*, etc.). Write the var into .env / .env.example.
|
|
235
|
+
- Core (any framework): create a singleton in e.g. src/whisperr.ts:
|
|
236
|
+
import { Whisperr } from '@whisperr/web';
|
|
237
|
+
export const whisperr = Whisperr.init({
|
|
238
|
+
apiKey: import.meta.env.VITE_WHISPERR_KEY,
|
|
239
|
+
baseUrl: '<INGESTION_BASE_URL from manifest>', // omit if it's the default
|
|
240
|
+
});
|
|
241
|
+
- React: wrap the app root instead of a bare singleton:
|
|
242
|
+
import { WhisperrProvider } from '@whisperr/react';
|
|
243
|
+
<WhisperrProvider apiKey={import.meta.env.VITE_WHISPERR_KEY}>\u2026</WhisperrProvider>
|
|
226
244
|
|
|
227
245
|
3) identify(). After auth resolves (login success, and on app load if a session
|
|
228
246
|
is restored):
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
email: user.email, phone: user.phone,
|
|
232
|
-
});
|
|
247
|
+
whisperr.identify(user.id, { traits: { /* manifest traits */ }, email: user.email, phone: user.phone });
|
|
248
|
+
React: const whisperr = useWhisperr(); then whisperr.identify(...).
|
|
233
249
|
Call whisperr.reset() on logout.
|
|
234
250
|
|
|
235
251
|
4) track(). For each manifest event, instrument the real handler:
|
|
236
|
-
|
|
237
|
-
In React
|
|
252
|
+
whisperr.track('event_type_from_manifest', { /* properties */ });
|
|
253
|
+
In React this belongs in event handlers / effects / mutation callbacks, never
|
|
238
254
|
in render. Copy event_type verbatim (snake_case) from the manifest.
|
|
239
255
|
|
|
240
256
|
Notes:
|
|
241
|
-
- The SDK batches
|
|
242
|
-
|
|
243
|
-
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
than scattering raw whisperr.track calls.
|
|
257
|
+
- The SDK batches + flushes automatically (timer + on page hide). Fire-and-forget
|
|
258
|
+
is fine; do NOT await track().
|
|
259
|
+
- It auto-captures pageviews \u2014 you only add the named business events.
|
|
260
|
+
- Only PUBLIC env vars (VITE_/REACT_APP_/NEXT_PUBLIC_) reach the browser; the
|
|
261
|
+
ingestion key is meant to be public + rate-limited, so that's expected.
|
|
247
262
|
`.trim();
|
|
248
263
|
var webPlaybook = {
|
|
249
264
|
target: target3,
|
|
@@ -253,14 +268,372 @@ var webPlaybook = {
|
|
|
253
268
|
verifyCommand: void 0
|
|
254
269
|
};
|
|
255
270
|
|
|
256
|
-
// src/core/playbooks/
|
|
271
|
+
// src/core/playbooks/node.ts
|
|
257
272
|
var target4 = {
|
|
273
|
+
id: "node",
|
|
274
|
+
displayName: "Node.js (backend)",
|
|
275
|
+
language: "typescript",
|
|
276
|
+
availability: "available"
|
|
277
|
+
};
|
|
278
|
+
function dependsOn2(pkgJson, names) {
|
|
279
|
+
try {
|
|
280
|
+
const pkg = JSON.parse(pkgJson);
|
|
281
|
+
const all = {
|
|
282
|
+
...pkg.dependencies ?? {},
|
|
283
|
+
...pkg.devDependencies ?? {}
|
|
284
|
+
};
|
|
285
|
+
return names.some((n) => n in all);
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
var BACKEND_FRAMEWORKS = [
|
|
291
|
+
"express",
|
|
292
|
+
"fastify",
|
|
293
|
+
"koa",
|
|
294
|
+
"@nestjs/core",
|
|
295
|
+
"hapi",
|
|
296
|
+
"@hapi/hapi",
|
|
297
|
+
"restify",
|
|
298
|
+
"hono",
|
|
299
|
+
"h3",
|
|
300
|
+
"@adonisjs/core"
|
|
301
|
+
];
|
|
302
|
+
var FRONTEND_FRAMEWORKS = [
|
|
303
|
+
"react",
|
|
304
|
+
"vue",
|
|
305
|
+
"svelte",
|
|
306
|
+
"@angular/core",
|
|
307
|
+
"next",
|
|
308
|
+
"react-native",
|
|
309
|
+
"expo"
|
|
310
|
+
];
|
|
311
|
+
var SERVER_ENTRYPOINTS = [
|
|
312
|
+
"server.js",
|
|
313
|
+
"server.ts",
|
|
314
|
+
"src/server.ts",
|
|
315
|
+
"src/server.js",
|
|
316
|
+
"app.js",
|
|
317
|
+
"src/app.ts",
|
|
318
|
+
"src/index.ts",
|
|
319
|
+
"index.js"
|
|
320
|
+
];
|
|
321
|
+
async function detect4(ctx) {
|
|
322
|
+
if (!await ctx.exists("package.json")) return null;
|
|
323
|
+
const pkgJson = await ctx.read("package.json");
|
|
324
|
+
const evidence = ["package.json"];
|
|
325
|
+
let confidence = 0;
|
|
326
|
+
if (dependsOn2(pkgJson, BACKEND_FRAMEWORKS)) {
|
|
327
|
+
evidence.push("backend framework dependency");
|
|
328
|
+
confidence += 0.7;
|
|
329
|
+
}
|
|
330
|
+
const hasFrontend = dependsOn2(pkgJson, FRONTEND_FRAMEWORKS);
|
|
331
|
+
const hasIndexHtml = await ctx.exists("index.html");
|
|
332
|
+
if ((hasFrontend || hasIndexHtml) && confidence === 0) return null;
|
|
333
|
+
for (const f of SERVER_ENTRYPOINTS) {
|
|
334
|
+
if (await ctx.exists(f)) {
|
|
335
|
+
evidence.push(f);
|
|
336
|
+
confidence += 0.15;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (confidence === 0 && !hasFrontend && !hasIndexHtml) {
|
|
341
|
+
try {
|
|
342
|
+
const pkg = JSON.parse(pkgJson);
|
|
343
|
+
const start = String(pkg.scripts?.start ?? "");
|
|
344
|
+
if (/\b(node|nodemon|ts-node|tsx)\b/.test(start)) {
|
|
345
|
+
evidence.push("node start script");
|
|
346
|
+
confidence += 0.5;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (confidence === 0) return null;
|
|
352
|
+
return { target: target4, confidence: Math.min(confidence, 0.95), evidence };
|
|
353
|
+
}
|
|
354
|
+
var systemPrompt4 = `
|
|
355
|
+
## SDK: Whisperr for Node (server-side) \u2014 package \`@whisperr/node\` (+ \`@whisperr/node/express\` for Express)
|
|
356
|
+
|
|
357
|
+
This is a BACKEND surface. Instrument events at the real SERVER-SIDE moment they
|
|
358
|
+
happen \u2014 inside request handlers, webhook handlers, billing logic, queue/cron
|
|
359
|
+
jobs, and service methods. The events in your plan are the ones that originate on
|
|
360
|
+
the server (payments, subscription/lifecycle changes, server-computed
|
|
361
|
+
thresholds); they will NOT be in any UI. Do not look for click/screen handlers.
|
|
362
|
+
|
|
363
|
+
The ONE thing that's different from the browser SDK: \`track()\` and
|
|
364
|
+
\`identify()\` take the end-user id as the FIRST argument \u2014 the server has no
|
|
365
|
+
persisted session to infer it from. Source the real user id at each call site.
|
|
366
|
+
|
|
367
|
+
1) Install (detect pnpm/yarn/npm from the lockfile):
|
|
368
|
+
<pm> add @whisperr/node
|
|
369
|
+
|
|
370
|
+
2) Create one shared client module, e.g. src/whisperr.ts (or .js):
|
|
371
|
+
import { createWhisperr } from '@whisperr/node';
|
|
372
|
+
export const whisperr = createWhisperr({
|
|
373
|
+
apiKey: process.env.WHISPERR_API_KEY!,
|
|
374
|
+
baseUrl: '<INGESTION_BASE_URL from manifest>', // omit if it's the default
|
|
375
|
+
});
|
|
376
|
+
Put WHISPERR_API_KEY in the app's server config and add it to .env /
|
|
377
|
+
.env.example. This is a SERVER env var \u2014 never a public/VITE_/NEXT_PUBLIC_ one.
|
|
378
|
+
|
|
379
|
+
3) identify(externalUserId, { ... }). Call where you already know the user \u2014
|
|
380
|
+
after signup, or when user-level traits/contact channels change:
|
|
381
|
+
whisperr.identify(user.id, { traits: { plan: user.plan }, email: user.email });
|
|
382
|
+
Use the same stable id you use everywhere else for that user, so server and
|
|
383
|
+
client events land on one timeline.
|
|
384
|
+
|
|
385
|
+
4) track(externalUserId, eventType, properties). For each plan event, find the
|
|
386
|
+
real server-side moment and source the user id from what's in scope:
|
|
387
|
+
- In a request handler: whisperr.track(req.user.id, 'plan_upgraded', { plan });
|
|
388
|
+
- In a webhook/cron/job: there's no request \u2014 get the id from the domain
|
|
389
|
+
object (e.g. subscription.userId, or map the Stripe customer to your user):
|
|
390
|
+
whisperr.track(subscription.userId, 'subscription_cancelled', { reason });
|
|
391
|
+
Copy event_type verbatim (snake_case) from the plan. If you genuinely can't
|
|
392
|
+
source a user id at the only sensible site, leave a one-line
|
|
393
|
+
\`// TODO(whisperr): need user id to track <event>\` and move on.
|
|
394
|
+
|
|
395
|
+
5) Express only \u2014 if this app uses Express, mount the adapter AFTER auth so the
|
|
396
|
+
user is resolvable, then use the per-request helper at call sites:
|
|
397
|
+
import { whisperrExpress } from '@whisperr/node/express';
|
|
398
|
+
app.use(whisperrExpress(whisperr)); // pass { resolveUser } if your auth isn't req.user.id
|
|
399
|
+
// ...later, in a route:
|
|
400
|
+
req.whisperr.track('payment_failed', { amount_cents });
|
|
401
|
+
|
|
402
|
+
6) Lifecycle. Flush on graceful shutdown so queued events aren't lost:
|
|
403
|
+
process.on('SIGTERM', () => { whisperr.shutdown(); });
|
|
404
|
+
In serverless handlers (Lambda/Vercel/Cloud Functions), \`await whisperr.flush()\`
|
|
405
|
+
before returning, since the runtime freezes after the response.
|
|
406
|
+
|
|
407
|
+
Notes / gotchas:
|
|
408
|
+
- The client queues + batches + retries automatically. Fire-and-forget is fine;
|
|
409
|
+
do NOT await track()/identify() \u2014 only await flush()/shutdown().
|
|
410
|
+
- track()/identify() no-op silently if the user id is empty \u2014 make sure you pass
|
|
411
|
+
a real id.
|
|
412
|
+
- One client instance per process (the exported singleton). Don't create a new
|
|
413
|
+
client per request.
|
|
414
|
+
`.trim();
|
|
415
|
+
var nodePlaybook = {
|
|
416
|
+
target: target4,
|
|
417
|
+
detect: detect4,
|
|
418
|
+
packageRef: "@whisperr/node (npm)",
|
|
419
|
+
systemPrompt: systemPrompt4,
|
|
420
|
+
verifyCommand: void 0
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/core/playbooks/python.ts
|
|
424
|
+
var target5 = {
|
|
425
|
+
id: "python",
|
|
426
|
+
displayName: "Python (backend)",
|
|
427
|
+
language: "python",
|
|
428
|
+
availability: "available"
|
|
429
|
+
};
|
|
430
|
+
var PROJECT_FILES = [
|
|
431
|
+
"pyproject.toml",
|
|
432
|
+
"requirements.txt",
|
|
433
|
+
"setup.py",
|
|
434
|
+
"setup.cfg",
|
|
435
|
+
"Pipfile"
|
|
436
|
+
];
|
|
437
|
+
var WEB_FRAMEWORKS = [
|
|
438
|
+
"django",
|
|
439
|
+
"flask",
|
|
440
|
+
"fastapi",
|
|
441
|
+
"starlette",
|
|
442
|
+
"sanic",
|
|
443
|
+
"aiohttp",
|
|
444
|
+
"tornado",
|
|
445
|
+
"bottle",
|
|
446
|
+
"falcon",
|
|
447
|
+
"litestar"
|
|
448
|
+
];
|
|
449
|
+
async function detect5(ctx) {
|
|
450
|
+
const present = [];
|
|
451
|
+
for (const f of PROJECT_FILES) {
|
|
452
|
+
if (await ctx.exists(f)) present.push(f);
|
|
453
|
+
}
|
|
454
|
+
const hasManage = await ctx.exists("manage.py");
|
|
455
|
+
if (present.length === 0 && !hasManage) return null;
|
|
456
|
+
const evidence = [...present];
|
|
457
|
+
let confidence = present.length ? 0.5 : 0.3;
|
|
458
|
+
let deps = "";
|
|
459
|
+
for (const f of present) deps += (await ctx.read(f)).toLowerCase() + "\n";
|
|
460
|
+
if (WEB_FRAMEWORKS.some((fw) => deps.includes(fw))) {
|
|
461
|
+
evidence.push("python web framework");
|
|
462
|
+
confidence += 0.35;
|
|
463
|
+
}
|
|
464
|
+
if (hasManage) {
|
|
465
|
+
evidence.push("manage.py");
|
|
466
|
+
confidence += 0.2;
|
|
467
|
+
}
|
|
468
|
+
return { target: target5, confidence: Math.min(confidence, 0.95), evidence };
|
|
469
|
+
}
|
|
470
|
+
var systemPrompt5 = `
|
|
471
|
+
## SDK: Whisperr for Python (server-side) \u2014 package \`whisperr\` (Django middleware in \`whisperr.django\`)
|
|
472
|
+
|
|
473
|
+
This is a BACKEND surface. Instrument events at the real SERVER-SIDE moment they
|
|
474
|
+
happen \u2014 inside views/request handlers, webhook handlers, billing logic, Celery
|
|
475
|
+
tasks, and management commands. The events in your plan originate on the server
|
|
476
|
+
(payments, subscription/lifecycle changes, server-computed thresholds); they will
|
|
477
|
+
NOT be in any UI. Do not look for click/screen handlers.
|
|
478
|
+
|
|
479
|
+
The ONE thing that's different from a browser SDK: \`track()\` and \`identify()\`
|
|
480
|
+
take the end-user id as the FIRST argument \u2014 the server has no persisted session
|
|
481
|
+
to infer it from. Source the real user id at each call site.
|
|
482
|
+
|
|
483
|
+
1) Install (detect the project's tool \u2014 pip/poetry/pipenv/uv from the lockfile or
|
|
484
|
+
pyproject): add \`whisperr\` (use \`whisperr[django]\` on a Django project).
|
|
485
|
+
|
|
486
|
+
2) Create one shared client, e.g. app/whisperr_client.py:
|
|
487
|
+
import os
|
|
488
|
+
from whisperr import Whisperr
|
|
489
|
+
whisperr = Whisperr(
|
|
490
|
+
api_key=os.environ["WHISPERR_API_KEY"],
|
|
491
|
+
base_url="<INGESTION_BASE_URL from manifest>", # omit if it's the default
|
|
492
|
+
)
|
|
493
|
+
Put WHISPERR_API_KEY in the app's server config / .env (server-side secret).
|
|
494
|
+
|
|
495
|
+
3) identify(external_user_id, ...). Where you already know the user (signup, or
|
|
496
|
+
when user-level traits/contact channels change):
|
|
497
|
+
whisperr.identify(user.id, traits={"plan": user.plan}, email=user.email)
|
|
498
|
+
|
|
499
|
+
4) track(external_user_id, event_type, properties). Find the real server-side
|
|
500
|
+
moment and source the user id from what's in scope:
|
|
501
|
+
- In a handler/view: whisperr.track(request.user.id, "plan_upgraded", {"plan": plan})
|
|
502
|
+
- In a webhook/task: there's no request \u2014 get the id from the domain object
|
|
503
|
+
(e.g. subscription.user_id, or map the Stripe customer to your user):
|
|
504
|
+
whisperr.track(subscription.user_id, "subscription_cancelled", {"reason": reason})
|
|
505
|
+
Copy event_type verbatim (snake_case) from the plan. If you genuinely can't
|
|
506
|
+
source a user id at the only sensible site, leave a one-line
|
|
507
|
+
\`# TODO(whisperr): need user id to track <event>\` and move on.
|
|
508
|
+
|
|
509
|
+
5) Django only \u2014 register the middleware AFTER the auth middleware in settings.py,
|
|
510
|
+
then use the per-request helper in views:
|
|
511
|
+
# settings.py
|
|
512
|
+
WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
|
|
513
|
+
MIDDLEWARE = [ ..., "django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
514
|
+
"whisperr.django.WhisperrMiddleware" ]
|
|
515
|
+
# views.py
|
|
516
|
+
request.whisperr.track("payment_failed", {"amount_cents": amount})
|
|
517
|
+
For Celery/webhook/management-command code (no request), use the shared client
|
|
518
|
+
or whisperr.django.get_client() with the user id from your data.
|
|
519
|
+
|
|
520
|
+
6) Lifecycle. The client flushes in the background and on interpreter exit
|
|
521
|
+
(atexit). In short-lived processes (management commands, scripts, serverless),
|
|
522
|
+
call \`whisperr.flush()\` before returning so queued events aren't lost.
|
|
523
|
+
|
|
524
|
+
Notes / gotchas:
|
|
525
|
+
- The client queues + batches + retries automatically on a daemon thread. Fire-
|
|
526
|
+
and-forget is fine; do NOT block on track()/identify() \u2014 only flush()/shutdown().
|
|
527
|
+
- track()/identify() no-op silently if the user id is empty \u2014 pass a real id.
|
|
528
|
+
- One client instance per process. Don't construct a new client per request.
|
|
529
|
+
`.trim();
|
|
530
|
+
var pythonPlaybook = {
|
|
531
|
+
target: target5,
|
|
532
|
+
detect: detect5,
|
|
533
|
+
packageRef: "whisperr (PyPI)",
|
|
534
|
+
systemPrompt: systemPrompt5,
|
|
535
|
+
verifyCommand: void 0
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/core/playbooks/php.ts
|
|
539
|
+
var target6 = {
|
|
540
|
+
id: "php",
|
|
541
|
+
displayName: "PHP (backend)",
|
|
542
|
+
language: "php",
|
|
543
|
+
availability: "available"
|
|
544
|
+
};
|
|
545
|
+
var FRAMEWORKS = [
|
|
546
|
+
"laravel/framework",
|
|
547
|
+
"symfony/",
|
|
548
|
+
"slim/slim",
|
|
549
|
+
"laminas/",
|
|
550
|
+
"cakephp/",
|
|
551
|
+
"yiisoft/",
|
|
552
|
+
"codeigniter4/"
|
|
553
|
+
];
|
|
554
|
+
async function detect6(ctx) {
|
|
555
|
+
if (!await ctx.exists("composer.json")) return null;
|
|
556
|
+
const composer = (await ctx.read("composer.json")).toLowerCase();
|
|
557
|
+
const evidence = ["composer.json"];
|
|
558
|
+
let confidence = 0.5;
|
|
559
|
+
if (FRAMEWORKS.some((f) => composer.includes(f))) {
|
|
560
|
+
evidence.push("php framework");
|
|
561
|
+
confidence += 0.35;
|
|
562
|
+
}
|
|
563
|
+
if (await ctx.exists("artisan")) {
|
|
564
|
+
evidence.push("artisan (Laravel)");
|
|
565
|
+
confidence += 0.15;
|
|
566
|
+
}
|
|
567
|
+
return { target: target6, confidence: Math.min(confidence, 0.95), evidence };
|
|
568
|
+
}
|
|
569
|
+
var systemPrompt6 = `
|
|
570
|
+
## SDK: Whisperr for PHP (server-side) \u2014 package \`whisperr/php\` (Laravel facade + service provider included)
|
|
571
|
+
|
|
572
|
+
This is a BACKEND surface. Instrument events at the real SERVER-SIDE moment they
|
|
573
|
+
happen \u2014 inside controllers/request handlers, webhook handlers, billing logic,
|
|
574
|
+
queued jobs, and console commands. The events in your plan originate on the
|
|
575
|
+
server (payments, subscription/lifecycle changes, server-computed thresholds);
|
|
576
|
+
they will NOT be in any UI. Do not look for click handlers / Blade views.
|
|
577
|
+
|
|
578
|
+
The ONE thing that's different from a browser SDK: \`track()\` and \`identify()\`
|
|
579
|
+
take the end-user id as the FIRST argument \u2014 the server has no session to infer
|
|
580
|
+
it from. Source the real user id at each call site.
|
|
581
|
+
|
|
582
|
+
1) Install:
|
|
583
|
+
composer require whisperr/php
|
|
584
|
+
|
|
585
|
+
2) Config. Set the key in the environment (.env), never hardcoded:
|
|
586
|
+
WHISPERR_API_KEY=<INGESTION_API_KEY from manifest>
|
|
587
|
+
On Laravel the service provider + \`Whisperr\` facade auto-register (package
|
|
588
|
+
discovery). Optionally publish config: \`php artisan vendor:publish --tag=whisperr-config\`.
|
|
589
|
+
On non-Laravel PHP, construct one client and reuse it:
|
|
590
|
+
use Whisperr\\Whisperr;
|
|
591
|
+
$whisperr = new Whisperr(['api_key' => getenv('WHISPERR_API_KEY')]);
|
|
592
|
+
|
|
593
|
+
3) identify(externalUserId, [...]). Where you already know the user (after login/
|
|
594
|
+
signup, or when user-level traits/contact channels change):
|
|
595
|
+
use Whisperr\\Laravel\\Facades\\Whisperr;
|
|
596
|
+
Whisperr::identify(auth()->id(), ['traits' => ['plan' => $user->plan], 'email' => $user->email]);
|
|
597
|
+
|
|
598
|
+
4) track(externalUserId, eventType, properties). Find the real server-side moment
|
|
599
|
+
and source the user id from what's in scope:
|
|
600
|
+
- In a controller: Whisperr::track(auth()->id(), 'plan_upgraded', ['plan' => $plan]);
|
|
601
|
+
- In a webhook/job: no auth() \u2014 get the id from the domain object
|
|
602
|
+
(e.g. $subscription->user_id, or map the Stripe customer to your user):
|
|
603
|
+
Whisperr::track($subscription->user_id, 'subscription_cancelled', ['reason' => $reason]);
|
|
604
|
+
Copy event_type verbatim (snake_case) from the plan. If you genuinely can't
|
|
605
|
+
source a user id at the only sensible site, leave a one-line
|
|
606
|
+
\`// TODO(whisperr): need user id to track <event>\` and move on.
|
|
607
|
+
On plain PHP use \`$whisperr->track(...)\` on your shared instance instead of the facade.
|
|
608
|
+
|
|
609
|
+
5) Lifecycle. On Laravel the buffered events flush automatically AFTER the
|
|
610
|
+
response is sent (the service provider hooks \`terminating\`), so tracking adds
|
|
611
|
+
no response latency. The client also flushes on shutdown. In short-lived
|
|
612
|
+
scripts/commands call \`Whisperr::flush()\` (or \`$whisperr->flush()\`) before exit.
|
|
613
|
+
|
|
614
|
+
Notes / gotchas:
|
|
615
|
+
- Events buffer in memory during the request and send in a batch on flush. Fire-
|
|
616
|
+
and-forget is fine; don't block on track()/identify().
|
|
617
|
+
- track()/identify() no-op silently if the user id is empty \u2014 pass a real id
|
|
618
|
+
(guard webhook code where auth() is null).
|
|
619
|
+
- Use the same stable user id everywhere so server + client events line up.
|
|
620
|
+
`.trim();
|
|
621
|
+
var phpPlaybook = {
|
|
622
|
+
target: target6,
|
|
623
|
+
detect: detect6,
|
|
624
|
+
packageRef: "whisperr/php (Packagist)",
|
|
625
|
+
systemPrompt: systemPrompt6,
|
|
626
|
+
verifyCommand: void 0
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/core/playbooks/react-native.ts
|
|
630
|
+
var target7 = {
|
|
258
631
|
id: "react-native",
|
|
259
632
|
displayName: "React Native",
|
|
260
633
|
language: "typescript",
|
|
261
634
|
availability: "planned"
|
|
262
635
|
};
|
|
263
|
-
async function
|
|
636
|
+
async function detect7(ctx) {
|
|
264
637
|
if (!await ctx.exists("package.json")) return null;
|
|
265
638
|
const pkgJson = await ctx.read("package.json");
|
|
266
639
|
let match = false;
|
|
@@ -278,9 +651,9 @@ async function detect4(ctx) {
|
|
|
278
651
|
evidence.push("app.json");
|
|
279
652
|
confidence += 0.1;
|
|
280
653
|
}
|
|
281
|
-
return { target:
|
|
654
|
+
return { target: target7, confidence: Math.min(confidence, 1), evidence };
|
|
282
655
|
}
|
|
283
|
-
var
|
|
656
|
+
var systemPrompt7 = `
|
|
284
657
|
## SDK: Whisperr for React Native \u2014 package \`@whisperr/react-native\`
|
|
285
658
|
|
|
286
659
|
1) Install \`@whisperr/react-native\` with the repo's package manager. If it has
|
|
@@ -303,20 +676,20 @@ Notes:
|
|
|
303
676
|
if one exists; otherwise a constants file is acceptable.
|
|
304
677
|
`.trim();
|
|
305
678
|
var reactNativePlaybook = {
|
|
306
|
-
target:
|
|
307
|
-
detect:
|
|
679
|
+
target: target7,
|
|
680
|
+
detect: detect7,
|
|
308
681
|
packageRef: "@whisperr/react-native (npm)",
|
|
309
|
-
systemPrompt:
|
|
682
|
+
systemPrompt: systemPrompt7
|
|
310
683
|
};
|
|
311
684
|
|
|
312
685
|
// src/core/playbooks/swift.ts
|
|
313
|
-
var
|
|
686
|
+
var target8 = {
|
|
314
687
|
id: "swift",
|
|
315
688
|
displayName: "Swift (iOS)",
|
|
316
689
|
language: "swift",
|
|
317
690
|
availability: "planned"
|
|
318
691
|
};
|
|
319
|
-
async function
|
|
692
|
+
async function detect8(ctx) {
|
|
320
693
|
const evidence = [];
|
|
321
694
|
let confidence = 0;
|
|
322
695
|
if (await ctx.exists("Package.swift")) {
|
|
@@ -337,9 +710,9 @@ async function detect5(ctx) {
|
|
|
337
710
|
confidence += 0.2;
|
|
338
711
|
}
|
|
339
712
|
if (confidence === 0) return null;
|
|
340
|
-
return { target:
|
|
713
|
+
return { target: target8, confidence: Math.min(confidence, 1), evidence };
|
|
341
714
|
}
|
|
342
|
-
var
|
|
715
|
+
var systemPrompt8 = `
|
|
343
716
|
## SDK: Whisperr for Swift (iOS) \u2014 Swift Package \`Whisperr\`
|
|
344
717
|
|
|
345
718
|
1) Add the Swift Package dependency (github.com/WhisperrAI/whisperr-swift). If the
|
|
@@ -367,10 +740,10 @@ Notes:
|
|
|
367
740
|
- Do not attempt to run xcodebuild; verification is the human's step here.
|
|
368
741
|
`.trim();
|
|
369
742
|
var swiftPlaybook = {
|
|
370
|
-
target:
|
|
371
|
-
detect:
|
|
743
|
+
target: target8,
|
|
744
|
+
detect: detect8,
|
|
372
745
|
packageRef: "Whisperr (Swift Package)",
|
|
373
|
-
systemPrompt:
|
|
746
|
+
systemPrompt: systemPrompt8
|
|
374
747
|
};
|
|
375
748
|
|
|
376
749
|
// src/core/playbooks/index.ts
|
|
@@ -379,6 +752,9 @@ var ALL_PLAYBOOKS = [
|
|
|
379
752
|
nextjsPlaybook,
|
|
380
753
|
reactNativePlaybook,
|
|
381
754
|
webPlaybook,
|
|
755
|
+
nodePlaybook,
|
|
756
|
+
pythonPlaybook,
|
|
757
|
+
phpPlaybook,
|
|
382
758
|
swiftPlaybook
|
|
383
759
|
];
|
|
384
760
|
function playbookByTargetId(id) {
|
|
@@ -679,7 +1055,7 @@ function renderEventsBrief(m) {
|
|
|
679
1055
|
lines.push("");
|
|
680
1056
|
}
|
|
681
1057
|
lines.push(
|
|
682
|
-
`${events.length} candidate events (highest-impact first). For each: fire track('<event_type>', { ...properties }) at the real moment it happens, and attach every listed property you can source from values in scope there. Skip an event entirely if it has no clear
|
|
1058
|
+
`${events.length} candidate events (highest-impact first). For each: fire track('<event_type>', { ...properties }) at the real moment it happens, and attach every listed property you can source from values in scope there. Skip an event entirely if it has no clear trigger in this codebase.`
|
|
683
1059
|
);
|
|
684
1060
|
if (events.some((e) => e.coverage?.length)) {
|
|
685
1061
|
lines.push("");
|
|
@@ -719,7 +1095,7 @@ function coverageNote(coverage) {
|
|
|
719
1095
|
async function runIntegrationAgent(opts) {
|
|
720
1096
|
const { repoPath, config, session, playbook, manifest, progress } = opts;
|
|
721
1097
|
applyModelAuthEnv(config, session);
|
|
722
|
-
const
|
|
1098
|
+
const systemPrompt9 = [BASE_WIZARD_PROMPT, playbook.systemPrompt].join("\n\n");
|
|
723
1099
|
const started = Date.now();
|
|
724
1100
|
let costUsd = 0;
|
|
725
1101
|
const summaries = [];
|
|
@@ -748,7 +1124,7 @@ async function runIntegrationAgent(opts) {
|
|
|
748
1124
|
].join("\n");
|
|
749
1125
|
const core = await runPass({
|
|
750
1126
|
prompt: corePrompt,
|
|
751
|
-
systemPrompt:
|
|
1127
|
+
systemPrompt: systemPrompt9,
|
|
752
1128
|
repoPath,
|
|
753
1129
|
model: config.model,
|
|
754
1130
|
maxTurns: 35,
|
|
@@ -777,7 +1153,7 @@ ${core.summary}`);
|
|
|
777
1153
|
].join("\n");
|
|
778
1154
|
const events = await runPass({
|
|
779
1155
|
prompt: eventsPrompt,
|
|
780
|
-
systemPrompt:
|
|
1156
|
+
systemPrompt: systemPrompt9,
|
|
781
1157
|
repoPath,
|
|
782
1158
|
model: config.model,
|
|
783
1159
|
maxTurns: config.maxTurns,
|
|
@@ -797,7 +1173,7 @@ ${events.summary}`);
|
|
|
797
1173
|
};
|
|
798
1174
|
}
|
|
799
1175
|
async function runPass(opts) {
|
|
800
|
-
const { prompt, systemPrompt:
|
|
1176
|
+
const { prompt, systemPrompt: systemPrompt9, repoPath, model, maxTurns, progress } = opts;
|
|
801
1177
|
let summary = "";
|
|
802
1178
|
let costUsd = 0;
|
|
803
1179
|
let ok = false;
|
|
@@ -807,7 +1183,7 @@ async function runPass(opts) {
|
|
|
807
1183
|
options: {
|
|
808
1184
|
model,
|
|
809
1185
|
cwd: repoPath,
|
|
810
|
-
systemPrompt:
|
|
1186
|
+
systemPrompt: systemPrompt9,
|
|
811
1187
|
allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
|
|
812
1188
|
permissionMode: "bypassPermissions",
|
|
813
1189
|
maxTurns,
|
|
@@ -918,6 +1294,21 @@ function revertHint(checkpoint) {
|
|
|
918
1294
|
if (!checkpoint.isRepo || !checkpoint.baseRef) return void 0;
|
|
919
1295
|
return `git restore . && git clean -fd (back to ${checkpoint.baseRef.slice(0, 7)})`;
|
|
920
1296
|
}
|
|
1297
|
+
async function isWorkingTreeClean(repoPath) {
|
|
1298
|
+
const status = await run(repoPath, ["status", "--porcelain"]);
|
|
1299
|
+
return status.ok && status.stdout.trim() === "";
|
|
1300
|
+
}
|
|
1301
|
+
async function revertToCheckpoint(repoPath, checkpoint) {
|
|
1302
|
+
if (!checkpoint.isRepo) return false;
|
|
1303
|
+
if (checkpoint.baseRef) {
|
|
1304
|
+
const reset = await run(repoPath, ["reset", "--hard", checkpoint.baseRef]);
|
|
1305
|
+
const clean2 = await run(repoPath, ["clean", "-fd"]);
|
|
1306
|
+
return reset.ok && clean2.ok;
|
|
1307
|
+
}
|
|
1308
|
+
const restore = await run(repoPath, ["restore", "."]);
|
|
1309
|
+
const clean = await run(repoPath, ["clean", "-fd"]);
|
|
1310
|
+
return restore.ok && clean.ok;
|
|
1311
|
+
}
|
|
921
1312
|
async function repoFingerprint(repoPath) {
|
|
922
1313
|
const remote = await run(repoPath, ["remote", "get-url", "origin"]);
|
|
923
1314
|
const seed = remote.ok && remote.stdout.trim() ? normalizeRemote(remote.stdout.trim()) : `dir:${basename(repoPath)}`;
|
|
@@ -1077,8 +1468,24 @@ Flutter is live today; ${theme.bright(
|
|
|
1077
1468
|
);
|
|
1078
1469
|
const checkpoint = await takeCheckpoint(repoPath);
|
|
1079
1470
|
if (!checkpoint.isRepo) {
|
|
1471
|
+
if (!options.force) {
|
|
1472
|
+
p.cancel(
|
|
1473
|
+
theme.alert("Not a git repository.") + " The wizard edits your code and relies on git to undo safely.\n" + theme.muted("Run `git init` and commit first, or re-run with --force to proceed without a safety net.")
|
|
1474
|
+
);
|
|
1475
|
+
return 1;
|
|
1476
|
+
}
|
|
1080
1477
|
p.log.warn(
|
|
1081
|
-
theme.warn("not a git repo") + theme.muted(" \u2014
|
|
1478
|
+
theme.warn("not a git repo") + theme.muted(" \u2014 proceeding with --force; there is no automatic undo.")
|
|
1479
|
+
);
|
|
1480
|
+
} else if (!await isWorkingTreeClean(repoPath)) {
|
|
1481
|
+
if (!options.force) {
|
|
1482
|
+
p.cancel(
|
|
1483
|
+
theme.alert("Your working tree has uncommitted changes.") + " Commit or stash them first so the wizard's edits stay isolated and reversible.\n" + theme.muted("Or re-run with --force to proceed anyway.")
|
|
1484
|
+
);
|
|
1485
|
+
return 1;
|
|
1486
|
+
}
|
|
1487
|
+
p.log.warn(
|
|
1488
|
+
theme.warn("uncommitted changes present") + theme.muted(" \u2014 proceeding with --force; a revert would also drop your own changes.")
|
|
1082
1489
|
);
|
|
1083
1490
|
}
|
|
1084
1491
|
const spin = p.spinner();
|
|
@@ -1114,6 +1521,7 @@ Flutter is live today; ${theme.bright(
|
|
|
1114
1521
|
clearInterval(tick);
|
|
1115
1522
|
if (!outcome) {
|
|
1116
1523
|
spin.stop(theme.alert("Integration stopped"));
|
|
1524
|
+
await maybeRevert(repoPath, checkpoint, "The run stopped before finishing.");
|
|
1117
1525
|
return 1;
|
|
1118
1526
|
}
|
|
1119
1527
|
const stopLabel = !outcome.coreOk ? theme.warn("Stopped early \u2014 partial setup is in your working tree") : outcome.eventsComplete ? theme.success("Integration complete") : theme.success("Core integration done") + theme.muted(" \u2014 some events left as follow-ups");
|
|
@@ -1125,6 +1533,17 @@ Flutter is live today; ${theme.bright(
|
|
|
1125
1533
|
if (outcome.summary.trim()) {
|
|
1126
1534
|
p.log.message(outcome.summary.trim());
|
|
1127
1535
|
}
|
|
1536
|
+
if (!outcome.coreOk) {
|
|
1537
|
+
const reverted = await maybeRevert(
|
|
1538
|
+
repoPath,
|
|
1539
|
+
checkpoint,
|
|
1540
|
+
"The core setup didn't complete, so these changes may be incomplete."
|
|
1541
|
+
);
|
|
1542
|
+
if (reverted) {
|
|
1543
|
+
p.outro(theme.muted("Reverted \u2014 nothing was left in your working tree."));
|
|
1544
|
+
return 1;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1128
1547
|
const wiredMap = await scanWiredEvents(
|
|
1129
1548
|
repoPath,
|
|
1130
1549
|
files,
|
|
@@ -1181,6 +1600,29 @@ Flutter is live today; ${theme.bright(
|
|
|
1181
1600
|
p.outro(theme.signal("\u2301 ") + theme.bright("Whisperr is wired in."));
|
|
1182
1601
|
return 0;
|
|
1183
1602
|
}
|
|
1603
|
+
async function maybeRevert(repoPath, checkpoint, reason) {
|
|
1604
|
+
if (!checkpoint.isRepo) return false;
|
|
1605
|
+
const files = await changedFiles(repoPath, checkpoint);
|
|
1606
|
+
if (files.length === 0) return false;
|
|
1607
|
+
const doRevert = await p.confirm({
|
|
1608
|
+
message: `${reason} Revert all of the wizard's changes now?`,
|
|
1609
|
+
initialValue: true
|
|
1610
|
+
});
|
|
1611
|
+
if (p.isCancel(doRevert) || !doRevert) {
|
|
1612
|
+
const hint = revertHint(checkpoint);
|
|
1613
|
+
if (hint) p.log.info(theme.muted(`Left in your working tree. Undo anytime: ${hint}`));
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
const ok = await revertToCheckpoint(repoPath, checkpoint);
|
|
1617
|
+
if (ok) {
|
|
1618
|
+
p.log.success(theme.success("Reverted to your pre-wizard state."));
|
|
1619
|
+
} else {
|
|
1620
|
+
p.log.warn(
|
|
1621
|
+
theme.warn("Couldn't revert automatically \u2014 undo manually: ") + (revertHint(checkpoint) ?? "git reset --hard")
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
return ok;
|
|
1625
|
+
}
|
|
1184
1626
|
async function chooseTarget(detections) {
|
|
1185
1627
|
const top = detections[0];
|
|
1186
1628
|
if (top && (detections.length === 1 || top.confidence >= 0.9)) {
|
|
@@ -1272,6 +1714,9 @@ function parseArgs(argv) {
|
|
|
1272
1714
|
case "--offline":
|
|
1273
1715
|
opts.offline = true;
|
|
1274
1716
|
break;
|
|
1717
|
+
case "--force":
|
|
1718
|
+
opts.force = true;
|
|
1719
|
+
break;
|
|
1275
1720
|
case "--api":
|
|
1276
1721
|
opts.apiBaseUrl = args[++i];
|
|
1277
1722
|
break;
|
|
@@ -1297,6 +1742,7 @@ function printHelp() {
|
|
|
1297
1742
|
"",
|
|
1298
1743
|
` ${theme.bright("Options")}`,
|
|
1299
1744
|
" --offline Use a demo manifest, no account/browser needed",
|
|
1745
|
+
" --force Proceed without a clean git tree (no safe undo)",
|
|
1300
1746
|
" --api <url> Override the Whisperr API base URL",
|
|
1301
1747
|
" --model <id> Override the coding-agent model",
|
|
1302
1748
|
" -h, --help Show this help",
|
|
@@ -1315,7 +1761,7 @@ async function main() {
|
|
|
1315
1761
|
return;
|
|
1316
1762
|
}
|
|
1317
1763
|
if ("version" in parsed) {
|
|
1318
|
-
console.log("0.1.
|
|
1764
|
+
console.log("0.1.7");
|
|
1319
1765
|
return;
|
|
1320
1766
|
}
|
|
1321
1767
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whisperr/wizard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Whisperr Wizard — one command to integrate the Whisperr SDK into your app. Authenticates with your onboarded account and uses an AI coding agent to wire up identify() and your business events automatically.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|