standout 0.5.25 → 0.5.27

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/cli.js CHANGED
@@ -119,7 +119,7 @@ async function maybeShareWrapped(wrapped) {
119
119
  process.stderr.write(` opened: ${wrapped.share_url}\n\n`);
120
120
  }
121
121
  catch (err) {
122
- process.stderr.write(` couldn't open browser open it manually: ${wrapped.share_url}\n`);
122
+ process.stderr.write(` couldn't open browser. open it manually: ${wrapped.share_url}\n`);
123
123
  process.stderr.write(` (${err instanceof Error ? err.message : err})\n\n`);
124
124
  }
125
125
  }
@@ -197,7 +197,7 @@ async function runAgent(jobId) {
197
197
  await maybeShareWrapped(wrapped);
198
198
  }
199
199
  else {
200
- process.stderr.write(" (couldn't generate wrapped right now continuing profile setup.)\n\n");
200
+ process.stderr.write(" (couldn't generate wrapped right now. continuing profile setup.)\n\n");
201
201
  }
202
202
  if (!profileChatEnabled()) {
203
203
  return;
package/dist/cursor.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface CursorStats {
11
11
  total: number;
12
12
  };
13
13
  models: string[];
14
+ model_session_counts: Record<string, number>;
15
+ tool_call_counts: Record<string, number>;
14
16
  hour_buckets: number[];
15
17
  weekend_ratio: number;
16
18
  languages: Record<string, number>;
@@ -18,15 +20,12 @@ export interface CursorStats {
18
20
  prompt_frequency: PromptFrequency[];
19
21
  exchanges: ConversationExchange[];
20
22
  interaction: InteractionSignals;
21
- monthly_buckets: {
22
- month: string;
23
- sessions: number;
24
- duration_hours: number;
25
- input_tokens: number;
26
- output_tokens: number;
27
- cache_tokens: number;
28
- primary_model: string | null;
29
- }[];
23
+ total_input_tokens: number;
24
+ total_output_tokens: number;
25
+ total_cache_read_tokens: number;
26
+ total_cache_write_tokens: number;
27
+ tokens_estimated: true;
28
+ monthly_buckets: CursorMonthlyBucket[];
30
29
  all_time?: {
31
30
  total_sessions: number;
32
31
  active_days: number;
@@ -37,18 +36,22 @@ export interface CursorStats {
37
36
  total_output_tokens: number;
38
37
  total_cache_read_tokens: number;
39
38
  total_cache_write_tokens: number;
40
- monthly_buckets: {
41
- month: string;
42
- sessions: number;
43
- duration_hours: number;
44
- input_tokens: number;
45
- output_tokens: number;
46
- cache_tokens: number;
47
- primary_model: string | null;
48
- }[];
39
+ monthly_buckets: CursorMonthlyBucket[];
49
40
  };
50
41
  }
42
+ interface CursorMonthlyBucket {
43
+ month: string;
44
+ sessions: number;
45
+ duration_hours: number;
46
+ input_tokens: number;
47
+ output_tokens: number;
48
+ cache_read_tokens: number;
49
+ cache_write_tokens: number;
50
+ cache_tokens: number;
51
+ primary_model: string | null;
52
+ }
51
53
  export declare function gatherCursor(opts?: {
52
54
  dbPath?: string;
53
55
  windowStartMs?: number;
54
56
  }): Promise<CursorStats | null>;
57
+ export {};
package/dist/cursor.js CHANGED
@@ -16,6 +16,27 @@ const CORRECTION_RE = /\b(no|nope|actually|wrong|incorrect|instead|revert|undo|d
16
16
  // Mirrors ai-usage.ts — cannot import (would create a module cycle).
17
17
  const TRIVIAL_ASK_RE = /^(y|n|yes|yep|yeah|ya|ok|okay|k|sure|go|go ahead|do it|continue|please continue|next|thanks|thank you|thx|ty|nice|cool|perfect|great|good|done|ship it|lgtm|👍|\.|\?)\b[\s.!]*$/i;
18
18
  const SHELL_COMMAND_RE = /^(npx|npm|pnpm|yarn|bun|git|cd|ls|cat|node|python|pip|brew|sudo|docker|curl|echo|mkdir|rm|mv|cp)\b/i;
19
+ // Cursor persists no usable token/cost data locally — bubble `tokenCount` is
20
+ // zeroed and composer `usageData` is empty. We estimate both from active
21
+ // duration using the economics of our Claude Code cohort (Cursor runs the same
22
+ // Claude models). The per-hour token split is calibrated so that, priced at the
23
+ // generic retail fallback rate, it totals ~$20/active-hour — the cohort's
24
+ // blended retail $/hour. Surfaced with an "estimated" disclaimer on the cards.
25
+ const CURSOR_EST_TOKENS_PER_HOUR = {
26
+ input: 38_000,
27
+ output: 110_000,
28
+ cacheRead: 24_750_000,
29
+ cacheWrite: 750_000,
30
+ };
31
+ function estimateCursorTokens(hours) {
32
+ const h = hours > 0 ? hours : 0;
33
+ return {
34
+ input: Math.round(CURSOR_EST_TOKENS_PER_HOUR.input * h),
35
+ output: Math.round(CURSOR_EST_TOKENS_PER_HOUR.output * h),
36
+ cacheRead: Math.round(CURSOR_EST_TOKENS_PER_HOUR.cacheRead * h),
37
+ cacheWrite: Math.round(CURSOR_EST_TOKENS_PER_HOUR.cacheWrite * h),
38
+ };
39
+ }
19
40
  function isLowSignalAsk(sample) {
20
41
  const t = sample.trim();
21
42
  return t.length < 12 || TRIVIAL_ASK_RE.test(t) || SHELL_COMMAND_RE.test(t);
@@ -159,13 +180,20 @@ async function readState(dbPath, Driver) {
159
180
  continue;
160
181
  const composerId = parsed.composerId ??
161
182
  row.key.slice("composerData:".length);
183
+ const modelConfig = parsed.modelConfig && typeof parsed.modelConfig === "object"
184
+ ? parsed.modelConfig
185
+ : null;
186
+ const modelName = (modelConfig && typeof modelConfig.modelName === "string"
187
+ ? modelConfig.modelName
188
+ : null) ??
189
+ (typeof parsed.latestSelectedModel === "string"
190
+ ? parsed.latestSelectedModel
191
+ : null);
162
192
  composers.set(composerId, {
163
193
  composerId,
164
194
  createdAt: numericTs(parsed.createdAt),
165
195
  lastUpdatedAt: numericTs(parsed.lastUpdatedAt),
166
- modelConfig: typeof parsed.latestSelectedModel === "string"
167
- ? parsed.latestSelectedModel
168
- : null,
196
+ modelName: modelName && modelName !== "default" ? modelName : null,
169
197
  });
170
198
  }
171
199
  if (rows.length < PAGE)
@@ -196,12 +224,22 @@ async function readState(dbPath, Driver) {
196
224
  const text = typeof parsed.text === "string" ? parsed.text : null;
197
225
  const createdAt = numericTs(parsed.createdAt);
198
226
  const filePaths = extractFilePaths(parsed);
227
+ const tool = parsed.toolFormerData && typeof parsed.toolFormerData === "object"
228
+ ? parsed.toolFormerData
229
+ : null;
230
+ const toolName = tool &&
231
+ (typeof tool.name === "string"
232
+ ? tool.name
233
+ : typeof tool.tool === "string"
234
+ ? tool.tool
235
+ : null);
199
236
  bubbles.set(parts[2], {
200
237
  conversationId,
201
238
  type,
202
239
  text,
203
240
  createdAt,
204
241
  filePaths,
242
+ toolName: toolName || null,
205
243
  });
206
244
  }
207
245
  if (rows.length < PAGE)
@@ -294,10 +332,10 @@ export async function gatherCursor(opts = {}) {
294
332
  first_session: allTime.first_session,
295
333
  last_session: allTime.last_session,
296
334
  total_duration_hours: allTime.total_duration_hours,
297
- total_input_tokens: 0,
298
- total_output_tokens: 0,
299
- total_cache_read_tokens: 0,
300
- total_cache_write_tokens: 0,
335
+ total_input_tokens: allTime.total_input_tokens,
336
+ total_output_tokens: allTime.total_output_tokens,
337
+ total_cache_read_tokens: allTime.total_cache_read_tokens,
338
+ total_cache_write_tokens: allTime.total_cache_write_tokens,
301
339
  monthly_buckets: allTime.monthly_buckets,
302
340
  };
303
341
  return stats.total_sessions > 0 || allTime.total_sessions > 0 ? stats : null;
@@ -335,6 +373,9 @@ function finalize(raw, opts = {}) {
335
373
  let userTurns = 0;
336
374
  let correctionTurns = 0;
337
375
  let countedSessions = 0;
376
+ let toolCalls = 0;
377
+ const modelSessionCounts = {};
378
+ const toolCallCounts = {};
338
379
  const monthly = new Map();
339
380
  for (const [composerId, composer] of raw.composers.entries()) {
340
381
  const entry = sessionBubbles.get(composerId);
@@ -366,6 +407,10 @@ function finalize(raw, opts = {}) {
366
407
  month.sessions += 1;
367
408
  month.durationMs += durationMs;
368
409
  monthly.set(monthKey, month);
410
+ if (composer.modelName) {
411
+ modelSessionCounts[composer.modelName] =
412
+ (modelSessionCounts[composer.modelName] ?? 0) + 1;
413
+ }
369
414
  for (const ts of windowTs) {
370
415
  const d = new Date(ts);
371
416
  if (Number.isNaN(d.getTime()))
@@ -385,6 +430,11 @@ function finalize(raw, opts = {}) {
385
430
  if (allFiles.size < MAX_FILE_PATHS)
386
431
  allFiles.add(fp);
387
432
  }
433
+ if (bubble.toolName) {
434
+ toolCalls += 1;
435
+ toolCallCounts[bubble.toolName] =
436
+ (toolCallCounts[bubble.toolName] ?? 0) + 1;
437
+ }
388
438
  }
389
439
  const windowSamples = windowBubbles
390
440
  .filter((bubble) => bubble.type === 1 && bubble.text)
@@ -443,6 +493,34 @@ function finalize(raw, opts = {}) {
443
493
  languages[lang] = (languages[lang] || 0) + 1;
444
494
  }
445
495
  }
496
+ const monthlyBuckets = [...monthly.entries()]
497
+ .sort(([a], [b]) => a.localeCompare(b))
498
+ .map(([month, b]) => {
499
+ const hours = +(b.durationMs / 1000 / 3600).toFixed(1);
500
+ const est = estimateCursorTokens(hours);
501
+ return {
502
+ month,
503
+ sessions: b.sessions,
504
+ duration_hours: hours,
505
+ input_tokens: est.input,
506
+ output_tokens: est.output,
507
+ cache_read_tokens: est.cacheRead,
508
+ cache_write_tokens: est.cacheWrite,
509
+ cache_tokens: est.cacheRead + est.cacheWrite,
510
+ primary_model: null,
511
+ };
512
+ });
513
+ // Totals are the exact sum of the (rounded-per-month) buckets so the top-level
514
+ // figures metrics.ts reads match what the card sums from monthly_buckets.
515
+ const totals = monthlyBuckets.reduce((acc, b) => ({
516
+ input: acc.input + b.input_tokens,
517
+ output: acc.output + b.output_tokens,
518
+ cacheRead: acc.cacheRead + b.cache_read_tokens,
519
+ cacheWrite: acc.cacheWrite + b.cache_write_tokens,
520
+ }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
521
+ const models = Object.entries(modelSessionCounts)
522
+ .sort((a, b) => b[1] - a[1])
523
+ .map(([model]) => model);
446
524
  return {
447
525
  total_sessions: countedSessions,
448
526
  active_days: activeDays.size,
@@ -458,7 +536,9 @@ function finalize(raw, opts = {}) {
458
536
  assistant: assistantTotal,
459
537
  total: userTotal + assistantTotal,
460
538
  },
461
- models: [],
539
+ models,
540
+ model_session_counts: modelSessionCounts,
541
+ tool_call_counts: toolCallCounts,
462
542
  hour_buckets: hourBuckets,
463
543
  weekend_ratio: totalEvents > 0 ? +(weekendEvents / totalEvents).toFixed(2) : 0,
464
544
  languages,
@@ -470,18 +550,13 @@ function finalize(raw, opts = {}) {
470
550
  interaction: {
471
551
  user_turns: userTurns,
472
552
  correction_turns: correctionTurns,
473
- tool_calls: 0,
553
+ tool_calls: toolCalls,
474
554
  },
475
- monthly_buckets: [...monthly.entries()]
476
- .sort(([a], [b]) => a.localeCompare(b))
477
- .map(([month, b]) => ({
478
- month,
479
- sessions: b.sessions,
480
- duration_hours: +(b.durationMs / 1000 / 3600).toFixed(1),
481
- input_tokens: 0,
482
- output_tokens: 0,
483
- cache_tokens: 0,
484
- primary_model: null,
485
- })),
555
+ total_input_tokens: totals.input,
556
+ total_output_tokens: totals.output,
557
+ total_cache_read_tokens: totals.cacheRead,
558
+ total_cache_write_tokens: totals.cacheWrite,
559
+ tokens_estimated: true,
560
+ monthly_buckets: monthlyBuckets,
486
561
  };
487
562
  }
package/dist/payload.js CHANGED
@@ -104,6 +104,19 @@ export function capPayload(payload) {
104
104
  }
105
105
  }
106
106
  }
107
+ // 2b. Display-only prompt samples (not read by any server card).
108
+ if (over()) {
109
+ for (const t of TOOLS) {
110
+ const tool = asRecord(aiUsage[t]);
111
+ if (tool &&
112
+ Array.isArray(tool.prompt_samples) &&
113
+ tool.prompt_samples.length) {
114
+ tool.prompt_samples = [];
115
+ if (!dropped.includes("prompt_samples"))
116
+ dropped.push("prompt_samples");
117
+ }
118
+ }
119
+ }
107
120
  // 3. Commit subjects + repo README excerpts (contributions/blurbs inputs).
108
121
  if (over()) {
109
122
  const aiCoding = asRecord(p.ai_coding);
@@ -495,13 +495,15 @@ function computeTokens(aiUsage) {
495
495
  const cacheTotal = (b) => b.cache_read_tokens !== undefined || b.cache_write_tokens !== undefined
496
496
  ? (b.cache_read_tokens ?? 0) + (b.cache_write_tokens ?? 0)
497
497
  : (b.cache_tokens ?? 0);
498
- // Cursor exposes no reliable token counts — flag it so the card can say so,
499
- // but only when it was actually used in THIS window (the card is "last 30
500
- // days"). All-time Cursor sessions don't count: claiming "Cursor used too" when
501
- // they haven't opened it in a month is just wrong.
498
+ // Cursor exposes no local token counts — its tokens/cost are estimated from
499
+ // active time (see cursor.ts). Flag it so the card discloses the estimate, but
500
+ // only when it was actually used in THIS window (the card is "last 30 days").
501
+ // All-time Cursor sessions don't count: claiming "Cursor too" when they haven't
502
+ // opened it in a month is just wrong.
502
503
  const CURSOR_MIN_SESSIONS = 3;
503
504
  const cursorStats = aiUsage["cursor"];
504
- const cursorUntracked = !!(cursorStats && (cursorStats.total_sessions ?? 0) >= CURSOR_MIN_SESSIONS);
505
+ const cursorEstimated = !!(cursorStats && (cursorStats.total_sessions ?? 0) >= CURSOR_MIN_SESSIONS);
506
+ let cursorTokens = 0;
505
507
  for (const src of SOURCES) {
506
508
  const stats = aiUsage[src];
507
509
  if (!stats)
@@ -512,6 +514,8 @@ function computeTokens(aiUsage) {
512
514
  const cache = cacheTotal(b);
513
515
  workTokens += work;
514
516
  cacheTokens += cache;
517
+ if (src === "cursor")
518
+ cursorTokens += work + cache;
515
519
  // The per-tool bars + sparkline show the cache-inclusive total (matches the
516
520
  // rainbow headline); the "generated" callout is the work-only number.
517
521
  byTool[TOOL_LABELS[src] ?? src] =
@@ -570,7 +574,10 @@ function computeTokens(aiUsage) {
570
574
  by_tool: sortedByTool,
571
575
  six_month_buckets: sixMonth,
572
576
  estimated_retail_cost_usd: Math.round(totalCost),
573
- cursor_untracked: cursorUntracked,
577
+ cursor_estimated: cursorEstimated,
578
+ cursor_token_share: cursorEstimated && workTokens + cacheTokens > 0
579
+ ? cursorTokens / (workTokens + cacheTokens)
580
+ : 0,
574
581
  };
575
582
  }
576
583
  function computeToolRelationship(aiUsage) {
@@ -453,9 +453,9 @@ function cardRankSummary(view) {
453
453
  .filter((stat) => stat.topNumber !== null)
454
454
  .sort((a, b) => a.topNumber - b.topNumber)[0];
455
455
  const cohort = rank.sample_size > 0
456
- ? `across ${rank.sample_size.toLocaleString()} Standout users`
456
+ ? `across ${rank.sample_size.toLocaleString()} users`
457
457
  : view.cohort_size > 0
458
- ? `among ${view.cohort_size.toLocaleString()} Standout users so far`
458
+ ? `among ${view.cohort_size.toLocaleString()} users so far`
459
459
  : "Standout profile summary";
460
460
  const row = (stat) => {
461
461
  const top = "top" in stat && stat.top
@@ -517,6 +517,14 @@ function cardTokens(view) {
517
517
  const costLine = cost > 0
518
518
  ? ` ${chalk.dim("would have cost")} ${TITLE_GRADIENT(`~$${cost.toLocaleString()}`)} ${chalk.dim("at retail API rates")}`
519
519
  : "";
520
+ // Cursor stores no token counts locally, so its tokens + cost are estimated
521
+ // from active time. Disclose it — wording scales with how much of this card is
522
+ // Cursor (cursor-only users: the whole number is an estimate).
523
+ const cursorNote = t.cursor_estimated
524
+ ? t.cursor_token_share >= 0.9
525
+ ? chalk.dim(" ≈ estimated from time spent in Cursor (no local token data)")
526
+ : chalk.dim(" includes Cursor (tokens + cost estimated from active time)")
527
+ : "";
520
528
  const cachedNote = t.cache_tokens > t.work_tokens
521
529
  ? chalk.dim(" thankfully, most of them were cached :)")
522
530
  : t.cache_tokens > 0
@@ -533,12 +541,10 @@ function cardTokens(view) {
533
541
  cachedNote,
534
542
  generatedLine,
535
543
  costLine,
544
+ cursorNote,
536
545
  "",
537
546
  " last 30 days, across:",
538
547
  toolRows,
539
- view.tokens.cursor_untracked
540
- ? chalk.dim(" Cursor used too (its tokens aren't tracked locally)")
541
- : "",
542
548
  "",
543
549
  deltaLine,
544
550
  ]
@@ -875,9 +881,9 @@ export function cardProficiency(view) {
875
881
  const scoreTop = topPercentNumber(p.score_percentile ?? null);
876
882
  const sampleSize = view.rank_summary.sample_size;
877
883
  const cohort = scoreTop != null && sampleSize > 0
878
- ? `top ${scoreTop}% of ${sampleSize.toLocaleString()} Standout users`
884
+ ? `top ${scoreTop}% of ${sampleSize.toLocaleString()} users`
879
885
  : view.cohort_size > 0
880
- ? `among ${view.cohort_size.toLocaleString()} Standout users so far`
886
+ ? `among ${view.cohort_size.toLocaleString()} users so far`
881
887
  : "";
882
888
  const metricRow = (label, value) => value === null
883
889
  ? null
@@ -115,7 +115,8 @@ export interface WrappedAggregateView {
115
115
  tokens: number;
116
116
  }[];
117
117
  estimated_retail_cost_usd: number;
118
- cursor_untracked: boolean;
118
+ cursor_estimated: boolean;
119
+ cursor_token_share: number;
119
120
  };
120
121
  tool_relationship: {
121
122
  kind: "switch";
@@ -10,18 +10,21 @@ const HEADERS = {
10
10
  // node-fetch UAs on certain paths. Set a clear identity.
11
11
  "User-Agent": "Standout/0.4.0 (node)",
12
12
  };
13
- // The request holds a connection open while the backend runs the wrapped's
14
- // LLM cards, so a transient blip shouldn't permanently drop the wrapped.
15
13
  const MAX_ATTEMPTS = 3;
16
- // Matches the server route's 300s maxDuration: the client only gives up once the
17
- // server itself would have. A shorter client timeout would abort a still-valid
18
- // slow run and orphan the wrapped the server goes on to create.
19
- const REQUEST_TIMEOUT_MS = 300000;
14
+ // The POST now returns as soon as the pending row is created (the LLM card deck
15
+ // runs server-side in the background), so it resolves in a couple seconds. The
16
+ // deck is then read by polling GET /wrapped/[id] short requests that can't be
17
+ // silently dropped mid-generation the way the old long-held POST was.
18
+ const POST_TIMEOUT_MS = 60000;
19
+ const POLL_REQUEST_TIMEOUT_MS = 15000;
20
+ const POLL_INTERVAL_MS = 2000;
21
+ // Generous ceiling — the deck normally lands in well under a minute; this only
22
+ // bounds a stuck/never-completing background pass.
23
+ const POLL_TIMEOUT_MS = 180000;
20
24
  const RETRY_BASE_DELAY_MS = 1000;
21
- // Wrapped creation is NOT idempotent — each POST makes a new row + LLM run. Only
22
- // retry failures that prove the request never reached the backend; a timeout or a
23
- // mid-flight drop may mean the server already created it, so replaying would
24
- // duplicate the wrapped.
25
+ // The POST is NOT idempotent — each one makes a new row + background LLM run. Only
26
+ // retry failures that prove the request never reached the backend; a mid-flight
27
+ // drop may mean the row was already created, so replaying would duplicate it.
25
28
  const RETRYABLE_CONNECT_CODES = new Set([
26
29
  "ECONNREFUSED",
27
30
  "ENOTFOUND",
@@ -37,46 +40,109 @@ function isPreConnectError(err) {
37
40
  }
38
41
  return false;
39
42
  }
43
+ // `fetch failed` is undici's catch-all message — the real reason (ECONNRESET,
44
+ // UND_ERR_HEADERS_TIMEOUT, …) lives in err.cause. Walk the chain so failures are
45
+ // actually diagnosable instead of all collapsing to "fetch failed".
46
+ function describeError(err) {
47
+ const parts = [];
48
+ let node = err;
49
+ for (let depth = 0; node && depth < 4; depth++) {
50
+ const e = node;
51
+ const code = typeof e.code === "string" ? e.code : null;
52
+ const message = typeof e.message === "string" ? e.message : null;
53
+ const label = [code, message].filter(Boolean).join(" ");
54
+ if (label && !parts.includes(label))
55
+ parts.push(label);
56
+ node = node?.cause;
57
+ }
58
+ return parts.join(" <- ") || String(err);
59
+ }
40
60
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
61
+ function isReady(computed) {
62
+ if (!computed)
63
+ return false;
64
+ // status is the explicit signal; archetype is a backstop for any response that
65
+ // predates the status field.
66
+ return computed.status === "ready" || computed.archetype != null;
67
+ }
68
+ // Poll GET /wrapped/[id] until the background card deck is persisted. Each request
69
+ // is short and idempotent, so transient blips are retried freely (unlike the POST).
70
+ async function pollComputed(id) {
71
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
72
+ // The deck never lands in under a second; wait once before the first read.
73
+ await sleep(POLL_INTERVAL_MS);
74
+ while (Date.now() < deadline) {
75
+ try {
76
+ const res = await fetch(`${STANDOUT_API_URL}/api/public/wrapped/${id}`, {
77
+ headers: HEADERS,
78
+ signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
79
+ });
80
+ if (res.ok) {
81
+ const data = (await res.json());
82
+ if (isReady(data.computed))
83
+ return data.computed ?? null;
84
+ }
85
+ }
86
+ catch {
87
+ // Transient — keep polling until the deadline.
88
+ }
89
+ await sleep(POLL_INTERVAL_MS);
90
+ }
91
+ return null;
92
+ }
41
93
  export async function createWrapped(args) {
42
94
  // Keep the request under Vercel's 4.5MB body cap (else a heavy history 413s
43
95
  // before the handler runs). Trims the bulkiest raw fields only when oversized.
44
- const { payload: profile } = capPayload(args.profile);
96
+ const { payload: profile, bytesAfter } = capPayload(args.profile);
45
97
  const body = JSON.stringify({
46
98
  profile,
47
99
  submission_id: args.submissionId ?? undefined,
48
100
  });
101
+ const bodyKb = Math.round(bytesAfter / 1024);
102
+ let created = null;
49
103
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
50
104
  try {
51
105
  const res = await fetch(`${STANDOUT_API_URL}/api/public/wrapped`, {
52
106
  method: "POST",
53
107
  headers: HEADERS,
54
108
  body,
55
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
109
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
56
110
  });
57
111
  // Any HTTP response means the request reached the server; don't replay it
58
112
  // (a 5xx could land after the row was already created).
59
113
  if (!res.ok) {
60
114
  const text = await res.text().catch(() => "");
61
- process.stderr.write(`[wrapped] backend returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}\n`);
115
+ process.stderr.write(` Standout had a server error (${res.status}) while building your wrapped. Please try again in a minute.\n` +
116
+ (text ? ` (details: ${text.slice(0, 200)})\n` : ""));
62
117
  return null;
63
118
  }
64
- const data = (await res.json());
65
- const view = aggregateView({
66
- profile: args.profile,
67
- computed: data.computed,
68
- share_url: data.share_url,
69
- });
70
- return { id: data.id, share_url: data.share_url, view };
119
+ created = (await res.json());
120
+ break;
71
121
  }
72
122
  catch (err) {
73
123
  if (isPreConnectError(err) && attempt < MAX_ATTEMPTS) {
74
124
  await sleep(RETRY_BASE_DELAY_MS * attempt);
75
125
  continue;
76
126
  }
77
- process.stderr.write(`[wrapped] failed to reach backend: ${err instanceof Error ? err.message : err}\n`);
127
+ process.stderr.write(` Couldn't reach Standout to build your wrapped. Your network (or a VPN/proxy) dropped the connection.\n` +
128
+ ` Check your connection and re-run: npx standout\n` +
129
+ ` (details: ${describeError(err)}; body ${bodyKb}KB)\n`);
78
130
  return null;
79
131
  }
80
132
  }
81
- return null;
133
+ if (!created)
134
+ return null;
135
+ // The POST only created a pending row; poll for the finished deck.
136
+ const computed = await pollComputed(created.id);
137
+ if (!computed) {
138
+ process.stderr.write(" Your wrapped is taking longer than usual to finish generating.\n" +
139
+ " Re-run to try again: npx standout\n");
140
+ return null;
141
+ }
142
+ const view = aggregateView({
143
+ profile: args.profile,
144
+ computed,
145
+ share_url: created.share_url,
146
+ });
147
+ return { id: created.id, share_url: created.share_url, view };
82
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standout",
3
- "version": "0.5.25",
3
+ "version": "0.5.27",
4
4
  "description": "Build your developer profile with AI. One command, zero friction.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",