behavior-wrapped 0.8.14 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,14 +25,32 @@ const portArg = process.argv.find((arg) => arg.startsWith("--port="));
25
25
  const port = Number(portArg?.split("=")[1] || 4317);
26
26
  const configuredIdleMs = Number(process.env.BEHAVIOR_WRAPPED_HELPER_IDLE_MS);
27
27
  const helperIdleMs = Number.isFinite(configuredIdleMs) && configuredIdleMs >= 100 ? configuredIdleMs : 5 * 60 * 1_000;
28
- let catalog = await loadCatalog();
28
+ const configuredTestCatalogDelayMs = process.env.NODE_ENV === "test" ? Number(process.env.BEHAVIOR_WRAPPED_TEST_CATALOG_DELAY_MS) : 0;
29
+ const testCatalogDelayMs = Number.isFinite(configuredTestCatalogDelayMs) && configuredTestCatalogDelayMs > 0 ? configuredTestCatalogDelayMs : 0;
30
+ let catalog = null;
31
+ let catalogPromise = null;
29
32
 
30
33
  async function loadCatalog() {
34
+ if (testCatalogDelayMs) await new Promise((resolve) => setTimeout(resolve, testCatalogDelayMs));
31
35
  const found = await discoverAllSessionsAsync(demo ? { claudeRoot: fixtureRoot, coworkRoot: coworkFixtureRoot, codexRoots: [codexFixtureRoot], cache: false } : undefined);
32
36
  if (demo) found.sessions = found.sessions.map((session, index) => ({ ...session, synthetic: true, label: `Demo session ${index + 1}` }));
33
37
  return found;
34
38
  }
35
39
 
40
+ async function catalogForRequest({ refresh = false } = {}) {
41
+ if (refresh || !catalogPromise) {
42
+ const loading = loadCatalog();
43
+ catalogPromise = loading;
44
+ try {
45
+ catalog = await loading;
46
+ } catch (error) {
47
+ if (catalogPromise === loading) catalogPromise = null;
48
+ throw error;
49
+ }
50
+ }
51
+ return catalogPromise;
52
+ }
53
+
36
54
  function securityHeaders(extra = {}) {
37
55
  return {
38
56
  "X-Content-Type-Options": "nosniff",
@@ -71,23 +89,24 @@ function readBody(request, maximumBytes = 1_000_000) {
71
89
  });
72
90
  }
73
91
 
74
- async function chosenRecords(ids, options = {}) {
92
+ async function chosenRecords(ids, options = {}, activeCatalog = null) {
93
+ const availableCatalog = activeCatalog || await catalogForRequest();
75
94
  const selected = [];
76
95
  for (const id of ids) {
77
- const session = catalog.index.get(id);
96
+ const session = availableCatalog.index.get(id);
78
97
  if (session) selected.push({ sessionId: id, agent: session.agent, records: await readRecordsAsync(session.file, session.agent, options) });
79
98
  }
80
99
  return selected;
81
100
  }
82
101
 
83
- function publicCatalog() {
102
+ function publicCatalog(availableCatalog) {
84
103
  const agentNames = supportedAgentNames(process.platform, { includeCowork: demo || process.platform === "darwin" });
85
104
  return {
86
- rootAvailable: catalog.rootAvailable,
105
+ rootAvailable: availableCatalog.rootAvailable,
87
106
  demo,
88
- projects: catalog.projects,
89
- sessions: catalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
90
- defaultRange: defaultDateRange(catalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
107
+ projects: availableCatalog.projects,
108
+ sessions: availableCatalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
109
+ defaultRange: defaultDateRange(availableCatalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
91
110
  agentNames,
92
111
  privacy: { canonicalDirectories: canonicalSessionDirectoryLabels(), networkRequests: "only-after-final-donation-consent" },
93
112
  };
@@ -99,10 +118,10 @@ const server = http.createServer(async (request, response) => {
99
118
  if (!new Set([`127.0.0.1:${port}`, `localhost:${port}`]).has(request.headers.host || "")) return json(response, 403, { error: "Local access only" });
100
119
  idleShutdown.touch();
101
120
  const url = new URL(request.url || "/", `http://${request.headers.host}`);
102
- if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo });
121
+ if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo, catalogState: catalog ? "ready" : catalogPromise ? "loading" : "not-loaded" });
103
122
  if (request.method === "GET" && url.pathname === "/api/discover") {
104
- catalog = await loadCatalog();
105
- return json(response, 200, publicCatalog());
123
+ const availableCatalog = await catalogForRequest({ refresh: true });
124
+ return json(response, 200, publicCatalog(availableCatalog));
106
125
  }
107
126
  const reportMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})$/);
