standout 0.5.20 → 0.5.22

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/api.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { capPayload } from "./payload.js";
1
2
  export const STANDOUT_API_URL = process.env.STANDOUT_API_URL || "https://standout.work";
3
+ // Matches the server's request budget so the client doesn't hang forever (the
4
+ // submit handler has no client timeout otherwise) nor abort a still-valid run.
5
+ const SUBMIT_TIMEOUT_MS = 300000;
2
6
  export async function enrichProfile(type, value) {
3
7
  try {
4
8
  const res = await fetch(`${STANDOUT_API_URL}/api/public/agent-enrich`, {
@@ -65,10 +69,14 @@ export async function submitProfile(profile, options = {}) {
65
69
  const jobId = process.env.STANDOUT_JOB_ID;
66
70
  const mergedProfile = mergeSubmissionProfile(profileData, options.prefetchedProfile);
67
71
  const payload = jobId ? { ...mergedProfile, jobId } : mergedProfile;
72
+ // Trim under Vercel's 4.5MB body cap so a heavy history can't 413 the core
73
+ // submission before the handler runs.
74
+ const { payload: capped } = capPayload(payload);
68
75
  const res = await fetch(`${STANDOUT_API_URL}/api/public/agent-submit`, {
69
76
  method: "POST",
70
77
  headers: { "Content-Type": "application/json" },
71
- body: JSON.stringify(payload),
78
+ body: JSON.stringify(capped),
79
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
72
80
  });
73
81
  if (!res.ok) {
74
82
  const error = await res.text();
@@ -0,0 +1,8 @@
1
+ export interface CapResult {
2
+ payload: Record<string, unknown>;
3
+ trimmed: boolean;
4
+ bytesBefore: number;
5
+ bytesAfter: number;
6
+ dropped: string[];
7
+ }
8
+ export declare function capPayload(payload: Record<string, unknown>): CapResult;
@@ -0,0 +1,150 @@
1
+ // Shapes the outbound profile so a heavy history can't break the request.
2
+ //
3
+ // The bulk is the raw `exchanges` corpus (user<->assistant pairs): up to 500 per
4
+ // tool x ~2.8KB, so a 3-tool user ships ~4.2MB. Two problems with sending it raw:
5
+ // 1. The wrapped cards that consume it (collaboration -> 500, craft -> 50) merge
6
+ // across tools then stride-sample DOWN, so anything past 500 total is
7
+ // discarded server-side — pure waste.
8
+ // 2. Vercel rejects a >4.5MB function body with a 413 BEFORE our handler runs,
9
+ // killing both the wrapped and the core submission.
10
+ //
11
+ // So we (a) always bound combined exchanges to what the cards actually read, and
12
+ // (b) keep a hard byte backstop that sheds the least-valuable raw fields first.
13
+ const MAX_BODY_BYTES = 4_000_000; // headroom under Vercel's 4.5MB hard limit
14
+ // The most any wrapped card consumes (collaboration's ceiling; craft reads 50).
15
+ // Capping here is lossless for card resolution — the server would sample to this
16
+ // anyway — it only changes WHICH evenly-strided subset is read.
17
+ const MAX_UPLOAD_EXCHANGES = 500;
18
+ const TOOLS = ["claude_code", "codex", "cursor"];
19
+ function byteLength(value) {
20
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
21
+ }
22
+ function asRecord(value) {
23
+ return value && typeof value === "object"
24
+ ? value
25
+ : undefined;
26
+ }
27
+ function exchangesOf(aiUsage, tool) {
28
+ const ex = asRecord(aiUsage[tool])?.exchanges;
29
+ return Array.isArray(ex) ? ex : [];
30
+ }
31
+ function totalExchanges(payload) {
32
+ const aiUsage = asRecord(payload.ai_usage);
33
+ if (!aiUsage)
34
+ return 0;
35
+ return TOOLS.reduce((n, t) => n + exchangesOf(aiUsage, t).length, 0);
36
+ }
37
+ // Evenly stride-sample an array down to `keep` items (representative, not
38
+ // recency-biased — matches how the server reads the corpus).
39
+ function evenSample(arr, keep) {
40
+ if (arr.length <= keep)
41
+ return arr;
42
+ const stride = arr.length / keep;
43
+ const out = [];
44
+ for (let i = 0; i < keep; i++)
45
+ out.push(arr[Math.floor(i * stride)]);
46
+ return out;
47
+ }
48
+ // Bound combined exchanges to `maxTotal`, proportional per tool, even-sampled.
49
+ function capExchangesInPlace(payload, maxTotal) {
50
+ const aiUsage = asRecord(payload.ai_usage);
51
+ if (!aiUsage)
52
+ return;
53
+ const present = TOOLS.map((t) => ({
54
+ t,
55
+ n: exchangesOf(aiUsage, t).length,
56
+ })).filter((x) => x.n > 0);
57
+ const total = present.reduce((s, x) => s + x.n, 0);
58
+ if (total <= maxTotal)
59
+ return;
60
+ for (const { t, n } of present) {
61
+ const keep = Math.max(1, Math.round((maxTotal * n) / total));
62
+ const tool = asRecord(aiUsage[t]);
63
+ if (tool)
64
+ tool.exchanges = evenSample(tool.exchanges, keep);
65
+ }
66
+ }
67
+ // Returns a (possibly reshaped) clone — never mutates the caller's object.
68
+ export function capPayload(payload) {
69
+ const bytesBefore = byteLength(payload);
70
+ const dropped = [];
71
+ // Only clone (and pay the cost) if there's actually something to do.
72
+ const needsExchangeCap = totalExchanges(payload) > MAX_UPLOAD_EXCHANGES;
73
+ if (!needsExchangeCap && bytesBefore <= MAX_BODY_BYTES) {
74
+ return {
75
+ payload,
76
+ trimmed: false,
77
+ bytesBefore,
78
+ bytesAfter: bytesBefore,
79
+ dropped: [],
80
+ };
81
+ }
82
+ const p = structuredClone(payload);
83
+ const aiUsage = asRecord(p.ai_usage) ?? {};
84
+ // 1. Always: bound exchanges to what the cards read (lossless for resolution).
85
+ if (needsExchangeCap) {
86
+ capExchangesInPlace(p, MAX_UPLOAD_EXCHANGES);
87
+ dropped.push(`exchanges->${MAX_UPLOAD_EXCHANGES}`);
88
+ }
89
+ // Hard byte backstop — only if still oversized after the exchange cap (rare).
90
+ // Shed least-valuable raw fields FIRST so the analyzed exchanges are the last
91
+ // thing we touch.
92
+ const over = () => byteLength(p) > MAX_BODY_BYTES;
93
+ // 2. Display-only conversation samples.
94
+ if (over()) {
95
+ for (const t of TOOLS) {
96
+ const tool = asRecord(aiUsage[t]);
97
+ if (tool &&
98
+ Array.isArray(tool.conversation_samples) &&
99
+ tool.conversation_samples.length) {
100
+ tool.conversation_samples = [];
101
+ if (!dropped.includes("conversation_samples")) {
102
+ dropped.push("conversation_samples");
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // 3. Commit subjects + repo README excerpts (contributions/blurbs inputs).
108
+ if (over()) {
109
+ const aiCoding = asRecord(p.ai_coding);
110
+ if (aiCoding &&
111
+ Array.isArray(aiCoding.commit_subjects) &&
112
+ aiCoding.commit_subjects.length) {
113
+ aiCoding.commit_subjects = [];
114
+ dropped.push("commit_subjects");
115
+ }
116
+ const local = asRecord(p.local);
117
+ if (local && Array.isArray(local.repos)) {
118
+ let touched = false;
119
+ for (const repo of local.repos) {
120
+ const r = asRecord(repo);
121
+ if (r && r.readme_excerpt) {
122
+ r.readme_excerpt = null;
123
+ touched = true;
124
+ }
125
+ }
126
+ if (touched)
127
+ dropped.push("readme_excerpts");
128
+ }
129
+ }
130
+ // 4. Last resort: shrink exchanges further (halve, keeping recent) until it
131
+ // fits. Only reached for pathologically large non-exchange data.
132
+ while (over() && totalExchanges(p) > 0) {
133
+ for (const t of TOOLS) {
134
+ const tool = asRecord(aiUsage[t]);
135
+ const ex = tool?.exchanges;
136
+ if (tool && Array.isArray(ex) && ex.length > 0) {
137
+ tool.exchanges = ex.slice(Math.ceil(ex.length / 2));
138
+ }
139
+ }
140
+ if (!dropped.includes("exchanges:backstop"))
141
+ dropped.push("exchanges:backstop");
142
+ }
143
+ return {
144
+ payload: p,
145
+ trimmed: true,
146
+ bytesBefore,
147
+ bytesAfter: byteLength(p),
148
+ dropped,
149
+ };
150
+ }
@@ -1,5 +1,5 @@
1
1
  // Dev preview of the proficiency finale card for every score tier.
2
- // npm run preview:tiers (from packages/agent)
2
+ // npm run preview:tiers (from packages/standout)
3
3
  // One representative profile per tier so you can eyeball figures, copy, and fit.
4
4
  import { cardProficiency } from "./render.js";
5
5
  import { TIERS } from "./tiers.js";
@@ -1,4 +1,8 @@
1
+ // PUBLISHING: this is shipped npm package code. Any runtime change here must bump
2
+ // "version" in package.json in the same PR — that bump is the ONLY trigger for the
3
+ // npm publish on merge. Land a change without it and the fix never reaches users.
1
4
  import { STANDOUT_API_URL } from "./api.js";
5
+ import { capPayload } from "./payload.js";
2
6
  import { aggregateView } from "./wrapped/aggregate.js";
3
7
  const HEADERS = {
4
8
  "Content-Type": "application/json",
@@ -6,31 +10,73 @@ const HEADERS = {
6
10
  // node-fetch UAs on certain paths. Set a clear identity.
7
11
  "User-Agent": "Standout/0.4.0 (node)",
8
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
+ 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;
20
+ 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
+ const RETRYABLE_CONNECT_CODES = new Set([
26
+ "ECONNREFUSED",
27
+ "ENOTFOUND",
28
+ "EAI_AGAIN",
29
+ "UND_ERR_CONNECT_TIMEOUT",
30
+ ]);
31
+ function isPreConnectError(err) {
32
+ for (const node of [err, err?.cause]) {
33
+ const code = node?.code;
34
+ if (typeof code === "string" && RETRYABLE_CONNECT_CODES.has(code)) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
41
  export async function createWrapped(args) {
10
- try {
11
- const res = await fetch(`${STANDOUT_API_URL}/api/public/wrapped`, {
12
- method: "POST",
13
- headers: HEADERS,
14
- body: JSON.stringify({
42
+ // Keep the request under Vercel's 4.5MB body cap (else a heavy history 413s
43
+ // before the handler runs). Trims the bulkiest raw fields only when oversized.
44
+ const { payload: profile } = capPayload(args.profile);
45
+ const body = JSON.stringify({
46
+ profile,
47
+ submission_id: args.submissionId ?? undefined,
48
+ });
49
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
50
+ try {
51
+ const res = await fetch(`${STANDOUT_API_URL}/api/public/wrapped`, {
52
+ method: "POST",
53
+ headers: HEADERS,
54
+ body,
55
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
56
+ });
57
+ // Any HTTP response means the request reached the server; don't replay it
58
+ // (a 5xx could land after the row was already created).
59
+ if (!res.ok) {
60
+ const text = await res.text().catch(() => "");
61
+ process.stderr.write(`[wrapped] backend returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}\n`);
62
+ return null;
63
+ }
64
+ const data = (await res.json());
65
+ const view = aggregateView({
15
66
  profile: args.profile,
16
- submission_id: args.submissionId ?? undefined,
17
- }),
18
- });
19
- if (!res.ok) {
20
- const text = await res.text().catch(() => "");
21
- process.stderr.write(`[wrapped] backend returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}\n`);
67
+ computed: data.computed,
68
+ share_url: data.share_url,
69
+ });
70
+ return { id: data.id, share_url: data.share_url, view };
71
+ }
72
+ catch (err) {
73
+ if (isPreConnectError(err) && attempt < MAX_ATTEMPTS) {
74
+ await sleep(RETRY_BASE_DELAY_MS * attempt);
75
+ continue;
76
+ }
77
+ process.stderr.write(`[wrapped] failed to reach backend: ${err instanceof Error ? err.message : err}\n`);
22
78
  return null;
23
79
  }
24
- const data = (await res.json());
25
- const view = aggregateView({
26
- profile: args.profile,
27
- computed: data.computed,
28
- share_url: data.share_url,
29
- });
30
- return { id: data.id, share_url: data.share_url, view };
31
- }
32
- catch (err) {
33
- process.stderr.write(`[wrapped] failed to reach backend: ${err instanceof Error ? err.message : err}\n`);
34
- return null;
35
80
  }
81
+ return null;
36
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standout",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "Build your developer profile with AI. One command, zero friction.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",