behavior-wrapped 0.2.11

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.
Files changed (35) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +81 -0
  3. package/dist/assets/index-0bjvY6uC.js +11 -0
  4. package/dist/assets/index-Cjdrcfkw.css +1 -0
  5. package/dist/index.html +15 -0
  6. package/fixtures/codex-sessions/2026/06/07/rollout-2026-06-07T10-00-00-synthetic.jsonl +8 -0
  7. package/fixtures/projects/notes-lab/33333333-3333-4333-8333-333333333333.jsonl +6 -0
  8. package/fixtures/projects/synthetic-studio/11111111-1111-4111-8111-111111111111.jsonl +10 -0
  9. package/fixtures/projects/synthetic-studio/22222222-2222-4222-8222-222222222222.jsonl +10 -0
  10. package/package.json +68 -0
  11. package/scripts/benchmark-ngram-extraction.mjs +204 -0
  12. package/scripts/mine-phrase-families.mjs +419 -0
  13. package/scripts/review-interaction-tone.mjs +40 -0
  14. package/scripts/review-workaround-judge.mjs +148 -0
  15. package/server/analysis.mjs +475 -0
  16. package/server/cli.mjs +287 -0
  17. package/server/consent.mjs +19 -0
  18. package/server/discovery.mjs +370 -0
  19. package/server/frustration-card.mjs +174 -0
  20. package/server/instrumental-workarounds.mjs +590 -0
  21. package/server/interaction-tone.mjs +339 -0
  22. package/server/judge-debug.mjs +58 -0
  23. package/server/launcher.mjs +151 -0
  24. package/server/leaderboard.mjs +98 -0
  25. package/server/model-names.mjs +14 -0
  26. package/server/phrase-card.mjs +278 -0
  27. package/server/privacy.mjs +60 -0
  28. package/server/progress.mjs +71 -0
  29. package/server/public-report-schema.mjs +108 -0
  30. package/server/public-report.mjs +38 -0
  31. package/server/research-donation-schema.mjs +50 -0
  32. package/server/research-donation.mjs +28 -0
  33. package/server/session-topics.mjs +263 -0
  34. package/server/store.mjs +57 -0
  35. package/server/tool-semantics.mjs +61 -0