108
127
  if (request.method === "GET" && reportMatch) {
@@ -116,39 +135,43 @@ const server = http.createServer(async (request, response) => {
116
135
  if (request.method === "GET" && selectionMatch) {
117
136
  const report = loadReport(selectionMatch[1]);
118
137
  if (!report) return json(response, 404, { error: "Saved report not found" });
119
- const available = new Set(catalog.sessions.map((session) => session.id));
138
+ const availableCatalog = await catalogForRequest();
139
+ const available = new Set(availableCatalog.sessions.map((session) => session.id));
120
140
  return json(response, 200, { sessionIds: (report.sessionIds || []).filter((id) => available.has(id)), localPrivateSelection: true });
121
141
  }
122
142
  const workaroundEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/workarounds$/);
123
143
  if (request.method === "GET" && workaroundEvidenceMatch) {
124
144
  const report = loadReport(workaroundEvidenceMatch[1]);
125
145
  if (!report) return json(response, 404, { error: "Saved report not found" });
146
+ const availableCatalog = await catalogForRequest();
126
147
  const allowed = new Set(report.sessionIds || []);
127
- const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 100);
128
- const records = await chosenRecords(ids, { includePrivateToolDetails: true });
129
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
148
+ const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 100);
149
+ const records = await chosenRecords(ids, { includePrivateToolDetails: true }, availableCatalog);
150
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
130
151
  return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
131
152
  }
132
153
  const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
133
154
  if (request.method === "GET" && interactionEvidenceMatch) {
134
155
  const report = loadReport(interactionEvidenceMatch[1]);
135
156
  if (!report) return json(response, 404, { error: "Saved report not found" });
157
+ const availableCatalog = await catalogForRequest();
136
158
  const allowed = new Set(report.sessionIds || []);
137
159
  const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || []), ...(report.apologyReview?.user || []), ...(report.apologyReview?.agent || [])];
138
- const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 200);
139
- const records = await chosenRecords(ids);
140
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
160
+ const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 200);
161
+ const records = await chosenRecords(ids, {}, availableCatalog);
162
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
141
163
  return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
142
164
  }
143
165
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
144
166
  const body = await readBody(request);
145
167
  const report = loadReport(body.reportId);
146
168
  if (!report) return json(response, 404, { error: "Saved report not found" });
169
+ const availableCatalog = await catalogForRequest();
147
170
  const allowed = new Set(report.sessionIds || []);
148
- const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && catalog.index.has(id)).slice(0, 250) : [];
149
- const records = await chosenRecords(ids);
171
+ const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
172
+ const records = await chosenRecords(ids, {}, availableCatalog);
150
173
  if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
151
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
174
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
152
175
  const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
153
176
  const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
154
177
  const unredacted = body.previewMode === "unredacted";
@@ -1,4 +1,5 @@
1
1
  import { BEHAVIOR_WRAPPED_ORIGIN } from "./origins.mjs";
2
+ import { buildSessionLengthDistribution } from "./session-length-distribution.mjs";
2
3
  export const LEADERBOARD_RELAY_ORIGIN = BEHAVIOR_WRAPPED_ORIGIN;
3
4
  const REQUEST_TIMEOUT_MS = 15_000;
4
5
  const demoTokens = [820_000, 2_400_000, 8_900_000, 14_300_000, 31_000_000, 47_500_000, 83_000_000, 126_000_000, 210_000_000, 380_000_000, 620_000_000, 940_000_000];
@@ -88,7 +89,7 @@ export function syntheticLeaderboardSnapshot(aggregate, participation = null) {
88
89
  },
89
90
  session_lengths: {
90
91
  values: aggregate.session_turn_counts,
91
- samples: demoSessionTurnCounts.flatMap((values, participantIndex) => values.map((value, sessionIndex) => ({ participant_id: participantIndex + 1, session_index: sessionIndex, value }))),
92
+ distribution: buildSessionLengthDistribution(demoSessionTurnCounts.flat()),
92
93
  },
93
94
  phrases: {
94
95
  entries: aggregate.favorite_phrase ? [{ participant_id: 1, phrase: aggregate.favorite_phrase, occurrences: aggregate.phrase_occurrences, sessions: aggregate.phrase_sessions }] : [],
@@ -208,6 +208,14 @@ export function buildLocalPhraseCard(candidates) {
208
208
  });
209
209
  }
210
210
 
