behavior-wrapped 0.5.8 → 0.5.10

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-D0gP-HqY.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DyBPWLnC.css">
9
+ <script type="module" crossorigin src="/assets/index-DzSM-hPQ.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-COnYUt6B.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.5.8",
3
+ "version": "0.5.10",
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",
package/server/cli.mjs CHANGED
@@ -181,8 +181,9 @@ async function createWrapped() {
181
181
  progress.succeed(`${testMode ? "Local test fallbacks" : "Local-only analysis"} ready; no LLM calls made`);
182
182
  } else {
183
183
  const judgeStates = { phrase: "waiting", tone: "waiting", topics: "waiting", workarounds: "waiting" };
184
+ const judgeLabels = { phrase: "favorite phrase", tone: "agent interaction", topics: "topics", workarounds: "workarounds" };
184
185
  const judgeStatus = (state) => state === "waiting" ? "queued" : state === "working" ? "…" : state === "done" ? "✓" : state === "failed" ? "skipped" : state;
185
- const judgeSummary = () => Object.entries(judgeStates).map(([name, state]) => `${name} ${judgeStatus(state)}`).join(" · ");
186
+ const judgeSummary = () => Object.entries(judgeStates).map(([name, state]) => `${judgeLabels[name]} ${judgeStatus(state)}`).join(" · ");
186
187
  const trackJudge = (name, promise) => {
187
188
  judgeStates[name] = "working";
188
189
  progress.update(judgeSummary());
@@ -196,7 +197,7 @@ async function createWrapped() {
196
197
  throw error;
197
198
  });
198
199
  };
199
- progress.start("Running privacy-safe AI analysis", judgeSummary());
200
+ progress.start("Running redacted excerpts through LLM Judge", judgeSummary(), { separateDetail: true });
200
201
  const phraseCardPromise = process.env.BEHAVIOR_WRAPPED_DIRECT_OPENROUTER === "1"
201
202
  ? judgePhraseCard(candidates, process.env.OPENROUTER_API_KEY)
202
203
  : judgePhraseCardViaRelay(candidates, { endpoint: process.env.BEHAVIOR_WRAPPED_JUDGE_URL || PHRASE_JUDGE_RELAY_URL, clientId: getOrCreateClientId() });
@@ -225,7 +226,7 @@ async function createWrapped() {
225
226
  optionalAnalysis(trackJudge("topics", sessionTopicsPromise), "usage-topic card", analysisWarnings),
226
227
  optionalAnalysis(trackJudge("workarounds", workaroundsPromise), "instrumental-workaround card", analysisWarnings),
227
228
  ]);
228
- progress.succeed("Privacy-safe AI analysis complete");
229
+ progress.succeed("LLM Judge complete");
229
230
  }
230
231
  analyzed.phraseCard = phraseCard;
