behavior-wrapped 0.10.1 → 0.11.0

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.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <meta name="theme-color" content="#0d0b1b" />
7
7
  <meta name="description" content="A local-first behavior report for you and your AI agents." />
8
8
  <title>Behavior Wrapped</title>
9
- <script type="module" crossorigin src="/assets/index-BaiNv0uD.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-D-wey53w.css">
9
+ <script type="module" crossorigin src="/assets/index-By4wq-jG.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CkqI9mbd.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "A private, local-first Wrapped report for Claude Code, Cowork, and Codex behavior.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { BEHAVIOR_WRAPPED_ORIGIN } from "./origins.mjs";
2
2
  import { buildSessionLengthDistribution } from "./session-length-distribution.mjs";
3
+ import { validatePhraseModels } from "./phrase-models.mjs";
3
4
  export const LEADERBOARD_RELAY_ORIGIN = BEHAVIOR_WRAPPED_ORIGIN;
4
5
  const REQUEST_TIMEOUT_MS = 15_000;
5
6
  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];
@@ -56,6 +57,7 @@ export function leaderboardAggregateFromReport(report) {
56
57
  favorite_phrase: typeof phrase === "string" && /^[a-z]+(?:'[a-z]+)?(?: [a-z]+(?:'[a-z]+)?){3,9}$/.test(phrase) ? phrase : null,
57
58
  phrase_occurrences: Math.round(finiteNonNegative(report?.phraseCard?.occurrences)),
58
59
  phrase_sessions: Math.round(finiteNonNegative(report?.phraseCard?.distinctSessions)),
60
+ phrase_models: validatePhraseModels(report?.phraseCard?.sourceModels, Math.round(finiteNonNegative(report?.phraseCard?.occurrences))) || [],
59
61
  session_turn_counts: sessionTurnCounts(stats.sessionTurnCounts),
60
62
  };
61
63
  }
@@ -92,7 +94,8 @@ export function syntheticLeaderboardSnapshot(aggregate, participation = null) {
92
94
  distribution: buildSessionLengthDistribution(demoSessionTurnCounts.flat()),
93
95
  },
94
96
  phrases: {
95
- entries: aggregate.favorite_phrase ? [{ participant_id: 1, phrase: aggregate.favorite_phrase, occurrences: aggregate.phrase_occurrences, sessions: aggregate.phrase_sessions }] : [],
97
+ entries: aggregate.favorite_phrase ? [{ participant_id: 1, phrase: aggregate.favorite_phrase, occurrences: aggregate.phrase_occurrences, sessions: aggregate.phrase_sessions, models: aggregate.phrase_models || [], participants: 1 }] : [],
98
+ common: [],
96
99
  },
97
100
  participation: participation || { joined: false },
98
101
  };
