behavior-wrapped 0.9.1 → 0.10.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-ajQ5w6T5.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-ddSuXgMW.css">
9
+ <script type="module" crossorigin src="/assets/index-D2k7FNoP.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CcqvXEU2.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/docs/privacy.md CHANGED
@@ -26,7 +26,11 @@ Local-only reports omit AI-judged interaction tone, topics, and workarounds. The
26
26
 
27
27
  ## Private evidence
28
28
 
29
- Confirmed workaround cards can link to a localhost-only evidence page. The local helper reconstructs a short redacted excerpt around the original method, blocker, and alternative from the saved session files, with up to two surrounding conversation turns on each side. These excerpts do not enter the public report, share-card export, or remote payload merely by opening the page.
29
+ Confirmed workaround cards can link to a localhost-only evidence page. Interaction-tone cards can likewise show the exact locally reconstructed yelling, thanking, and apology excerpts. These excerpts do not enter the public report, share-card export, or remote payload merely by opening the page.
30
+
31
+ Yelling and thanking occurrences include an optional **Is this inaccurate?** path. It resolves the occurrence to its original session on localhost and opens the normal research-donation review restricted to that one session. The user chooses a corrected label, can edit or exclude messages and customize redactions, then provides purpose-specific consent. Clicking the feedback link alone transmits nothing.
32
+
33
+ Accepted classifier feedback receives the same local encryption, private ciphertext storage, deletion receipt, and deletion controls as an ordinary research donation. Its encrypted contents include the reviewed session, original verdict, correction, judged excerpt, judge version, and optional note. Public reports, leaderboard payloads, share-card exports, and operational notifications never include this material.
30
34
 
31
35
  ## Boundaries
32
36
 
@@ -2,11 +2,13 @@
2
2
 
3
3
  Research donation is optional and separate from creating or publishing a Wrapped report. No donation data is transmitted until the user selects a mode, reviews the resulting material, checks the final research-consent box, and presses **Donate**.
4
4
 
5
+ Classifier feedback uses this same donation tier. From a private yelling or thanking occurrence, the review is locked to the single source session and includes a corrected label plus an optional explanation. The purpose-specific consent states that the reviewed session will be used for research and to evaluate and improve Behavior Wrapped. Opening the review or marking a correction does not itself transmit data.
6
+
5
7
  The localhost helper can construct a standard-redacted preview, a customizable redaction review, or a deliberately unredacted copy. Detailed modes let users exclude sessions and messages, edit text, and keep timestamps off by default. The unredacted path shows every included line and requires a separate warning and explicit acknowledgement that credentials and private details may be transmitted.
6
8
 
7
9
  Donation discovery, default redaction, preview, exclusions, editing, schema validation, compression, and authenticated AES-256-GCM encryption happen on localhost. Compression is applied before encryption so substantial reviewed transcripts can be transmitted without weakening confidentiality. Each donation receives a fresh content key, wrapped with a rotation-versioned RSA-OAEP public key. The private key is absent from the npm package, Worker, D1, and R2.
8
10
 
9
- The receiving Worker accepts only encrypted protocol-2 envelopes. A private R2 bucket stores ciphertext. A separate D1 database stores pseudonymous consent, size, count, encryption-key, and object-location metadata—never transcript text. No automatic retention policy is currently configured. A locally retained deletion receipt lets the donor delete both records.
11
+ The receiving Worker accepts only encrypted protocol-2 envelopes. A private R2 bucket stores ciphertext, separating general donations and classifier-feedback donations by object prefix while applying the same access controls. A separate D1 database stores pseudonymous consent, size, count, encryption-key, and object-location metadata—never transcript text or classifier corrections. No automatic retention policy is currently configured. A locally retained deletion receipt lets the donor delete both records.
10
12
 
11
13
  ## Maintainer operations
