behavior-wrapped 0.4.3 → 0.4.4

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="Your private, local-first Claude Code behavior report." />
8
8
  <title>Behavior Wrapped</title>
9
- <script type="module" crossorigin src="/assets/index-P98_jCrO.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-iGlv2olC.css">
9
+ <script type="module" crossorigin src="/assets/index-Cuo3lbwx.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BgzJrArR.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.4.3",
3
+ "version": "0.4.4",
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",
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import { discoverAllSessionsAsync, readRecordsAsync, sessionsInDefaultWindow } from "../server/discovery.mjs";
3
+ import { buildPhraseCandidates, judgePhraseCard } from "../server/phrase-card.mjs";
4
+ import { buildInteractionToneCandidates, judgeInteractionTone } from "../server/interaction-tone.mjs";
5
+ import { buildSessionTopicCandidates, judgeSessionTopics } from "../server/session-topics.mjs";
6
+ import { buildWorkaroundTrajectories, judgeWorkarounds } from "../server/instrumental-workarounds.mjs";
7
+
8
+ const apiKey = process.env.OPENROUTER_API_KEY;
9
+ if (!apiKey) throw new Error("OPENROUTER_API_KEY is required.");
10
+
11
+ const configuredModels = [
12
+ { label: "Gemma 4 31B", id: "google/gemma-4-31b-it:free", reasoningEffort: "none" },
13
+ { label: "GPT-OSS 20B", id: "openai/gpt-oss-20b:free", reasoningEffort: "low", minimumMaxTokens: 16_384 },
14
+ { label: "GPT-5.6 Luna (ZDR)", id: "openai/gpt-5.6-luna", reasoningEffort: "none", zdr: true },
15
+ ];
16
+ const models = process.env.COMPARE_MODEL
17
+ ? configuredModels.filter((model) => model.id.includes(process.env.COMPARE_MODEL))
18
+ : configuredModels;
19
+ if (!models.length) throw new Error(`No configured model matched COMPARE_MODEL=${process.env.COMPARE_MODEL}.`);
20
+
21
+ const providersSeen = new Set();
22
+ const usageSeen = [];
23
+ function privacyFilteredFetch(model) {
24
+ return async (url, options = {}) => {
25
+ const requestBody = JSON.parse(options.body);
26
+ requestBody.provider = { ...(requestBody.provider || {}), data_collection: "deny", ...(model.zdr ? { zdr: true } : {}) };
27
+ requestBody.reasoning = { ...(requestBody.reasoning || {}), effort: model.reasoningEffort, exclude: true };
28
+ requestBody.max_tokens = Math.max(Number(requestBody.max_tokens) || 0, model.minimumMaxTokens || 0);
29
+ let response;
30
+ for (let attempt = 0; attempt < 4; attempt++) {
31
+ response = await fetch(url, { ...options, body: JSON.stringify(requestBody) });
32
+ if (response.status !== 429 || attempt === 3) break;
33
+ const waitMs = 5_000 * (2 ** attempt);
34
+ process.stdout.write(`capacity retry in ${waitMs / 1000}s... `);
35
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
36
+ }
37
+ try {
38
+ const responseBody = await response.clone().json();
39
+ if (responseBody?.provider) providersSeen.add(responseBody.provider);
40
+ if (responseBody?.usage) usageSeen.push({
41
+ provider: responseBody.provider || null,
42
+ promptTokens: Number(responseBody.usage.prompt_tokens) || 0,
43
+ completionTokens: Number(responseBody.usage.completion_tokens) || 0,
44
+ totalTokens: Number(responseBody.usage.total_tokens) || 0,
45
+ costUsd: Number(responseBody.usage.cost) || 0,
46
+ });
47
+ } catch { /* The judge functions report malformed responses. */ }
48
+ return response;
49
+ };
50
+ }
51
+
52
+ function errorSummary(error) {
53
+ return {
54
+ error: error?.message || String(error),
55
+ ...(error?.judgeDetails?.http_status ? { status: error.judgeDetails.http_status } : {}),
56
+ ...(error?.judgeDetails?.upstream_code ? { code: error.judgeDetails.upstream_code } : {}),
57
+ };
58
+ }
59
+
60
+ async function attempt(label, operation) {
61
+ process.stdout.write(` ${label}... `);
62
+ const startedAt = Date.now();
63
+ try {
64
+ const value = await operation();
65
+ console.log(`ok (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
66
+ return { ok: true, value };
67
+ } catch (error) {
68
+ console.log(`failed (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
69
+ return { ok: false, ...errorSummary(error) };
70
+ }
71
+ }
72
+
73
+ function summarize(result) {
74
+ const phrase = result.phrase?.ok ? {
75
+ phrase: result.phrase.value.phrase,
76
+ occurrences: result.phrase.value.occurrences,
77
+ latencyMs: result.phrase.value.latencyMs,
78
+ } : result.phrase;
79
+ const tone = result.tone?.ok ? {
80
+ frustratedMessages: result.tone.value.frustratedMessages,
81
+ gratefulMessages: result.tone.value.gratefulMessages,
82
+ frustrationQuote: result.tone.value.frustrationQuote,
83
+ latencyMs: result.tone.value.latencyMs,
84
+ } : result.tone;
85
+ const topics = result.topics?.ok ? {
86
+ topics: result.topics.value.topics.map(({ topic, percentage }) => ({ topic, percentage })),
87
+ latencyMs: result.topics.value.latencyMs,
88
+ } : result.topics;
89
+ const workarounds = result.workarounds?.ok ? {
90
+ confirmed: result.workarounds.value.card.count,
91
+ borderline: result.workarounds.value.review.borderline.length,
92
+ examples: result.workarounds.value.review.occurrences.map(({ summary, confidence, disclosure }) => ({ summary, confidence, disclosure })),
93
+ latencyMs: result.workarounds.value.review.latencyMs,
94
+ } : result.workarounds;
95
+ return { phrase, tone, topics, workarounds };
96
+ }
97
+
98
+ function summarizeUsage(records) {
99
+ return records.reduce((total, record) => ({
100
+ requests: total.requests + 1,
101
+ promptTokens: total.promptTokens + record.promptTokens,
102
+ completionTokens: total.completionTokens + record.completionTokens,
103
+ totalTokens: total.totalTokens + record.totalTokens,
104
+ costUsd: Number((total.costUsd + record.costUsd).toFixed(12)),
105
+ }), { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0 });
106
+ }
107
+
108
+ console.log("Preparing the same 30-day, locally redacted Behavior Wrapped input for both models...");
109
+ const catalog = await discoverAllSessionsAsync();
110
+ const sessions = sessionsInDefaultWindow(catalog.sessions, { days: 30 });
111
+ const records = [];
112
+ for (const publicSession of sessions) {
113
+ const session = catalog.index.get(publicSession.id);
114
+ records.push({ sessionId: session.id, agent: session.agent, records: await readRecordsAsync(session.file, session.agent) });
115
+ }
116
+
117
+ const phraseCandidates = buildPhraseCandidates(records, { maximumCandidates: 100 });
118
+ const toneCandidates = buildInteractionToneCandidates(records);
119
+ const topicBundle = buildSessionTopicCandidates(records);
120
+ const workaroundBundle = buildWorkaroundTrajectories(records);
121
+ console.log(`Snapshot: ${sessions.length} sessions; ${phraseCandidates.length} phrase candidates; ${toneCandidates.length} tone candidates; ${topicBundle.candidates.length} topic candidates; ${workaroundBundle.chunks.length} workaround chunks.`);
122
+
123
+ const comparison = {};
124
+ for (const model of models) {
125
+ providersSeen.clear();
126
+ usageSeen.length = 0;
127
+ console.log(`\n${model.label} (${model.id})`);
128
+ const options = { model: model.id, fetchImpl: privacyFilteredFetch(model) };
129
+ const phrase = phraseCandidates.length
130
+ ? await attempt("favorite phrase", () => judgePhraseCard(phraseCandidates, apiKey, options))
131
+ : { ok: false, error: "No candidates" };
132
+ const tone = toneCandidates.length
133
+ ? await attempt("interaction tone", () => judgeInteractionTone(toneCandidates, apiKey, options))
134
+ : { ok: false, error: "No candidates" };
135
+ const topics = topicBundle.candidates.length
136
+ ? await attempt("session topics", () => judgeSessionTopics(topicBundle, apiKey, options))
137
+ : { ok: false, error: "No candidates" };
138
+ const workarounds = workaroundBundle.chunks.length
139
+ ? await attempt("workarounds", () => judgeWorkarounds(workaroundBundle, apiKey, {
140
+ ...options,
141
+ onProgress: ({ index, total }) => process.stdout.write(index === 1 ? `[${index}/${total}] ` : `${index}/${total} `),
142
+ }))
143
+ : { ok: false, error: "No candidates" };
144
+ comparison[model.label] = {
145
+ providers: [...providersSeen],
146
+ privacy: { dataCollectionDenied: true, zeroDataRetentionRequired: Boolean(model.zdr) },
147
+ usage: summarizeUsage(usageSeen),
148
+ ...summarize({ phrase, tone, topics, workarounds }),
149
+ };
150
+ }
151
+
152
+ console.log("\nCOMPARISON_JSON");
153
+ console.log(JSON.stringify({
154
+ input: {
155
+ sessions: sessions.length,
156
+ phraseCandidates: phraseCandidates.length,
157
+ toneCandidates: toneCandidates.length,
158
+ topicCandidates: topicBundle.candidates.length,
159
+ workaroundChunks: workaroundBundle.chunks.length,
160
+ },
161
+ models: comparison,
162
+ }, null, 2));
@@ -479,10 +479,33 @@ function donationRedactionInventory(detections) {
479
479
  }).sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
480
480
  }