package/server/cli.mjs ADDED
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ import { discoverAllSessionsAsync, readRecordsAsync, sessionsInDefaultWindow, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
7
+ import { analyzeSessions } from "./analysis.mjs";
8
+ import { buildLocalPhraseCard, buildPhraseCandidates, judgePhraseCard, judgePhraseCardViaRelay, PHRASE_JUDGE_NAME, PHRASE_JUDGE_RELAY_URL } from "./phrase-card.mjs";
9
+ import { requestRemoteAnalysisConsent } from "./consent.mjs";
10
+ import { applyInteractionToneJudgment, buildInteractionToneCandidates, emptyInteractionToneJudgment, INTERACTION_TONE_RELAY_URL, judgeInteractionTone, judgeInteractionToneViaRelay } from "./interaction-tone.mjs";
11
+ import { applySessionTopicJudgment, buildSessionTopicCandidates, emptySessionTopicJudgment, judgeSessionTopics, judgeSessionTopicsViaRelay, SESSION_TOPIC_RELAY_URL } from "./session-topics.mjs";
12
+ import { applyWorkaroundJudgment, buildWorkaroundTrajectories, emptyWorkaroundJudgment, judgeWorkarounds, judgeWorkaroundsViaRelay, WORKAROUND_RELAY_URL } from "./instrumental-workarounds.mjs";
13
+ import { deletePublicReport, publishPublicReport, PUBLIC_REPORT_ORIGIN } from "./public-report.mjs";
14
+ import { createReportId, deleteReport, getOrCreateClientId, listReports, saveReport, storeRoot } from "./store.mjs";
15
+ import { judgeErrorDetails } from "./judge-debug.mjs";
16
+ import { createCliProgress } from "./progress.mjs";
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const root = path.dirname(here);
20
+ const fixtureRoot = path.join(root, "fixtures", "projects");
21
+ const codexFixtureRoot = path.join(root, "fixtures", "codex-sessions");
22
+ const port = Number(process.env.BEHAVIOR_WRAPPED_PORT || 4317);
23
+ const baseUrl = `http://127.0.0.1:${port}`;
24
+ const command = process.argv[2];
25
+ const verbose = process.argv.includes("--verbose") || process.argv.includes("--debug") || process.env.BEHAVIOR_WRAPPED_DEBUG === "1";
26
+ const muted = "\x1b[2m"; const bright = "\x1b[1m"; const lime = "\x1b[38;2;201;242;75m"; const purple = "\x1b[38;2;141;92;255m"; const reset = "\x1b[0m";
27
+ const progress = createCliProgress();
28
+
29
+ const mark = `${lime}
30
+ ╭──╮ ╭──╮
31
+ ╰╮ ╰─╯ ╭╯
32
+ ╰╮ ╭╯
33
+ ╭────┴───┴────╮
34
+ │ ${purple}● ●${lime} │
35
+ │ ╰─╯ │
36
+ ├──────┬──────┤
37
+ │ │ │
38
+ ╰──────┴──────╯${reset}`;
39
+
40
+ function formatNumber(value) {
41
+ if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
42
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
43
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
44
+ return String(value);
45
+ }
46
+
47
+ function formatRange(sessions) {
48
+ const values = sessions.map((s) => new Date(s.startedAt)).filter((d) => !Number.isNaN(d.getTime())).sort((a, b) => a.getTime() - b.getTime());
49
+ if (!values.length) return "Your coding history";
50
+ const format = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: values[0].getFullYear() === values.at(-1).getFullYear() ? undefined : "numeric" });
51
+ const year = values.at(-1).getFullYear();
52
+ return `${format.format(values[0])} – ${format.format(values.at(-1))}, ${year}`;
53
+ }
54
+
55
+ async function optionalAnalysis(promise, label, warnings) {
56
+ try { return await promise; }
57
+ catch (error) {
58
+ warnings.push({ label, error });
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function printJudgeDebug(label, error) {
64
+ if (!verbose) return;
65
+ console.error(`${muted}[judge debug] ${label}${reset}`);
66
+ console.error(JSON.stringify(judgeErrorDetails(error), null, 2));
67
+ }
68
+
69
+ async function serverReady(expectedDemo = false) {
70
+ try {
71
+ const response = await fetch(`${baseUrl}/api/health`);
72
+ const body = await response.json();
73
+ return response.ok && body.app === "behavior-wrapped" && Boolean(body.demo) === expectedDemo;
74
+ } catch { return false; }
75
+ }
76
+
77
+ async function ensureServer(demo = false) {
78
+ if (await serverReady(demo)) return;
79
+ const child = spawn(process.execPath, [path.join(here, "launcher.mjs"), `--port=${port}`, "--no-open", ...(demo ? ["--demo"] : [])], { detached: true, stdio: "ignore", env: { ...process.env, BEHAVIOR_WRAPPED_DAEMON: "1" } });
80
+ child.unref();
81
+ for (let attempt = 0; attempt < 30; attempt++) {
82
+ await new Promise((resolve) => setTimeout(resolve, 100));
83
+ if (await serverReady(demo)) return;
84
+ }
85
+ throw new Error(`Could not start the local donation helper on port ${port}.`);
86
+ }
87
+
88
+ function openUrl(url) {
89
+ if (process.argv.includes("--no-open")) return;
90
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
91
+ }
92
+
93
+ function printList() {
94
+ const reports = listReports();
95
+ console.log(`\n${bright}behavior-wrapped reports${reset}\n`);
96
+ if (!reports.length) { console.log(`${muted}No saved reports yet. Run behavior-wrapped to make one.${reset}\n`); return; }
97
+ for (const report of reports) console.log(`${purple}${report.id}${reset} ${report.rangeLabel} · ${report.stats.sessions} sessions · ${formatNumber(report.stats.tokens || 0)} tokens`);
98
+ console.log(`\n${muted}Open one: behavior-wrapped open <id>${reset}\n`);
99
+ }
100
+
101
+ async function openSaved(id) {
102
+ const report = id ? listReports().find((item) => item.id === id) : listReports()[0];
103
+ if (!report) throw new Error("That saved report was not found.");
104
+ const url = report.managementUrl || report.publicUrl || `${baseUrl}/w/${report.id}`;
105
+ if (!report.publicUrl) await ensureServer(false);
106
+ console.log(`${purple}${url}${reset}`);
107
+ openUrl(url);
108
+ }
109
+
110
+ async function createWrapped() {
111
+ console.log(mark);
112
+ console.log(`\n ${bright}behavior-wrapped${reset} ${muted}· the wrapped for your coding agent${reset}\n`);
113
+ const demo = process.argv.includes("--demo");
114
+ const testMode = process.argv.includes("--test") || process.argv.includes("--no-llm");
115
+ const daysArgument = process.argv.find((argument) => argument.startsWith("--days="));
116
+ const windowDays = daysArgument ? Number(daysArgument.split("=")[1]) : DEFAULT_WINDOW_DAYS;
117
+ if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) throw new Error("--days must be a whole number from 1 to 3650.");
118
+ progress.start("Finding local agent sessions", demo ? "synthetic demo history" : "Claude Code + Codex");
119
+ const catalog = await discoverAllSessionsAsync(demo ? { claudeRoot: fixtureRoot, codexRoots: [codexFixtureRoot] } : undefined);
120
+ const chosenSessions = sessionsInDefaultWindow(catalog.sessions, { days: windowDays, anchorLatest: demo });
121
+ progress.succeed(`Found ${catalog.sessions.length} sessions; ${chosenSessions.length} are in the ${windowDays}-day window`);
122
+ if (!chosenSessions.length) throw new Error(`No Claude Code or Codex sessions found in the last ${windowDays} days.`);
123
+ const consented = testMode || await requestRemoteAnalysisConsent();
124
+ if (!consented) {
125
+ console.log(`\n${muted}Nothing was sent or published.${reset}\n`);
126
+ return;
127
+ }
128
+ progress.start("Reading selected sessions locally", `0/${chosenSessions.length}`);
129
+ const sessionRecords = [];
130
+ let bytesRead = 0;
131
+ for (let index = 0; index < chosenSessions.length; index++) {
132
+ const publicSession = chosenSessions[index];
133
+ const session = catalog.index.get(publicSession.id);
134
+ sessionRecords.push({ sessionId: session.id, agent: session.agent, records: await readRecordsAsync(session.file, session.agent) });
135
+ bytesRead += publicSession.sizeBytes || 0;
136
+ progress.update(`${index + 1}/${chosenSessions.length} · ${(bytesRead / 1_000_000).toFixed(1)} MB`);
137
+ }
138
+ progress.succeed(`Read ${chosenSessions.length} sessions locally`);
139
+ progress.start("Preparing privacy-safe analysis", "favorite-phrase candidates");
140
+ const candidates = buildPhraseCandidates(sessionRecords, { maximumCandidates: 100 });
141
+ let interactionCandidates = [];
142
+ let sessionTopicBundle = null;
143
+ let workaroundBundle = null;
144
+ if (!testMode) {
145
+ progress.update("interaction candidates");
146
+ interactionCandidates = buildInteractionToneCandidates(sessionRecords);
147
+ progress.update("usage-topic candidates");
148
+ sessionTopicBundle = buildSessionTopicCandidates(sessionRecords);
149
+ progress.update("redacted workaround windows");
150
+ workaroundBundle = buildWorkaroundTrajectories(sessionRecords);
151
+ }
152
+ progress.update("deterministic usage statistics");
153
+ const analyzed = analyzeSessions(sessionRecords);
154
+ progress.succeed("Local analysis prepared");
155
+ const analysisWarnings = [];
156
+ let phraseCard;
157
+ let interactionTone = null;
158
+ let sessionTopics = null;
159
+ let workarounds;
160
+ if (testMode) {
161
+ progress.start("Building local test report", "all LLM calls disabled");
162
+ phraseCard = buildLocalPhraseCard(candidates);
163
+ workarounds = emptyWorkaroundJudgment();
164
+ progress.succeed("Local test fallbacks ready; no LLM calls made");
165
+ } else {
166
+ const judgeStates = { phrase: "waiting", tone: "waiting", topics: "waiting", workarounds: "waiting" };
167
+ const judgeStatus = (state) => state === "waiting" ? "queued" : state === "working" ? "…" : state === "done" ? "✓" : state === "failed" ? "skipped" : state;
168
+ const judgeSummary = () => Object.entries(judgeStates).map(([name, state]) => `${name} ${judgeStatus(state)}`).join(" · ");
169
+ const trackJudge = (name, promise) => {
170
+ judgeStates[name] = "working";
171
+ progress.update(judgeSummary());
172
+ return promise.then((value) => {
173
+ judgeStates[name] = "done";
174
+ progress.update(judgeSummary());
175
+ return value;
176
+ }, (error) => {
177
+ judgeStates[name] = "failed";
178
+ progress.update(judgeSummary());
179
+ throw error;
180
+ });
181
+ };
182
+ progress.start("Running privacy-safe AI analysis", judgeSummary());
183
+ const phraseCardPromise = process.env.BEHAVIOR_WRAPPED_DIRECT_OPENROUTER === "1"
184
+ ? judgePhraseCard(candidates, process.env.OPENROUTER_API_KEY)
185
+ : judgePhraseCardViaRelay(candidates, { endpoint: process.env.BEHAVIOR_WRAPPED_JUDGE_URL || PHRASE_JUDGE_RELAY_URL, clientId: getOrCreateClientId() });
186
+ const interactionTonePromise = interactionCandidates.length
187
+ ? process.env.BEHAVIOR_WRAPPED_DIRECT_OPENROUTER === "1"
188
+ ? judgeInteractionTone(interactionCandidates, process.env.OPENROUTER_API_KEY)
189
+ : judgeInteractionToneViaRelay(interactionCandidates, { endpoint: process.env.BEHAVIOR_WRAPPED_INTERACTION_TONE_URL || INTERACTION_TONE_RELAY_URL, clientId: getOrCreateClientId() })
190
+ : Promise.resolve(emptyInteractionToneJudgment());
191
+ const sessionTopicsPromise = sessionTopicBundle.candidates.length
192
+ ? process.env.BEHAVIOR_WRAPPED_DIRECT_OPENROUTER === "1"
193
+ ? judgeSessionTopics(sessionTopicBundle, process.env.OPENROUTER_API_KEY)
194
+ : judgeSessionTopicsViaRelay(sessionTopicBundle, { endpoint: process.env.BEHAVIOR_WRAPPED_SESSION_TOPICS_URL || SESSION_TOPIC_RELAY_URL, clientId: getOrCreateClientId() })
195
+ : Promise.resolve(emptySessionTopicJudgment(sessionTopicBundle));
196
+ const workaroundProgress = ({ index, total }) => {
197
+ judgeStates.workarounds = `batch ${index}/${total}`;
198
+ progress.update(judgeSummary());
199
+ };
200
+ const workaroundsPromise = workaroundBundle.chunks.length
201
+ ? process.env.BEHAVIOR_WRAPPED_DIRECT_OPENROUTER === "1"
202
+ ? judgeWorkarounds(workaroundBundle, process.env.OPENROUTER_API_KEY, { onProgress: workaroundProgress })
203
+ : judgeWorkaroundsViaRelay(workaroundBundle, { endpoint: process.env.BEHAVIOR_WRAPPED_WORKAROUND_URL || WORKAROUND_RELAY_URL, clientId: getOrCreateClientId(), onProgress: workaroundProgress })
204
+ : Promise.resolve(emptyWorkaroundJudgment(workaroundBundle.coverage));
205
+ [phraseCard, interactionTone, sessionTopics, workarounds] = await Promise.all([
206
+ trackJudge("phrase", phraseCardPromise),
207
+ optionalAnalysis(trackJudge("tone", interactionTonePromise), "interaction card", analysisWarnings),
208
+ optionalAnalysis(trackJudge("topics", sessionTopicsPromise), "usage-topic card", analysisWarnings),
209
+ optionalAnalysis(trackJudge("workarounds", workaroundsPromise), "instrumental-workaround card", analysisWarnings),
210
+ ]);
211
+ progress.succeed("Privacy-safe AI analysis complete");
212
+ }
213
+ analyzed.phraseCard = phraseCard;
214
+ if (!testMode) {
215
+ if (interactionTone) applyInteractionToneJudgment(analyzed, interactionTone);
216
+ else delete analyzed.stats.interactionTone;
217
+ if (sessionTopics) applySessionTopicJudgment(analyzed, sessionTopics);
218
+ else analyzed.stats.topics = [];
219
+ }
220
+ applyWorkaroundJudgment(analyzed, workarounds);
221
+ for (const warning of analysisWarnings) {
222
+ console.log(`◇ ${muted}Skipped the ${warning.label}; the judge request failed or its response could not be validated.${reset} `);
223
+ printJudgeDebug(warning.label, warning.error);
224
+ }
225
+ const id = createReportId();
226
+ const safeFindings = analyzed.findings.map(({ evidence, method, ...finding }) => finding);
227
+ const hasPrivateWorkaroundEvidence = Boolean(analyzed.workaroundReview?.occurrences?.length || analyzed.workaroundReview?.borderline?.length);
228
+ const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: "Claude Code + Codex", stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !testMode, ...(testMode ? { transmittedData: "None; test mode stays local.", externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, NVIDIA, and public report hosting" }) } };
229
+ let publicUrl = null;
230
+ if (!testMode) {
231
+ progress.start("Publishing share-safe Wrapped", "strict aggregate-only schema");
232
+ try {
233
+ const published = await publishPublicReport(report, { clientId: getOrCreateClientId(), origin: process.env.BEHAVIOR_WRAPPED_PUBLIC_URL || PUBLIC_REPORT_ORIGIN });
234
+ publicUrl = published.public_url;
235
+ report.publicUrl = publicUrl;
236
+ report.managementUrl = published.management_url;
237
+ progress.succeed("Public Wrapped published");
238
+ } catch (error) {
239
+ report.publicHostingError = error.message;
240
+ progress.succeed("Public hosting unavailable; local fallback ready");
241
+ }
242
+ }
243
+ saveReport(report);
244
+ progress.start("Starting local donation helper", `127.0.0.1:${port}`);
245
+ await ensureServer(demo);
246
+ progress.succeed("Local donation helper ready");
247
+ const localUrl = `${baseUrl}/w/${id}`;
248
+ const url = testMode ? localUrl : report.managementUrl || publicUrl || localUrl;
249
+ const tokenLabel = formatNumber(report.stats.tokens || 0);
250
+ console.log(`◇ ${bright}Wrapped ready${reset} · ${tokenLabel} tokens across ${report.stats.sessions} sessions `);
251
+ if (report.phraseCard) console.log(`◇ ${testMode ? "Local test pick" : `${PHRASE_JUDGE_NAME}'s pick`} · “${report.phraseCard.phrase}” × ${report.phraseCard.occurrences}${testMode ? "" : ` · ${(report.phraseCard.latencyMs / 1000).toFixed(1)}s`} `);
252
+ console.log(`│\n◇ Your wrapped is ${publicUrl ? "live" : "ready locally"} ───────────────────────────────╮`);
253
+ console.log(`│ │`);
254
+ console.log(`│ ${purple}${bright}${publicUrl || localUrl}${reset}`);
255
+ console.log(`│ │`);
256
+ console.log(`│ ${tokenLabel} tokens · ${report.stats.toolCalls} tool calls · ${report.stats.sessions} sessions`);
257
+ console.log(`│ │`);
258
+ console.log(`├────────────────────────────────────────────────────────╯`);
259
+ if (testMode) console.log(`│ ${muted}Test mode: no LLM calls and no public report upload.${reset}`);
260
+ else if (!publicUrl) console.log(`│ ${muted}Public hosting unavailable; your local report still works.${reset}`);
261
+ console.log(`│\n└ ${muted}saved to ${storeRoot} · behavior-wrapped list / delete <id>${reset}\n`);
262
+ openUrl(url);
263
+ }
264
+
265
+ try {
266
+ if (command === "list") printList();
267
+ else if (command === "open") await openSaved(process.argv[3]);
268
+ else if (command === "delete") {
269
+ const id = process.argv[3];
270
+ if (!id) throw new Error("Usage: behavior-wrapped delete <id>");
271
+ const report = listReports().find((item) => item.id === id);
272
+ if (report?.publicUrl) {
273
+ try { await deletePublicReport(id, { clientId: getOrCreateClientId(), origin: process.env.BEHAVIOR_WRAPPED_PUBLIC_URL || PUBLIC_REPORT_ORIGIN }); }
274
+ catch (error) { throw new Error(`The public copy could not be removed, so the local management record was kept. ${error.message}`); }
275
+ }
276
+ const deleted = deleteReport(id);
277
+ console.log(deleted ? `Deleted report ${id}.` : "That saved report was not found.");
278
+ } else if (command === "help" || command === "--help" || command === "-h") {
279
+ console.log("behavior-wrapped [--demo] [--test|--no-llm] [--days=30] [--no-open] [--verbose|--debug]\nbehavior-wrapped list\nbehavior-wrapped open [id]\nbehavior-wrapped delete <id>");
280
+ } else await createWrapped();
281
+ } catch (error) {
282
+ progress.stop();
283
+ printJudgeDebug("required analysis", error);
284
+ console.error(`\n${bright}Could not create your Wrapped.${reset} ${error.message}\n`);
285
+ if (!verbose && error?.judgeDetails) console.error(`${muted}Rerun with --verbose for privacy-safe judge diagnostics.${reset}\n`);
286
+ process.exitCode = 1;
287
+ }
@@ -0,0 +1,19 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ const bright = "\x1b[1m";
4
+ const lime = "\x1b[38;2;201;242;75m";
5
+ const purple = "\x1b[38;2;141;92;255m";
6
+ const reset = "\x1b[0m";
7
+
8
+ export const remoteAnalysisConsentText = "Behavior Wrapped will send redacted excerpts from your session history to Nemotron 3 Ultra via OpenRouter for analysis. OK to proceed?";
9
+
10
+ export async function requestRemoteAnalysisConsent({ input = process.stdin, output = process.stdout } = {}) {
11
+ const prompt = createInterface({ input, output });
12
+ try {
13
+ const question = `${lime}◇${reset} Behavior Wrapped will send redacted excerpts from your session history to ${purple}${bright}Nemotron 3 Ultra${reset} via OpenRouter for analysis. OK to proceed? ${bright}(Y/n)${reset} `;
14
+ const answer = await prompt.question(question);
15
+ return /^(?:|y|yes)$/i.test(answer.trim());
16
+ } finally {
17
+ prompt.close();
18
+ }
19
+ }