@@ -3,8 +3,8 @@ export function displayModelName(value) {
3
3
  if (raw === "<synthetic>") return "Synthetic model";
4
4
  const claude = raw.match(/^claude-([a-z]+)-(\d+)-(\d+)$/i);
5
5
  if (claude) return `Claude ${claude[1][0].toUpperCase()}${claude[1].slice(1).toLowerCase()} ${claude[2]}.${claude[3]}`;
6
- const gpt = raw.match(/^gpt-(\d+)[.-](\d+)(?:-([a-z]+))?$/i);
7
- if (gpt) return `GPT-${gpt[1]}.${gpt[2]}${gpt[3] ? ` ${gpt[3][0].toUpperCase()}${gpt[3].slice(1).toLowerCase()}` : ""}`;
6
+ const gpt = raw.match(/^gpt-(\d+)(?:[.-](\d+))?(?:-([a-z]+))?$/i);
7
+ if (gpt) return `GPT-${gpt[1]}${gpt[2] ? `.${gpt[2]}` : ""}${gpt[3] ? ` ${gpt[3][0].toUpperCase()}${gpt[3].slice(1).toLowerCase()}` : ""}`;
8
8
  return raw
9
9
  .replace(/^claude-/i, "Claude ")
10
10
  .replace(/^gpt-/i, "GPT-")
@@ -1,6 +1,8 @@
1
1
  import { redactAggregateText } from "./privacy.mjs";
2
2
  import { judgeError, judgeRequestDetails, judgeResponseDetails } from "./judge-debug.mjs";
3
3
  import { BEHAVIOR_WRAPPED_ORIGIN } from "./origins.mjs";
4
+ import { displayModelName } from "./model-names.mjs";
5
+ import { validatePhraseModels } from "./phrase-models.mjs";
4
6
 
5
7
  export const OPENROUTER_MODEL = "openai/gpt-5.6-luna";
6
8
  export const PHRASE_JUDGE_NAME = "GPT-5.6 Luna";
@@ -56,6 +58,9 @@ export function buildPhraseCandidates(sessionRecords, { maximumCandidates = MAX_
56
58
  if (record.type !== "assistant" || record.isApiErrorMessage || record?.message?.model === "<synthetic>") continue;
57
59
  const prose = cleanText(visibleText(record));
58
60
  if (!prose.trim()) continue;
61
+ const rawModel = record?.message?.model || record?.model;
62
+ const modelName = displayModelName(typeof rawModel === "string" ? rawModel.replace(/^(?:openai|anthropic)\//i, "") : "Unknown model");
63
+ const model = /^[\p{L}\p{N} ._+-]{1,80}$/u.test(modelName) && !/^(?:Codex|Claude|Cowork|Unknown) model$/i.test(modelName) ? modelName : "Unknown model";
59
64
  let clauseIndex = 0;
60
65
  for (const part of segmenter.segment(prose)) {
61
66
  const clauses = part.segment.split(/(?:[;:—–]|\n+|,(?=\s+(?:and|but|or|so|yet)\b))/i);
@@ -68,8 +73,10 @@ export function buildPhraseCandidates(sessionRecords, { maximumCandidates = MAX_
68
73
  if (slice.filter((token) => !stopwords.has(token)).length < 2) continue;
69
74
  const phrase = slice.join(" ");
70
75
  let item = counts.get(phrase);
71
- if (!item) counts.set(phrase, item = { phrase, occurrences: 0, sessions: new Set(), openingOccurrences: 0, startBoundaryOccurrences: 0, endBoundaryOccurrences: 0, previousTokens: new Map(), nextTokens: new Map() });
76
+ if (!item) counts.set(phrase, item = { phrase, occurrences: 0, models: new Map(), sessions: new Set(), openingOccurrences: 0, startBoundaryOccurrences: 0, endBoundaryOccurrences: 0, previousTokens: new Map(), nextTokens: new Map() });
72
77
  item.occurrences++;
78
+ const sourceModel = item.models.has(model) || item.models.size < 49 ? model : "Unknown model";
79
+ item.models.set(sourceModel, (item.models.get(sourceModel) || 0) + 1);
73
80
  item.sessions.add(sessionIndex);
74
81
  if (clauseIndex === 0 && offset === 0) item.openingOccurrences++;
75
82
  if (offset === 0) item.startBoundaryOccurrences++;
@@ -114,6 +121,7 @@ export function buildPhraseCandidates(sessionRecords, { maximumCandidates = MAX_
114
121
  phrase: item.phrase,
115
122
  occurrences: item.occurrences,
116
123
  distinct_sessions: item.sessions.size,
124
+ sourceModels: [...item.models].map(([model, count]) => ({ model, count })).sort((left, right) => right.count - left.count || left.model.localeCompare(right.model)),
117
125
  opening_rate: Number((item.openingOccurrences / item.occurrences).toFixed(4)),
118
126
  start_boundary_rate: Number((item.startBoundaryOccurrences / item.occurrences).toFixed(4)),
119
127
  end_boundary_rate: Number((item.endBoundaryOccurrences / item.occurrences).toFixed(4)),
@@ -151,7 +159,7 @@ export function extractCandidateId(body, candidates) {
151
159
  }
152
160
 
153
161
  export function buildOpenRouterJudgeRequest(candidates, model = OPENROUTER_MODEL) {
154
- const payload = JSON.stringify(candidates);
162
+ const payload = JSON.stringify(candidates.map(({ sourceModels, ...candidate }) => candidate));
155
163
  assertSafePayload(payload);
156
164
  return {
157
165
  model,
@@ -188,6 +196,7 @@ function phraseCardFromSelection(candidates, candidateId, { model, provider, lat
188
196
  phrase: selected.phrase,
189
197
  occurrences: selected.occurrences,
190
198
  distinctSessions: selected.distinct_sessions,
199
+ sourceModels: validatePhraseModels(selected.sourceModels, selected.occurrences) || [],
191
200
  model,
192
201
  provider,
193
202
  latencyMs,
@@ -254,7 +263,8 @@ export async function judgePhraseCard(candidates, apiKey, { fetchImpl = fetch, m
254
263
 
255
264
  export async function judgePhraseCardViaRelay(candidates, { fetchImpl = fetch, endpoint = PHRASE_JUDGE_RELAY_URL, clientId, timeoutMs = PHRASE_JUDGE_TIMEOUT_MS } = {}) {
256
265
  if (!candidates.length) throw new Error("Not enough repeated, share-safe phrases were found for a phrase card.");
257
- const payload = JSON.stringify(candidates);
266
+ const judgeCandidates = candidates.map(({ sourceModels, ...candidate }) => candidate);
267
+ const payload = JSON.stringify(judgeCandidates);
258
268
  assertSafePayload(payload);
259
269
  const startedAt = Date.now();
260
270
  const debug = judgeRequestDetails("favorite-phrase", "relay", endpoint, candidates);
@@ -268,7 +278,7 @@ export async function judgePhraseCardViaRelay(candidates, { fetchImpl = fetch, e
268
278
  ...(clientId ? { "x-behavior-wrapped-client": clientId } : {}),
269
279
  },
270
280
  signal: AbortSignal.timeout(timeoutMs),
271
- body: JSON.stringify({ candidates }),
281
+ body: JSON.stringify({ candidates: judgeCandidates }),
272
282
  });
273
283
  } catch (error) {
274
284
  const wrapped = timeoutMessage(error, timeoutMs);
@@ -0,0 +1,20 @@
1
+ // Counts describe the models that said the selected phrase, not the selection judge.
2
+ export function validatePhraseModels(value, total) {
3
+ if (value === undefined) return [];
4
+ if (!Array.isArray(value) || value.length > 50) return null;
5
+ const seen = new Set();
6
+ const models = [];
7
+ for (const item of value) {
8
+ const model = typeof item?.model === "string" ? item.model.normalize("NFKC").trim() : "";
9
+ if (!/^[\p{L}\p{N} ._+-]{1,80}$/u.test(model) || seen.has(model)
10
+ || !Number.isInteger(item?.count) || item.count < 1 || item.count > 10_000_000) return null;
11
+ seen.add(model);
12
+ models.push({ model, count: item.count });
13
+ }
14
+ if (models.length && models.reduce((sum, item) => sum + item.count, 0) !== total) return null;
15
+ return models.sort((left, right) => right.count - left.count || left.model.localeCompare(right.model));
16
+ }
17
+
18
+ export function parsePhraseModels(value, total) {
19
+ try { return validatePhraseModels(JSON.parse(value), total) || []; } catch { return []; }
20
+ }
@@ -1,5 +1,6 @@
1
1
  import { isShareSafeFrustrationQuote } from "./frustration-card.mjs";
2
2
  import { safeWorkaroundSummary } from "./instrumental-workarounds.mjs";
3
+ import { validatePhraseModels } from "./phrase-models.mjs";
3
4
 
4
5
  function safeNumber(value, maximum = 10_000_000_000_000) {
5
6
  const number = Number(value);
@@ -101,6 +102,7 @@ export function sanitizePublicReport(value) {
101
102
  phrase,
102
103
  occurrences: Math.round(safeNumber(value.phraseCard.occurrences, 10_000_000)),
103
104
  distinctSessions: Math.round(safeNumber(value.phraseCard.distinctSessions, 1_000_000)),
105
+ sourceModels: validatePhraseModels(value.phraseCard.sourceModels, Math.round(safeNumber(value.phraseCard.occurrences, 10_000_000))) || [],
104
106
  } : null;
105
107
  const frustrationQuote = value.interactionCard?.frustrationQuote || value.interactionCard?.quote;
106
108
  const allowedLanguages = new Set(["English", "Spanish", "French", "German", "Portuguese", "Italian", "Japanese", "Korean", "Chinese", "Arabic", "Hebrew", "Hindi", "Thai", "Cyrillic"]);