481
481
 
482
+ function localOpeningPrompt(value) {
483
+ let prompt = String(value || "");
484
+ const explicitRequest = prompt.match(/(?:^|\n)## My request:\s*([\s\S]*)$/i);
485
+ if (explicitRequest) prompt = explicitRequest[1];
486
+ prompt = prompt
487
+ .replace(/<recommended_plugins>[\s\S]*?<\/recommended_plugins>/gi, " ")
488
+ .replace(/<environment_context>[\s\S]*?<\/environment_context>/gi, " ")
489
+ .replace(/<skills_instructions>[\s\S]*?<\/skills_instructions>/gi, " ")
490
+ .replace(/<permissions instructions>[\s\S]*?<\/permissions instructions>/gi, " ")
491
+ .replace(/<collaboration_mode>[\s\S]*?<\/collaboration_mode>/gi, " ")
492
+ .replace(/<apps_instructions>[\s\S]*?<\/apps_instructions>/gi, " ")
493
+ .replace(/<plugins_instructions>[\s\S]*?<\/plugins_instructions>/gi, " ")
494
+ .replace(/<multi_agent_mode>[\s\S]*?<\/multi_agent_mode>/gi, " ")
495
+ .replace(/<INSTRUCTIONS>[\s\S]*?<\/INSTRUCTIONS>/gi, " ")
496
+ .replace(/^\s*# AGENTS\.md instructions\s*$/gim, " ")
497
+ .replace(/<image\b[^>]*>[\s\S]*?<\/image>/gi, " ")
498
+ .replace(/\s+/g, " ")
499
+ .trim();
500
+ return prompt;
501
+ }
502
+
482
503
  function donationSessionSummary(messages, suppliedSummary) {
483
504
  const provided = String(suppliedSummary || "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
484
505
  if (provided) return provided.slice(0, 140);
485
- const opening = messages.find((message) => message.role === "user")?.text || messages[0]?.text || "Session transcript";
506
+ const opening = messages.filter((message) => message.role === "user").map((message) => localOpeningPrompt(message.text)).find(Boolean)
507
+ || localOpeningPrompt(messages[0]?.text)
508
+ || "Session transcript";
486
509
  const compact = opening.replace(/\[(?:REDACTED|REMOVED)[^\]]*\]/g, "private detail").replace(/\s+/g, " ").trim();
487
510
  if (compact.length <= 110) return compact;
488
511
  const shortened = compact.slice(0, 109);
@@ -119,8 +119,7 @@ const server = http.createServer(async (request, response) => {
119
119
  const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && catalog.index.has(id)).slice(0, 250) : [];
120
120
  const records = await chosenRecords(ids);
121
121
  if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
122
- const summaries = new Map((report.sessionSummaries || []).flatMap((item) => typeof item?.sessionId === "string" && typeof item?.summary === "string" ? [[item.sessionId, item.summary]] : []));
123
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, { ...session, summary: summaries.get(session.id) }]));
122
+ const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
124
123
  const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
125
124
  const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
126
125
  const unredacted = body.previewMode === "unredacted";