12
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -36,12 +36,16 @@ export function sanitizeEncryptedDonationEnvelope(value) {
36
36
  if (typeof value.encryption.iv !== "string" || value.encryption.iv.length !== 16 || !base64url.test(value.encryption.iv)) return null;
37
37
  if (typeof value.encryption.authTag !== "string" || value.encryption.authTag.length !== 22 || !base64url.test(value.encryption.authTag)) return null;
38
38
  if (typeof value.ciphertext !== "string" || !value.ciphertext.length || !base64url.test(value.ciphertext)) return null;
39
- const baseMetadataKeys = ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"];
40
- const compressed = exactKeys(value.metadata, [...baseMetadataKeys, "contentEncoding"]);
41
- if (!compressed && !exactKeys(value.metadata, baseMetadataKeys)) return null;
39
+ const legacyMetadataKeys = ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"];
40
+ const baseMetadataKeys = [...legacyMetadataKeys, "purpose"];
41
+ const compressed = exactKeys(value.metadata, [...baseMetadataKeys, "contentEncoding"])
42
+ || exactKeys(value.metadata, [...legacyMetadataKeys, "contentEncoding"]);
43
+ if (!compressed && !exactKeys(value.metadata, baseMetadataKeys) && !exactKeys(value.metadata, legacyMetadataKeys)) return null;
42
44
  const metadata = value.metadata;
43
45
  if (compressed && metadata.contentEncoding !== DONATION_CONTENT_ENCODING) return null;
44
46
  if (!/^[A-Za-z0-9_-]{8,32}$/.test(metadata.reportId || "")) return null;
47
+ if ("purpose" in metadata && !new Set(["general_research", "classifier_feedback"]).has(metadata.purpose)) return null;
48
+ if (metadata.purpose === "classifier_feedback" && metadata.sessions !== 1) return null;
45
49
  if (!new Set(["standard", "custom", "unredacted"]).has(metadata.redactionMode)) return null;
46
50
  if (!timestamp.test(metadata.createdAt || "") || !timestamp.test(metadata.consentedAt || "")) return null;
47
51
  if (!new Set([1, DONATION_CONSENT_VERSION]).has(metadata.consentVersion) || typeof metadata.unredactedData !== "boolean" || metadata.unredactedData !== (metadata.redactionMode === "unredacted")) return null;
@@ -1,3 +1,5 @@
1
+ import { interactionFeedbackId } from "./interaction-feedback.mjs";
2
+
1
3
  const MAX_OCCURRENCES_PER_KIND = 100;
2
4
 
3
5
  function contentBlocks(record) {
@@ -33,7 +35,7 @@ function adjacentMessage(records, fromIndex, direction) {
33
35
  return null;
34
36
  }
35
37
 
36
- function exactOccurrence(reference, index, records, metadata, expectedRole) {
38
+ function exactOccurrence(reference, index, records, metadata, expectedRole, feedbackId = null) {
37
39
  const recordIndex = matchingRecordIndex(reference, records, expectedRole);
38
40
  if (recordIndex === null) return null;
39
41
  const record = records[recordIndex];
@@ -44,6 +46,7 @@ function exactOccurrence(reference, index, records, metadata, expectedRole) {
44
46
  return {
45
47
  index: index + 1,
46
48
  candidateId: reference.candidateId,
49
+ ...(feedbackId ? { feedbackId } : {}),
47
50
  session: {
48
51
  label: metadata?.label || `Session ${index + 1}`,
49
52
  agentName: metadata?.agentName || "AI agent",
@@ -59,7 +62,7 @@ function buildKind(review, kind, expectedRole, recordsById, metadataById) {
59
62
  const sessionId = reference?.location?.sessionId;
60
63
  const records = recordsById.get(sessionId);
61
64
  if (!records) return [];
62
- const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId), expectedRole);
65
+ const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId), expectedRole, interactionFeedbackId(kind, index));
63
66
  return occurrence ? [occurrence] : [];
64
67
  });
65
68
  }
@@ -0,0 +1,78 @@
1
+ import { interactionToneCandidateText } from "./interaction-tone.mjs";
2
+
3
+ export const INTERACTION_FEEDBACK_LABELS = new Set(["yelling", "thanking", "neither", "unsure"]);
4
+
5
+ function contentBlocks(record) {
6
+ const content = record?.message?.content ?? record?.content;
7
+ if (Array.isArray(content)) return content;
8
+ if (typeof content === "string") return [{ type: "text", text: content }];
9
+ return [];
10
+ }
11
+
12
+ function visibleText(record) {
13
+ return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
14
+ }
15
+
16
+ export function interactionFeedbackId(kind, index) {
17
+ const label = kind === "frustrated" ? "yelling" : kind === "grateful" ? "thanking" : null;
18
+ return label && Number.isInteger(index) && index >= 0 ? `${label}-${index + 1}` : null;
19
+ }
20
+
21
+ function feedbackCoordinates(value) {
22
+ const match = String(value || "").match(/^(yelling|thanking)-([1-9][0-9]{0,2})$/);
23
+ return match ? { originalLabel: match[1], index: Number(match[2]) - 1 } : null;
24
+ }
25
+
26
+ function fallbackJudgedText(reference, records) {
27
+ if (typeof reference?.judgedText === "string" && reference.judgedText) return reference.judgedText;
28
+ const location = reference?.location || {};
29
+ let record = Number.isInteger(location.recordIndex) ? records?.[location.recordIndex] : null;
30
+ if (!record || record.type !== "user") record = records?.find((item) => item?.type === "user" && item?.timestamp === location.timestamp);
31
+ return interactionToneCandidateText(visibleText(record)) || "";
32
+ }
33
+
34
+ export function resolveInteractionFeedback(report, feedbackId, recordsById = new Map()) {
35
+ const coordinates = feedbackCoordinates(feedbackId);
36
+ if (!coordinates) return null;
37
+ const kind = coordinates.originalLabel === "yelling" ? "frustrated" : "grateful";
38
+ const reference = report?.interactionReview?.[kind]?.[coordinates.index];
39
+ const sessionId = reference?.location?.sessionId;
40
+ if (!reference || typeof sessionId !== "string" || !(report?.sessionIds || []).includes(sessionId)) return null;
41
+ const review = report.interactionReview || {};
42
+ return {
43
+ id: feedbackId,
44
+ originalLabel: coordinates.originalLabel,
45
+ sessionId,
46
+ candidateId: typeof reference.candidateId === "string" ? reference.candidateId.slice(0, 64) : "unknown",
47
+ judgedText: fallbackJudgedText(reference, recordsById.get(sessionId)).slice(0, 240),
48
+ occurrences: Number.isInteger(reference.occurrences) && reference.occurrences > 0 ? Math.min(reference.occurrences, 1_000_000) : 1,
49
+ confidence: Number.isFinite(reference.confidence) ? Math.max(0, Math.min(1, reference.confidence)) : 1,
50
+ judge: {
51
+ model: typeof review.model === "string" ? review.model.slice(0, 120) : "unknown",
52
+ promptVersion: Number.isInteger(review.promptVersion) && review.promptVersion > 0 ? review.promptVersion : 1,
53
+ },
54
+ };
55
+ }
56
+
57
+ export function publicInteractionFeedback(value) {
58
+ if (!value) return null;
59
+ const { sessionId, ...safe } = value;
60
+ return safe;
61
+ }
62
+
63
+ export function sanitizeInteractionFeedbackSubmission(value, trusted) {
64
+ if (!trusted || value?.feedbackId !== trusted.id || !INTERACTION_FEEDBACK_LABELS.has(value?.correctedLabel)) return null;
65
+ const note = typeof value.note === "string"
66
+ ? value.note.normalize("NFKC").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1_000)
67
+ : "";
68
+ return {
69
+ originalLabel: trusted.originalLabel,
70
+ correctedLabel: value.correctedLabel,
71
+ candidateId: trusted.candidateId,
72
+ judgedText: trusted.judgedText,
73
+ occurrences: trusted.occurrences,
74
+ confidence: trusted.confidence,
75
+ judge: trusted.judge,
76
+ ...(note ? { note } : {}),
77
+ };
78
+ }
@@ -6,6 +6,7 @@ import { BEHAVIOR_WRAPPED_ORIGIN } from "./origins.mjs";
6
6
  export const INTERACTION_TONE_RELAY_URL = `${BEHAVIOR_WRAPPED_ORIGIN}/v1/interaction-tone`;
