behavior-wrapped 0.6.0 → 0.7.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-DO52v2Jm.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-a017khge.css">
9
+ <script type="module" crossorigin src="/assets/index-CtRLyktk.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-hFREdA4j.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.6.0",
3
+ "version": "0.7.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",
package/server/cli.mjs CHANGED
@@ -248,6 +248,7 @@ async function createWrapped() {
248
248
  delete analyzed.stats.topics;
249
249
  delete analyzed.stats.topicMethod;
250
250
  delete analyzed.interactionCard;
251
+ delete analyzed.interactionReview;
251
252
  delete analyzed.workaroundCard;
252
253
  delete analyzed.workaroundReview;
253
254
  }
@@ -258,7 +259,7 @@ async function createWrapped() {
258
259
  const id = createReportId();
259
260
  const safeFindings = analyzed.findings.map(({ evidence, method, ...finding }) => finding);
260
261
  const hasPrivateWorkaroundEvidence = Boolean(analyzed.workaroundReview?.occurrences?.length || analyzed.workaroundReview?.borderline?.length);
261
- const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: formatAgentSource(chosenSessions), stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !localOnly, analysisMode: localOnly ? "local-only" : "remote", leaderboardParticipation: localOnly ? "excluded" : "included-by-default", ...(localOnly ? { transmittedData: `None; ${testMode ? "test" : "local-only"} mode stays on this device.`, 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, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
262
+ const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: formatAgentSource(chosenSessions), stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, interactionReview: analyzed.interactionReview, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !localOnly, analysisMode: localOnly ? "local-only" : "remote", leaderboardParticipation: localOnly ? "excluded" : "included-by-default", ...(localOnly ? { transmittedData: `None; ${testMode ? "test" : "local-only"} mode stays on this device.`, 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, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
262
263
  let publicUrl = null;
263
264
  if (!localOnly) {
264
265
  progress.start("Publishing share-safe Wrapped", "strict aggregate-only schema");
@@ -0,0 +1,77 @@
1
+ const MAX_OCCURRENCES_PER_KIND = 100;
2
+
3
+ function contentBlocks(record) {
4
+ const content = record?.message?.content ?? record?.content;
5
+ if (Array.isArray(content)) return content;
6
+ if (typeof content === "string") return [{ type: "text", text: content }];
7
+ return [];
8
+ }
9
+
10
+ function visibleText(record) {
11
+ return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
12
+ }
13
+
14
+ function transcriptRole(record) {
15
+ return record?.type === "assistant" ? "assistant" : record?.type === "user" && !record?.isMeta ? "user" : null;
16
+ }
17
+
18
+ function matchingRecordIndex(reference, records) {
19
+ const index = reference?.location?.recordIndex;
20
+ if (Number.isInteger(index) && index >= 0 && index < records.length) return index;
21
+ const timestamp = reference?.location?.timestamp;
22
+ if (!timestamp) return null;
23
+ const fallback = records.findIndex((record) => record?.timestamp === timestamp && record?.type === "user" && !record?.isMeta);
24
+ return fallback >= 0 ? fallback : null;
25
+ }
26
+
27
+ function adjacentMessage(records, fromIndex, direction) {
28
+ for (let index = fromIndex + direction; index >= 0 && index < records.length; index += direction) {
29
+ const role = transcriptRole(records[index]);
30
+ const text = visibleText(records[index]);
31
+ if (role && text) return { role, text, timestamp: records[index]?.timestamp || null, highlighted: false };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function exactOccurrence(reference, index, records, metadata) {
37
+ const recordIndex = matchingRecordIndex(reference, records);
38
+ if (recordIndex === null) return null;
39
+ const record = records[recordIndex];
40
+ const text = visibleText(record);
41
+ if (record?.type !== "user" || record?.isMeta || !text) return null;
42
+ const before = adjacentMessage(records, recordIndex, -1);
43
+ const after = adjacentMessage(records, recordIndex, 1);
44
+ return {
45
+ index: index + 1,
46
+ candidateId: reference.candidateId,
47
+ session: {
48
+ label: metadata?.label || `Session ${index + 1}`,
49
+ agentName: metadata?.agentName || "AI agent",
50
+ startedAt: metadata?.startedAt || records.find((item) => item?.timestamp)?.timestamp || null,
51
+ },
52
+ timestamp: record.timestamp || null,
53
+ messages: [before, { role: "user", text, timestamp: record.timestamp || null, highlighted: true }, after].filter(Boolean),
54
+ };
55
+ }
56
+
57
+ function buildKind(report, kind, recordsById, metadataById) {
58
+ return (report?.interactionReview?.[kind] || []).slice(0, MAX_OCCURRENCES_PER_KIND).flatMap((reference, index) => {
59
+ const sessionId = reference?.location?.sessionId;
60
+ const records = recordsById.get(sessionId);
61
+ if (!records) return [];
62
+ const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId));
63
+ return occurrence ? [occurrence] : [];
64
+ });
65
+ }
66
+
67
+ export function makeInteractionEvidencePreview(report, sessionRecords, metadataById) {
68
+ const recordsById = new Map(sessionRecords.map((session) => [session.sessionId, session.records]));
69
+ return {
70
+ format: "behavior-wrapped-interaction-evidence-v1",
71
+ localPrivate: true,
72
+ standardRedactionsApplied: false,
73
+ reportId: report?.id,
74
+ frustrated: buildKind(report, "frustrated", recordsById, metadataById),
75
+ grateful: buildKind(report, "grateful", recordsById, metadataById),
76
+ };
77
+ }
@@ -69,8 +69,8 @@ function priority(text, occurrences) {
69
69
  export function buildInteractionToneCandidates(sessionRecords, { maximumCandidates = INTERACTION_TONE_MAX_CANDIDATES } = {}) {
70
70
  const messages = new Map();
71
71
  let order = 0;
72
- for (const { records } of sessionRecords) {
73
- for (const record of records) {
72
+ for (const { sessionId, records } of sessionRecords) {
73
+ for (const [recordIndex, record] of records.entries()) {
74
74
  if (record.type !== "user" || record.isMeta) continue;
75
75
  const text = safeInteractionExcerpt(visibleText(record));
76
76
  if (!text) continue;
@@ -78,15 +78,23 @@ export function buildInteractionToneCandidates(sessionRecords, { maximumCandidat
78
78
  if (!likelyTonePattern.test(text) && words > 40 && !/[!?]{2,}/.test(text)) continue;
79
79
  const key = text.normalize("NFKC").toLocaleLowerCase();
80
80
  const existing = messages.get(key);
81
- if (existing) existing.occurrences++;
82
- else messages.set(key, { text, occurrences: 1, order: order++ });
81
+ const location = { sessionId, recordIndex, timestamp: record.timestamp || null };
82
+ if (existing) {
83
+ existing.occurrences++;
84
+ existing.locations.push(location);
85
+ } else messages.set(key, { text, occurrences: 1, order: order++, locations: [location] });
83
86
  }
84
87
  }
85
88
  return [...messages.values()]
86
89
  .sort((left, right) => priority(right.text, right.occurrences) - priority(left.text, left.occurrences)
87
90
  || right.occurrences - left.occurrences || left.order - right.order)
88
91
  .slice(0, Math.min(INTERACTION_TONE_MAX_CANDIDATES, maximumCandidates))
89
- .map(({ text, occurrences }, index) => ({ candidate_id: `interaction-${index + 1}`, text, occurrences }));
92
+ .map(({ text, occurrences, locations }, index) => {
93
+ const candidate = { candidate_id: `interaction-${index + 1}`, text, occurrences };
94
+ // Local transcript locations must survive judging without entering the relay payload.
95
+ Object.defineProperty(candidate, "locations", { value: locations, enumerable: false });
96
+ return candidate;
97
+ });
90
98
  }
91
99
 
92
100
  export const interactionToneJudgePrompt = `You classify how a user speaks to an AI agent for a playful "Behavior Wrapped" report. Evaluate every supplied excerpt independently.
@@ -222,6 +230,10 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
222
230
  occurrences: byId.get(item.candidate_id).occurrences,
223
231
  confidence: item.confidence,
224
232
  }));
233
+ const reviewRefs = (items) => items.flatMap((item) => (byId.get(item.candidate_id).locations || []).map((location) => ({
234
+ candidateId: item.candidate_id,
235
+ location,
236
+ })));
225
237
  return {
226
238
  frustratedMessages: count(selection.frustrated),
227
239
  gratefulMessages: count(selection.grateful),
@@ -230,6 +242,11 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
230
242
  candidateMessages: candidates.reduce((sum, candidate) => sum + candidate.occurrences, 0),
231
243
  frustrationQuote: funniest?.text || null,
232
244
  privateMatches: { frustrated: matches(selection.frustrated), grateful: matches(selection.grateful) },
245
+ review: {
246
+ format: "behavior-wrapped-interaction-review-v1",
247
+ frustrated: reviewRefs(selection.frustrated),
248
+ grateful: reviewRefs(selection.grateful),
249
+ },
233
250
  model,
234
251
  provider,
235
252
  latencyMs,
@@ -325,6 +342,7 @@ export function applyInteractionToneJudgment(analyzed, judgment) {
325
342
  method: judgment.method,
326
343
  };
327
344
  analyzed.interactionCard = judgment.frustrationQuote ? { frustrationQuote: judgment.frustrationQuote } : null;
345
+ analyzed.interactionReview = judgment.review;
328
346
  return analyzed;
329
347
  }
330
348
 
@@ -9,6 +9,7 @@ import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadRe
9
9
  import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
10
10
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
11
11
  import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
12
+ import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
12
13
  import { createIdleShutdownController } from "./local-helper-runtime.mjs";
13
14
  import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
14
15
 
@@ -106,7 +107,7 @@ const server = http.createServer(async (request, response) => {
106
107
  if (request.method === "GET" && reportMatch) {
107
108
  const report = loadReport(reportMatch[1]);
108
109
  if (!report) return json(response, 404, { error: "Saved report not found" });
109
- const { sessionIds, workaroundReview, ...shareSafeReport } = report;
110
+ const { sessionIds, workaroundReview, interactionReview, ...shareSafeReport } = report;
110
111
  shareSafeReport.privacy = { ...shareSafeReport.privacy, shareSafe: true, containsTranscriptText: false };
111
112
  return json(response, 200, shareSafeReport);
112
113
  }
@@ -127,6 +128,17 @@ const server = http.createServer(async (request, response) => {
127
128
  const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
128
129
  return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
129
130
  }
131
+ const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
132
+ if (request.method === "GET" && interactionEvidenceMatch) {
133
+ const report = loadReport(interactionEvidenceMatch[1]);
134
+ if (!report) return json(response, 404, { error: "Saved report not found" });
135
+ const allowed = new Set(report.sessionIds || []);
136
+ const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || [])];
137
+ const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 200);
138
+ const records = await chosenRecords(ids);
139
+ const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
140
+ return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
141
+ }
130
142
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
131
143
  const body = await readBody(request);
132
144
  const report = loadReport(body.reportId);