231
232
  if (!localOnly) {
@@ -106,7 +106,7 @@ function trajectoryEvents(records, agent, trajectoryId, sessionId, sessionIndex)
106
106
  const events = [];
107
107
  let currentModel = agent === "codex" ? "Codex model" : "Claude model";
108
108
  let sourceIndex = 0;
109
- const add = (role, kind, text, timestamp, action = null, method = null) => {
109
+ const add = (role, kind, text, timestamp, action = null, method = null, recordIndex = null) => {
110
110
  events.push({
111
111
  event_id: `${trajectoryId}-event-${events.length + 1}`,
112
112
  role,
@@ -116,27 +116,29 @@ function trajectoryEvents(records, agent, trajectoryId, sessionId, sessionIndex)
116
116
  model: currentModel,
117
117
  action,
118
118
  method,
119
+ recordIndex,
119
120
  sourceIndex: sourceIndex++,
120
121
  sessionIndex,
121
122
  sessionId,
122
123
  });
123
124
  };
124
- for (const record of records) {
125
+ for (let recordIndex = 0; recordIndex < records.length; recordIndex++) {
126
+ const record = records[recordIndex];
125
127
  if (typeof record?.message?.model === "string" && record.message.model !== "<synthetic>") currentModel = record.message.model;
126
128
  const role = record.type === "assistant" ? "assistant" : record.type === "system" ? "system" : "user";
127
129
  const kind = record.type === "assistant" ? "assistant_text" : record.type === "system" ? "system_text" : "user_text";
128
130
  const text = visibleText(record);
129
131
  if (!record.isMeta && ["user", "assistant", "system"].includes(record.type)) {
130
132
  const parts = splitSafeText(text);
131
- if (parts.length) for (const part of parts) add(role, kind, part, record.timestamp);
132
- else if (text) add(role, "content_removed", "Message content removed locally", record.timestamp);
133
+ if (parts.length) for (const part of parts) add(role, kind, part, record.timestamp, null, null, recordIndex);
134
+ else if (text) add(role, "content_removed", "Message content removed locally", record.timestamp, null, null, recordIndex);
133
135
  }
134
136
  for (const block of contentBlocks(record)) {
135
137
  if (block?.type === "tool_use") {
136
138
  const { action, method } = semanticToolUse({ name: block.name, inputValue: block.input, actionHint: block.action_hint, methodHint: block.method_hint });
137
- add("assistant", "tool_use", `Tool use: ${safeToolName(block.name)}${action ? ` (${action})` : ""}`, record.timestamp, action, method);
139
+ add("assistant", "tool_use", `Tool use: ${safeToolName(block.name)}${action ? ` (${action})` : ""}`, record.timestamp, action, method, recordIndex);
138
140
  } else if (block?.type === "tool_result") {
139
- add("tool", "tool_result", `Tool result: ${toolResultSummary(block)}`, record.timestamp);
141
+ add("tool", "tool_result", `Tool result: ${toolResultSummary(block)}`, record.timestamp, null, null, recordIndex);
140
142
  }
141
143
  }
142
144
  }
@@ -466,7 +468,7 @@ function resultFromSelections(bundle, selections, { model, provider, latencyMs,
466
468
  confidence: item.confidence,
467
469
  model: displayModelName(alternative.model),
468
470
  evidence: [original, blocker, alternative].map(({ role, kind, text, timestamp }) => ({ role, kind, text, timestamp })),
469
- location: { sessionId: trajectory.sessionId, trajectoryId: item.trajectory_id, blockerEventId: item.blocker_event_id, originalMethodEventId: item.original_method_event_id, alternativeMethodEventId: item.alternative_method_event_id },
471
+ location: { sessionId: trajectory.sessionId, trajectoryId: item.trajectory_id, blockerEventId: item.blocker_event_id, originalMethodEventId: item.original_method_event_id, alternativeMethodEventId: item.alternative_method_event_id, originalRecordIndex: original.recordIndex, blockerRecordIndex: blocker.recordIndex, alternativeRecordIndex: alternative.recordIndex },
470
472
  };
471
473
  };
472
474
  const confirmedItems = selections.flatMap((selection) => selection.confirmed);
@@ -10,6 +10,7 @@ import { makeDonationPreview } from "./analysis.mjs";
10
10
  import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
11
11
  import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
12
12
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
13
+ import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
13
14
 
14
15
  const here = path.dirname(fileURLToPath(import.meta.url));
15
16
  const root = path.dirname(here);
@@ -111,6 +112,16 @@ const server = http.createServer(async (request, response) => {
111
112
  const available = new Set(catalog.sessions.map((session) => session.id));
112
113
  return json(response, 200, { sessionIds: (report.sessionIds || []).filter((id) => available.has(id)), localPrivateSelection: true });
113
114
  }
115
+ const workaroundEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/workarounds$/);
116
+ if (request.method === "GET" && workaroundEvidenceMatch) {
117
+ const report = loadReport(workaroundEvidenceMatch[1]);
118
+ if (!report) return json(response, 404, { error: "Saved report not found" });
119
+ const allowed = new Set(report.sessionIds || []);
120
+ const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 100);
121
+ const records = await chosenRecords(ids);
122
+ const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
123
+ return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
124
+ }
114
125
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
115
126
  const body = await readBody(request);
116
127
  const report = loadReport(body.reportId);
@@ -19,6 +19,7 @@ export function createCliProgress({
19
19
  let label = "";
20
20
  let detail = "";
21
21
  let startedAt = 0;
22
+ let separateDetail = false;
22
23
 
23
24
  function line(symbol) {
24
25
  const extra = detail ? ` · ${detail}` : "";
@@ -27,7 +28,19 @@ export function createCliProgress({
27
28
 
28
29
  function render() {
29
30
  if (!timer) return;
30
- output.write(`\r\x1b[2K${line(spinnerFrames[frame++ % spinnerFrames.length])}`);
31
+ clearLine();
32
+ output.write(separateDetail
33
+ ? `${spinnerFrames[frame++ % spinnerFrames.length]} ${detail}`
34
+ : line(spinnerFrames[frame++ % spinnerFrames.length]));
35
+ }
36
+
37
+ function clearLine() {
38
+ if (typeof output.clearLine === "function" && typeof output.cursorTo === "function") {
39
+ output.clearLine(0);
40
+ output.cursorTo(0);
41
+ return;
42
+ }
43
+ output.write("\r\x1b[2K");
31
44
  }
32
45
 
33
46
  function stopTimer() {
@@ -37,16 +50,18 @@ export function createCliProgress({
37
50
  }
38
51
 
39
52
  return {
40
- start(nextLabel, nextDetail = "") {
53
+ start(nextLabel, nextDetail = "", options = {}) {
41
54
  stopTimer();
42
55
  label = nextLabel;
43
56
  detail = nextDetail;
57
+ separateDetail = Boolean(options.separateDetail && detail);
44
58
  startedAt = now();
45
59
  frame = 0;
46
60
  if (!interactive) {
47
- output.write(`◇ ${label}${detail ? ` · ${detail}` : ""}\n`);
61
+ output.write(separateDetail ? `◇ ${label}\n◇ ${detail}\n` : `◇ ${label}${detail ? ` · ${detail}` : ""}\n`);
48
62
  return;
49
63
  }
64
+ if (separateDetail) output.write(`◇ ${label}\n`);
50
65
  timer = setIntervalImpl(render, intervalMs);
51
66
  timer?.unref?.();
52
67
  render();
@@ -59,11 +74,12 @@ export function createCliProgress({
59
74
  succeed(summary = label) {
60
75
  const elapsed = elapsedLabel(now() - startedAt);
61
76
  stopTimer();
62
- output.write(interactive ? `\r\x1b[2K✓ ${summary} · ${elapsed}\n` : `✓ ${summary} · ${elapsed}\n`);
77
+ if (interactive) clearLine();
78
+ output.write(`✓ ${summary} · ${elapsed}\n`);
63
79
  },
64
80
  stop() {
65
81
  stopTimer();
66
- if (interactive) output.write("\r\x1b[2K");
82
+ if (interactive) clearLine();
67
83
  },
68
84
  };
69
85
  }
@@ -0,0 +1,125 @@
1
+ import { makeDonationPreview } from "./analysis.mjs";
2
+ import { redactText } from "./privacy.mjs";
3
+
4
+ const DEFAULT_CONTEXT_TURNS = 2;
5
+ const MAX_OCCURRENCES = 100;
6
+
7
+ function finiteRecordIndex(value, length) {
8
+ return Number.isInteger(value) && value >= 0 && value < length ? value : null;
9
+ }
10
+
11
+ function contentBlocks(record) {
12
+ const content = record?.message?.content ?? record?.content;
13
+ if (Array.isArray(content)) return content;
14
+ if (typeof content === "string") return [{ type: "text", text: content }];
15
+ return [];
16
+ }
17
+
18
+ function visibleText(record) {
19
+ return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
20
+ }
21
+
22
+ function toolResultText(record) {
23
+ return contentBlocks(record).filter((block) => block?.type === "tool_result").map((block) => {
24
+ const content = typeof block.content === "string"
25
+ ? block.content
26
+ : Array.isArray(block.content)
27
+ ? block.content.map((part) => typeof part === "string" ? part : part?.text || "").filter(Boolean).join("\n")
28
+ : "";
29
+ return [block.error_summary, content].filter(Boolean).join("\n");
30
+ }).filter(Boolean).join("\n").trim();
31
+ }
32
+
33
+ function toolUseText(record) {
34
+ return contentBlocks(record).filter((block) => block?.type === "tool_use").map((block) => {
35
+ const input = block.input && typeof block.input === "object" ? JSON.stringify(block.input, null, 2) : String(block.input || "").trim();
36
+ return [`${String(block.name || "Tool")} tool call`, input].filter(Boolean).join("\n");
37
+ }).filter(Boolean).join("\n\n").trim();
38
+ }
39
+
40
+ function locallyRedacted(value) {
41
+ return redactText(String(value || ""), [], { includeHeuristicSecrets: false }).text.trim();
42
+ }
43
+
44
+ function matchingEvidenceText(occurrence, kind) {
45
+ return String((occurrence?.evidence || []).find((event) => event?.kind === kind)?.text || "").trim();
46
+ }
47
+
48
+ function fallbackRecordIndex(occurrence, records, kind) {
49
+ const kinds = new Set(Array.isArray(kind) ? kind : kind ? [kind] : []);
50
+ const timestamps = new Set((occurrence?.evidence || []).filter((event) => !kinds.size || kinds.has(event?.kind)).map((event) => event?.timestamp).filter(Boolean));
51
+ const index = records.findIndex((record) => timestamps.has(record?.timestamp));
52
+ return index >= 0 ? index : null;
53
+ }
54
+
55
+ function occurrenceRecordIndex(occurrence, records, field, kind) {
56
+ return finiteRecordIndex(occurrence?.location?.[field], records.length) ?? fallbackRecordIndex(occurrence, records, kind);
57
+ }
58
+
59
+ function transcriptMessage(record, kind = "context") {
60
+ const text = locallyRedacted(visibleText(record));
61
+ if (!text) return null;
62
+ return { role: record.type, timestamp: record.timestamp || null, text, kind };
63
+ }
64
+
65
+ function contextAroundBlocker(records, blockerIndex, alternativeIndex, contextTurns, blockerText) {
66
+ if (blockerIndex === null) return [];
67
+ const conversationIndexes = records.flatMap((record, index) => !record?.isMeta && (record?.type === "user" || record?.type === "assistant") && visibleText(record) ? [index] : []);
68
+ const selectedIndexes = [
69
+ ...conversationIndexes.filter((index) => index < blockerIndex).slice(-contextTurns),
70
+ blockerIndex,
71
+ ...conversationIndexes.filter((index) => index > blockerIndex).slice(0, contextTurns),
72
+ ];
73
+ return selectedIndexes.flatMap((recordIndex) => {
74
+ if (recordIndex === blockerIndex) return [{ role: "tool", timestamp: records[recordIndex]?.timestamp || null, text: blockerText, kind: "blocker" }];
75
+ const message = transcriptMessage(records[recordIndex], recordIndex === alternativeIndex ? "workaround" : "context");
76
+ return message ? [message] : [];
77
+ });
78
+ }
79
+
80
+ function safeFallbackMessages(occurrence) {
81
+ return (occurrence?.evidence || []).flatMap((event) => {
82
+ if (!["user", "assistant", "tool"].includes(event?.role)) return [];
83
+ const text = locallyRedacted(event?.text);
84
+ return text ? [{ role: event.role, timestamp: event.timestamp || null, text, kind: event.kind === "tool_result" ? "blocker" : "context" }] : [];
85
+ });
86
+ }
87
+
88
+ export function makeWorkaroundEvidencePreview(report, sessionRecords, metadataById, { contextTurns = DEFAULT_CONTEXT_TURNS } = {}) {
89
+ const boundedContext = Number.isInteger(contextTurns) ? Math.min(4, Math.max(1, contextTurns)) : DEFAULT_CONTEXT_TURNS;
90
+ const recordsById = new Map(sessionRecords.map((session) => [session.sessionId, session.records]));
91
+ const occurrences = (report?.workaroundReview?.occurrences || []).slice(0, MAX_OCCURRENCES).flatMap((occurrence, index) => {
92
+ const sessionId = occurrence?.location?.sessionId;
93
+ const records = recordsById.get(sessionId);
94
+ if (!records) return [];
95
+ const metadata = metadataById.get(sessionId);
96
+ const donationSession = makeDonationPreview([{ sessionId, records }], metadataById).sessions[0];
97
+ const blockerIndex = occurrenceRecordIndex(occurrence, records, "blockerRecordIndex", "tool_result");
98
+ const alternativeIndex = occurrenceRecordIndex(occurrence, records, "alternativeRecordIndex", ["assistant_text", "tool_use"]);
99
+ const blockerRecord = blockerIndex === null ? null : records[blockerIndex];
100
+ const alternativeRecord = alternativeIndex === null ? null : records[alternativeIndex];
101
+ const blockerText = locallyRedacted(toolResultText(blockerRecord) || matchingEvidenceText(occurrence, "tool_result") || occurrence.blocker || "The original method was blocked.");
102
+ const workaroundText = locallyRedacted(visibleText(alternativeRecord) || occurrence.alternativeMethod || toolUseText(alternativeRecord) || matchingEvidenceText(occurrence, "assistant_text") || "The agent tried another method.");
103
+ const context = contextAroundBlocker(records, blockerIndex, alternativeIndex, boundedContext, blockerText);
104
+ return [{
105
+ index: index + 1,
106
+ session: {
107
+ label: metadata?.label || `Session ${index + 1}`,
108
+ agentName: metadata?.agentName || "AI agent",
109
+ startedAt: metadata?.startedAt || records.find((record) => record?.timestamp)?.timestamp || null,
110
+ openingMessage: donationSession?.summary || "Opening message unavailable",
111
+ },
112
+ workaroundAction: { text: workaroundText, timestamp: alternativeRecord?.timestamp || null },
113
+ blocker: { text: blockerText, timestamp: blockerRecord?.timestamp || null },
114
+ context: context.length ? context : safeFallbackMessages(occurrence),
115
+ contextTurns: boundedContext,
116
+ }];
117
+ });
118
+ return {
119
+ format: "behavior-wrapped-workaround-evidence-v2",
120
+ localPrivate: true,
121
+ standardRedactionsApplied: true,
122
+ reportId: report?.id,
123
+ occurrences,
124
+ };
125
+ }