211
+ export async function phraseCardWithLocalFallback(candidates, judgedCard, { onFallback } = {}) {
212
+ try { return await judgedCard; }
213
+ catch (error) {
214
+ onFallback?.(error);
215
+ return buildLocalPhraseCard(candidates);
216
+ }
217
+ }
218
+
211
219
  function timeoutMessage(error, timeoutMs) {
212
220
  if (error?.name === "TimeoutError" || error?.name === "AbortError") return new Error(`${PHRASE_JUDGE_NAME} timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
213
221
  return error;
@@ -19,7 +19,7 @@ function safeBreakdown(value, labelKey, countKey, allowedLabels) {
19
19
  });
20
20
  }
21
21
 
22
- const stockPhraseLabels = ["You're right", "Say the word", "genuinely", "one wrinkle", "load bearing", "the full picture", "delve"];
22
+ const stockPhraseLabels = ["You're right", "Say the word", "genuinely", "one wrinkle", "load bearing", "full picture", "the full picture", "delve"];
23
23
  const allowedAgents = new Set(["claude", "cowork", "codex"]);
24
24
 
25
25
  function safeTurnCounts(value) {
@@ -0,0 +1,74 @@
1
+ const MAX_SESSION_TURNS = 1_000_000;
2
+ const CONTOUR_POINT_COUNT = 64;
3
+
4
+ function validSessionTurns(values) {
5
+ return Array.isArray(values)
6
+ ? values.flatMap((value) => Number.isInteger(value) && value >= 1 && value <= MAX_SESSION_TURNS ? [value] : [])
7
+ : [];
8
+ }
9
+
10
+ function median(values) {
11
+ const sorted = [...values].sort((left, right) => left - right);
12
+ if (!sorted.length) return 0;
13
+ const middle = Math.floor(sorted.length / 2);
14
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
15
+ }
16
+
17
+ export function buildSessionLengthDistribution(values) {
18
+ const sessions = validSessionTurns(values);
19
+ if (!sessions.length) return { session_count: 0, median_turns: 0, min_turns: 0, max_turns: 0, points: [] };
20
+
21
+ const logs = sessions.map((value) => Math.log10(value));
22
+ const observedMinimum = Math.min(...logs);
23
+ const observedMaximum = Math.max(...logs);
24
+ const observedSpan = observedMaximum - observedMinimum;
25
+ const padding = Math.max(.12, observedSpan * .08);
26
+ const domainMinimum = Math.max(0, observedMinimum - padding);
27
+ const domainMaximum = Math.max(domainMinimum + .32, observedMaximum + padding);
28
+ const mean = logs.reduce((sum, value) => sum + value, 0) / logs.length;
29
+ const variance = logs.reduce((sum, value) => sum + (value - mean) ** 2, 0) / logs.length;
30
+ const deviation = Math.sqrt(variance);
31
+ const bandwidth = Math.max(.08, Math.min(.32, 1.06 * (deviation || .18) * logs.length ** -.2));
32
+ const raw = Array.from({ length: CONTOUR_POINT_COUNT }, (_, index) => {
33
+ const position = domainMinimum + index / (CONTOUR_POINT_COUNT - 1) * (domainMaximum - domainMinimum);
34
+ const density = logs.reduce((sum, value) => sum + Math.exp(-.5 * ((position - value) / bandwidth) ** 2), 0) / logs.length;
35
+ return { turns: 10 ** position, density };
36
+ });
37
+ const maximumDensity = Math.max(...raw.map((point) => point.density), Number.EPSILON);
38
+
39
+ return {
40
+ session_count: sessions.length,
41
+ median_turns: Number(median(sessions).toFixed(1)),
42
+ min_turns: Math.min(...sessions),
43
+ max_turns: Math.max(...sessions),
44
+ points: raw.map((point) => ({
45
+ turns: Number(point.turns.toPrecision(7)),
46
+ density: Number((point.density / maximumDensity).toFixed(4)),
47
+ })),
48
+ };
49
+ }
50
+
51
+ export function parseSessionLengthDistribution(value) {
52
+ let parsed;
53
+ try { parsed = typeof value === "string" ? JSON.parse(value) : value; }
54
+ catch { return null; }
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
56
+ const sessionCount = Number(parsed.session_count);
57
+ const medianTurns = Number(parsed.median_turns);
58
+ const minTurns = Number(parsed.min_turns);
59
+ const maxTurns = Number(parsed.max_turns);
60
+ if (![sessionCount, medianTurns, minTurns, maxTurns].every(Number.isFinite)) return null;
61
+ if (!Number.isInteger(sessionCount) || sessionCount < 0 || sessionCount > 100_000_000) return null;
62
+ if (sessionCount === 0) return parsed.points?.length === 0 ? { session_count: 0, median_turns: 0, min_turns: 0, max_turns: 0, points: [] } : null;
63
+ if (medianTurns < 1 || minTurns < 1 || maxTurns < minTurns || maxTurns > MAX_SESSION_TURNS || medianTurns > maxTurns) return null;
64
+ if (!Array.isArray(parsed.points) || parsed.points.length !== CONTOUR_POINT_COUNT) return null;
65
+ let previousTurns = 0;
66
+ const points = parsed.points.flatMap((point) => {
67
+ const turns = Number(point?.turns);
68
+ const density = Number(point?.density);
69
+ if (!Number.isFinite(turns) || turns <= previousTurns || turns < 1 || turns > MAX_SESSION_TURNS * 2 || !Number.isFinite(density) || density < 0 || density > 1) return [];
70
+ previousTurns = turns;
71
+ return [{ turns, density }];
72
+ });
73
+ return points.length === CONTOUR_POINT_COUNT ? { session_count: sessionCount, median_turns: medianTurns, min_turns: minTurns, max_turns: maxTurns, points } : null;
74
+ }