7
7
  export const INTERACTION_TONE_MAX_CANDIDATES = 120;
8
8
  export const INTERACTION_TONE_BATCH_SIZE = 30;
9
+ export const INTERACTION_TONE_PROMPT_VERSION = 1;
9
10
  const MAX_TEXT_LENGTH = 240;
10
11
  const MIN_CONFIDENCE = 0.75;
11
12
  const JUDGE_TIMEOUT_MS = 90_000;
@@ -55,6 +56,10 @@ function safeInteractionExcerpt(value) {
55
56
  return isShareSafeInteractionText(excerpt) ? excerpt : null;
56
57
  }
57
58
 
59
+ export function interactionToneCandidateText(value) {
60
+ return safeInteractionExcerpt(value);
61
+ }
62
+
58
63
  function priority(text, occurrences) {
59
64
  const words = text.match(/\p{L}+/gu)?.length || 0;
60
65
  return Number(strongFrustrationPattern.test(text)) * 20
@@ -247,10 +252,16 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
247
252
  occurrences: byId.get(item.candidate_id).occurrences,
248
253
  confidence: item.confidence,
249
254
  }));
250
- const reviewRefs = (items) => items.flatMap((item) => (byId.get(item.candidate_id).locations || []).map((location) => ({
251
- candidateId: item.candidate_id,
252
- location,
253
- })));
255
+ const reviewRefs = (items) => items.flatMap((item) => {
256
+ const candidate = byId.get(item.candidate_id);
257
+ return (candidate.locations || []).map((location) => ({
258
+ candidateId: item.candidate_id,
259
+ judgedText: candidate.text,
260
+ occurrences: candidate.occurrences,
261
+ confidence: item.confidence,
262
+ location,
263
+ }));
264
+ });
254
265
  return {
255
266
  frustratedMessages: count(selection.frustrated),
256
267
  gratefulMessages: count(selection.grateful),
@@ -261,6 +272,9 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
261
272
  privateMatches: { frustrated: matches(selection.frustrated), grateful: matches(selection.grateful) },
262
273
  review: {
263
274
  format: "behavior-wrapped-interaction-review-v1",
275
+ model,
276
+ provider,
277
+ promptVersion: INTERACTION_TONE_PROMPT_VERSION,
264
278
  frustrated: reviewRefs(selection.frustrated),
265
279
  grateful: reviewRefs(selection.grateful),
266
280
  },
@@ -11,6 +11,7 @@ import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
11
11
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
12
12
  import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
13
13
  import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
14
+ import { publicInteractionFeedback, resolveInteractionFeedback, sanitizeInteractionFeedbackSubmission } from "./interaction-feedback.mjs";
14
15
  import { createIdleShutdownController } from "./local-helper-runtime.mjs";
15
16
  import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
16
17
 
@@ -162,13 +163,30 @@ const server = http.createServer(async (request, response) => {
162
163
  const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
163
164
  return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
164
165
  }
166
+ const interactionFeedbackMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interaction-feedback\/(yelling|thanking)-([1-9][0-9]{0,2})$/);
167
+ if (request.method === "GET" && interactionFeedbackMatch) {
168
+ const report = loadReport(interactionFeedbackMatch[1]);
169
+ if (!report) return json(response, 404, { error: "Saved report not found" });
170
+ const feedbackId = `${interactionFeedbackMatch[2]}-${interactionFeedbackMatch[3]}`;
171
+ const reference = resolveInteractionFeedback(report, feedbackId);
172
+ if (!reference) return json(response, 404, { error: "That interaction classification is no longer available." });
173
+ const availableCatalog = await catalogForRequest();
174
+ if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
175
+ const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
176
+ const trusted = resolveInteractionFeedback(report, feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
177
+ return json(response, 200, { sessionIds: [reference.sessionId], feedback: publicInteractionFeedback(trusted), localPrivateSelection: true });
178
+ }
165
179
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
166
180
  const body = await readBody(request);
167
181
  const report = loadReport(body.reportId);
168
182
  if (!report) return json(response, 404, { error: "Saved report not found" });
169
183
  const availableCatalog = await catalogForRequest();
184
+ const feedback = body.feedbackId ? resolveInteractionFeedback(report, body.feedbackId) : null;
185
+ if (body.feedbackId && !feedback) return json(response, 400, { error: "Invalid classifier-feedback selection." });
170
186
  const allowed = new Set(report.sessionIds || []);
171
- const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
187
+ const ids = feedback
188
+ ? [feedback.sessionId].filter((id) => availableCatalog.index.has(id))
189
+ : Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
172
190
  const records = await chosenRecords(ids, {}, availableCatalog);
173
191
  if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
174
192
  const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
@@ -181,8 +199,26 @@ const server = http.createServer(async (request, response) => {
181
199
  const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
182
200
  const report = loadReport(body?.donation?.reportId);
183
201
  if (!report) return json(response, 404, { error: "Saved report not found" });
202
+ let donation = body.donation;
203
+ if (body.feedback) {
204
+ const reference = resolveInteractionFeedback(report, body.feedback.feedbackId);
205
+ const suppliedSession = donation?.sessions?.[0]?.sessionId;
206
+ if (!reference || donation?.sessions?.length !== 1 || suppliedSession !== reference.sessionId) return json(response, 400, { error: "Classifier feedback must contain only its original session." });
207
+ const availableCatalog = await catalogForRequest();
208
+ if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
209
+ const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
210
+ const trusted = resolveInteractionFeedback(report, body.feedback.feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
211
+ const classifierFeedback = sanitizeInteractionFeedbackSubmission(body.feedback, trusted);
212
+ if (!classifierFeedback) return json(response, 400, { error: "Choose a valid corrected classification before donating." });
213
+ donation = {
214
+ ...donation,
215
+ purpose: "classifier_feedback",
216
+ classifierFeedback,
217
+ consent: { ...donation.consent, classifierFeedback: true },
218
+ };
219
+ } else donation = { ...donation, purpose: "general_research", classifierFeedback: undefined };
184
220
  if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
185
- const result = await submitResearchDonation(body.donation, {
221
+ const result = await submitResearchDonation(donation, {
186
222
  clientId: getOrCreateClientId(),
187
223
  endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
188
224
  });
@@ -34,6 +34,7 @@ export function encryptResearchDonation(value, publicKey = RESEARCH_DONATION_PUB
34
34
  encryption: { algorithm: DONATION_ENCRYPTION_ALGORITHM, keyId: DONATION_KEY_ID },
35
35
  metadata: {
36
36
  reportId: donation.reportId,
37
+ purpose: donation.purpose,
37
38
  redactionMode: donation.redactionMode,
38
39
  createdAt: donation.createdAt,
39
40
  consentedAt: donation.consent.consentedAt,
@@ -7,10 +7,36 @@ function safeText(value, maximum) {
7
7
  return typeof value === "string" ? value.normalize("NFKC").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, maximum) : "";
8
8
  }
9
9
 
10
+ function normalizeClassifierFeedback(value) {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
12
+ if (!new Set(["yelling", "thanking"]).has(value.originalLabel) || !new Set(["yelling", "thanking", "neither", "unsure"]).has(value.correctedLabel)) return null;
13
+ const judgedText = safeText(value.judgedText, 240).trim();
14
+ const candidateId = safeText(value.candidateId, 64).trim();
15
+ const model = safeText(value.judge?.model, 120).trim();
16
+ const promptVersion = Number(value.judge?.promptVersion);
17
+ const occurrences = Number(value.occurrences);
18
+ const confidence = Number(value.confidence);
19
+ if (!judgedText || !candidateId || !model || !Number.isInteger(promptVersion) || promptVersion < 1 || promptVersion > 10_000) return null;
20
+ if (!Number.isInteger(occurrences) || occurrences < 1 || occurrences > 1_000_000 || !Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null;
21
+ const note = safeText(value.note, 1_000).trim();
22
+ return {
23
+ originalLabel: value.originalLabel,
24
+ correctedLabel: value.correctedLabel,
25
+ candidateId,
26
+ judgedText,
27
+ occurrences,
28
+ confidence,
29
+ judge: { model, promptVersion },
30
+ ...(note ? { note } : {}),
31
+ };
32
+ }
33
+
10
34
  function normalizeResearchDonation(value) {
11
35
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
12
36
  if (value.consent?.researchDonation !== true) return null;
13
37
  if (!/^[A-Za-z0-9_-]{8,32}$/.test(value.reportId || "")) return null;
38
+ if (value.purpose !== undefined && !new Set(["general_research", "classifier_feedback"]).has(value.purpose)) return null;
39
+ const purpose = value.purpose === "classifier_feedback" ? "classifier_feedback" : "general_research";
14
40
  if (!new Set(["standard", "custom", "unredacted"]).has(value.redactionMode)) return null;
15
41
  const unredacted = value.redactionMode === "unredacted";
16
42
  if (unredacted && value.consent?.unredactedData !== true) return null;
@@ -29,9 +55,12 @@ function normalizeResearchDonation(value) {
29
55
  return messages.length ? [{ label: `Session ${sessionIndex + 1}`, messages }] : [];
30
56
  });
31
57
  if (!sessions.length || messageCount > MAX_MESSAGES) return null;
58
+ const classifierFeedback = purpose === "classifier_feedback" ? normalizeClassifierFeedback(value.classifierFeedback) : null;
59
+ if (purpose === "classifier_feedback" && (!classifierFeedback || value.consent?.classifierFeedback !== true || sessions.length !== 1)) return null;
32
60
  const donation = {
33
61
  format: "behavior-wrapped-research-donation-v1",
34
62
  reportId: value.reportId,
63
+ purpose,
35
64
  redactionMode: value.redactionMode,
36
65
  createdAt: /^\d{4}-\d{2}-\d{2}T/.test(value.createdAt || "") ? value.createdAt : new Date().toISOString(),
37
66
  redactionSummary: {
@@ -39,11 +68,15 @@ function normalizeResearchDonation(value) {
39
68
  sessions: sessions.length,
40
69
  messages: messageCount,
41
70
  },
71
+ ...(classifierFeedback ? { classifierFeedback } : {}),
42
72
  sessions,
43
73
  consent: {
44
74
  researchDonation: true,
75
+ ...(classifierFeedback ? { classifierFeedback: true } : {}),
45
76
  ...(unredacted ? { unredactedData: true } : {}),
46
- statement: unredacted
77
+ statement: classifierFeedback
78
+ ? "I consent for this reviewed session and classification correction to be transmitted to the Susan Calvin Project and used for research and to evaluate and improve Behavior Wrapped under the data policy."
79
+ : unredacted
47
80
  ? "I understand this donation is not automatically redacted and may contain credentials, personal details, private code, URLs, and file paths. I consent to transmit it to the Susan Calvin Project for research under the data policy."
48
81
  : "I consent for this reviewed data to be transmitted to the Susan Calvin Project and used for research under the data policy.",
49
82
  consentedAt: /^\d{4}-\d{2}-\d{2}T/.test(value.consent.consentedAt || "") ? value.consent.consentedAt : new Date().toISOString(),