behavior-wrapped 0.9.0 → 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-DtJbmHbC.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.0",
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",
package/server/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
6
  import { discoverAllSessionsAsync, readRecordsAsync, sessionsInDefaultWindow, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
7
7
  import { analyzeSessions } from "./analysis.mjs";
8
- import { buildLocalPhraseCard, buildPhraseCandidates, judgePhraseCard, judgePhraseCardViaRelay, PHRASE_JUDGE_NAME, PHRASE_JUDGE_RELAY_URL } from "./phrase-card.mjs";
8
+ import { buildLocalPhraseCard, buildPhraseCandidates, judgePhraseCard, judgePhraseCardViaRelay, phraseCardWithLocalFallback, PHRASE_JUDGE_NAME, PHRASE_JUDGE_RELAY_URL } from "./phrase-card.mjs";
9
9
  import { requestAnalysisMode } from "./consent.mjs";
10
10
  import { applyInteractionToneJudgment, buildInteractionToneCandidates, emptyInteractionToneJudgment, INTERACTION_TONE_RELAY_URL, judgeInteractionTone, judgeInteractionToneViaRelay } from "./interaction-tone.mjs";
11
11
  import { applySessionTopicJudgment, buildSessionTopicCandidates, emptySessionTopicJudgment, judgeSessionTopics, judgeSessionTopicsViaRelay, SESSION_TOPIC_RELAY_URL } from "./session-topics.mjs";
@@ -28,6 +28,10 @@ const baseUrl = `http://localhost:${port}`;
28
28
  const loopbackUrl = `http://127.0.0.1:${port}`;
29
29
  const command = process.argv[2];
30
30
  const verbose = process.argv.includes("--verbose") || process.argv.includes("--debug") || process.env.BEHAVIOR_WRAPPED_DEBUG === "1";
31
+ const HELPER_HEALTH_TIMEOUT_MS = 500;
32
+ const HELPER_POLL_MS = 100;
33
+ const HELPER_STOP_TIMEOUT_MS = 5_000;
34
+ const HELPER_START_TIMEOUT_MS = 10_000;
31
35
  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";
32
36
  const progress = createCliProgress();
33
37
 
@@ -78,7 +82,7 @@ function printJudgeDebug(label, error) {
78
82
 
79
83
  async function helperStatus(expectedDemo = false) {
80
84
  try {
81
- const response = await fetch(`${loopbackUrl}/api/health`);
85
+ const response = await fetch(`${loopbackUrl}/api/health`, { signal: AbortSignal.timeout(HELPER_HEALTH_TIMEOUT_MS) });
82
86
  const body = await response.json();
83
87
  const recognized = response.ok && body.app === "behavior-wrapped" && body.local === true && body.purpose === "research-donation";
84
88
  return { recognized, compatible: recognized && helperHealthMatches(body, { version: APP_VERSION, protocol: LOCAL_DONATION_PROTOCOL, demo: expectedDemo }), pid: Number(body.pid) || null };
@@ -91,18 +95,30 @@ async function ensureServer(demo = false) {
91
95
  if (current.recognized) {
92
96
  const stopped = await stopVerifiedStaleHelper(port, current.pid);
93
97
  if (!stopped) throw new Error(`An older Behavior Wrapped helper is using port ${port}. Stop it, then run this command again.`);
94
- for (let attempt = 0; attempt < 30; attempt++) {
95
- await new Promise((resolve) => setTimeout(resolve, 100));
98
+ const stopDeadline = Date.now() + HELPER_STOP_TIMEOUT_MS;
99
+ while (Date.now() < stopDeadline) {
100
+ await new Promise((resolve) => setTimeout(resolve, HELPER_POLL_MS));
96
101
  if (!(await helperStatus(demo)).recognized) break;
97
102
  }
98
103
  }
99
104
  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" } });
105
+ let childFailure = null;
106
+ child.once("error", (error) => { childFailure = error; });
107
+ child.once("exit", (code, signal) => { childFailure = new Error(`helper exited with ${signal ? `signal ${signal}` : `code ${code}`}`); });
100
108
  child.unref();
101
- for (let attempt = 0; attempt < 30; attempt++) {
102
- await new Promise((resolve) => setTimeout(resolve, 100));
109
+ const startDeadline = Date.now() + HELPER_START_TIMEOUT_MS;
110
+ while (Date.now() < startDeadline) {
111
+ await new Promise((resolve) => setTimeout(resolve, HELPER_POLL_MS));
103
112
  if ((await helperStatus(demo)).compatible) return;
113
+ if (childFailure) break;
104
114
  }
105
- throw new Error(`Could not start the local donation helper on port ${port}.`);
115
+ if ((await helperStatus(demo)).compatible) return;
116
+ if (!childFailure && child.pid && child.exitCode === null) {
117
+ try { process.kill(child.pid, "SIGTERM"); }
118
+ catch (error) { if (error?.code !== "ESRCH") throw error; }
119
+ }
120
+ const reason = childFailure ? `${childFailure.message}.` : `It did not become ready within ${HELPER_START_TIMEOUT_MS / 1_000} seconds and was stopped.`;
121
+ throw new Error(`Could not start the local donation helper on port ${port}. ${reason} Another application may already be using that port.`);
106
122
  }
107
123
 
108
124
  function openUrl(url) {
@@ -229,7 +245,9 @@ async function createWrapped() {
229
245
  : judgeWorkaroundsViaRelay(workaroundBundle, { endpoint: process.env.BEHAVIOR_WRAPPED_WORKAROUND_URL || WORKAROUND_RELAY_URL, clientId: getOrCreateClientId(), onProgress: workaroundProgress })
230
246
  : Promise.resolve(emptyWorkaroundJudgment(workaroundBundle.coverage));
231
247
  [phraseCard, interactionTone, sessionTopics, workarounds] = await Promise.all([
232
- trackJudge("phrase", phraseCardPromise),
248
+ phraseCardWithLocalFallback(candidates, trackJudge("phrase", phraseCardPromise), {
249
+ onFallback(error) { analysisWarnings.push({ label: "favorite-phrase judge", error, localPhraseFallback: true }); },
250
+ }),
233
251
  optionalAnalysis(trackJudge("tone", interactionTonePromise), "interaction card", analysisWarnings),
234
252
  optionalAnalysis(trackJudge("topics", sessionTopicsPromise), "usage-topic card", analysisWarnings),
235
253
  optionalAnalysis(trackJudge("workarounds", workaroundsPromise), "instrumental-workaround card", analysisWarnings),
@@ -253,7 +271,9 @@ async function createWrapped() {
253
271
  delete analyzed.workaroundReview;
254
272
  }
255
273
  for (const warning of analysisWarnings) {
256
- console.log(`◇ ${muted}Skipped the ${warning.label}; the judge request failed or its response could not be validated.${reset} `);
274
+ console.log(warning.localPhraseFallback
275
+ ? `◇ ${muted}The favorite-phrase judge was unavailable; used the deterministic local phrase instead.${reset} `
276
+ : `◇ ${muted}Skipped the ${warning.label}; the judge request failed or its response could not be validated.${reset} `);
257
277
  printJudgeDebug(warning.label, warning.error);
258
278
  }
259
279
  const id = createReportId();
@@ -278,8 +298,9 @@ async function createWrapped() {
278
298
  const localUrl = `${baseUrl}/w/${id}`;
279
299
  const url = localOnly ? localUrl : report.managementUrl || publicUrl || localUrl;
280
300
  const tokenLabel = formatNumber(report.stats.tokens || 0);
301
+ const localPhrasePick = localOnly || report.phraseCard?.provider === "Local deterministic analysis";
281
302
  console.log(`◇ ${bright}Wrapped ready${reset} · ${tokenLabel} tokens across ${report.stats.sessions} sessions `);
282
- if (report.phraseCard) console.log(`◇ ${localOnly ? "Local pick" : `${PHRASE_JUDGE_NAME}'s pick`} · “${report.phraseCard.phrase}” × ${report.phraseCard.occurrences}${localOnly ? "" : ` · ${(report.phraseCard.latencyMs / 1000).toFixed(1)}s`} `);
303
+ if (report.phraseCard) console.log(`◇ ${localPhrasePick ? "Local pick" : `${PHRASE_JUDGE_NAME}'s pick`} · “${report.phraseCard.phrase}” × ${report.phraseCard.occurrences}${localPhrasePick ? "" : ` · ${(report.phraseCard.latencyMs / 1000).toFixed(1)}s`} `);
283
304
  console.log(`│\n◇ Your wrapped is ${publicUrl ? "live" : "ready locally"} ───────────────────────────────╮`);
284
305
  console.log(`│ │`);
285
306
  console.log(`│ ${purple}${bright}${publicUrl || localUrl}${reset}`);
@@ -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
 
@@ -25,14 +26,32 @@ const portArg = process.argv.find((arg) => arg.startsWith("--port="));
25
26
  const port = Number(portArg?.split("=")[1] || 4317);
26
27
  const configuredIdleMs = Number(process.env.BEHAVIOR_WRAPPED_HELPER_IDLE_MS);
27
28
  const helperIdleMs = Number.isFinite(configuredIdleMs) && configuredIdleMs >= 100 ? configuredIdleMs : 5 * 60 * 1_000;
28
- let catalog = await loadCatalog();
29
+ const configuredTestCatalogDelayMs = process.env.NODE_ENV === "test" ? Number(process.env.BEHAVIOR_WRAPPED_TEST_CATALOG_DELAY_MS) : 0;
30
+ const testCatalogDelayMs = Number.isFinite(configuredTestCatalogDelayMs) && configuredTestCatalogDelayMs > 0 ? configuredTestCatalogDelayMs : 0;
31
+ let catalog = null;
32
+ let catalogPromise = null;
29
33
 
30
34
  async function loadCatalog() {
35
+ if (testCatalogDelayMs) await new Promise((resolve) => setTimeout(resolve, testCatalogDelayMs));
31
36
  const found = await discoverAllSessionsAsync(demo ? { claudeRoot: fixtureRoot, coworkRoot: coworkFixtureRoot, codexRoots: [codexFixtureRoot], cache: false } : undefined);
32
37
  if (demo) found.sessions = found.sessions.map((session, index) => ({ ...session, synthetic: true, label: `Demo session ${index + 1}` }));
33
38
  return found;
34
39
  }
35
40
 
41
+ async function catalogForRequest({ refresh = false } = {}) {
42
+ if (refresh || !catalogPromise) {
43
+ const loading = loadCatalog();
44
+ catalogPromise = loading;
45
+ try {
46
+ catalog = await loading;
47
+ } catch (error) {
48
+ if (catalogPromise === loading) catalogPromise = null;
49
+ throw error;
50
+ }
51
+ }
52
+ return catalogPromise;
53
+ }
54
+
36
55
  function securityHeaders(extra = {}) {
37
56
  return {
38
57
  "X-Content-Type-Options": "nosniff",
@@ -71,23 +90,24 @@ function readBody(request, maximumBytes = 1_000_000) {
71
90
  });
72
91
  }
73
92
 
74
- async function chosenRecords(ids, options = {}) {
93
+ async function chosenRecords(ids, options = {}, activeCatalog = null) {
94
+ const availableCatalog = activeCatalog || await catalogForRequest();
75
95
  const selected = [];
76
96
  for (const id of ids) {
77
- const session = catalog.index.get(id);
97
+ const session = availableCatalog.index.get(id);
78
98
  if (session) selected.push({ sessionId: id, agent: session.agent, records: await readRecordsAsync(session.file, session.agent, options) });
79
99
  }
80
100
  return selected;
81
101
  }
82
102
 
83
- function publicCatalog() {
103
+ function publicCatalog(availableCatalog) {
84
104
  const agentNames = supportedAgentNames(process.platform, { includeCowork: demo || process.platform === "darwin" });
85
105
  return {
86
- rootAvailable: catalog.rootAvailable,
106
+ rootAvailable: availableCatalog.rootAvailable,
87
107
  demo,
88
- projects: catalog.projects,
89
- sessions: catalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
90
- defaultRange: defaultDateRange(catalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
108
+ projects: availableCatalog.projects,
109
+ sessions: availableCatalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
110
+ defaultRange: defaultDateRange(availableCatalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
91
111
  agentNames,
92
112
  privacy: { canonicalDirectories: canonicalSessionDirectoryLabels(), networkRequests: "only-after-final-donation-consent" },
93
113
  };
@@ -99,10 +119,10 @@ const server = http.createServer(async (request, response) => {
99
119
  if (!new Set([`127.0.0.1:${port}`, `localhost:${port}`]).has(request.headers.host || "")) return json(response, 403, { error: "Local access only" });
100
120
  idleShutdown.touch();
101
121
  const url = new URL(request.url || "/", `http://${request.headers.host}`);
102
- if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo });
122
+ if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo, catalogState: catalog ? "ready" : catalogPromise ? "loading" : "not-loaded" });
103
123
  if (request.method === "GET" && url.pathname === "/api/discover") {
104
- catalog = await loadCatalog();
105
- return json(response, 200, publicCatalog());
124
+ const availableCatalog = await catalogForRequest({ refresh: true });
125
+ return json(response, 200, publicCatalog(availableCatalog));
106
126
  }
107
127
  const reportMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})$/);
108
128
  if (request.method === "GET" && reportMatch) {
@@ -116,39 +136,60 @@ const server = http.createServer(async (request, response) => {
116
136
  if (request.method === "GET" && selectionMatch) {
117
137
  const report = loadReport(selectionMatch[1]);
118
138
  if (!report) return json(response, 404, { error: "Saved report not found" });
119
- const available = new Set(catalog.sessions.map((session) => session.id));
139
+ const availableCatalog = await catalogForRequest();
140
+ const available = new Set(availableCatalog.sessions.map((session) => session.id));
120
141
  return json(response, 200, { sessionIds: (report.sessionIds || []).filter((id) => available.has(id)), localPrivateSelection: true });
121
142
  }
122
143
  const workaroundEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/workarounds$/);
123
144
  if (request.method === "GET" && workaroundEvidenceMatch) {
124
145
  const report = loadReport(workaroundEvidenceMatch[1]);
125
146
  if (!report) return json(response, 404, { error: "Saved report not found" });
147
+ const availableCatalog = await catalogForRequest();
126
148
  const allowed = new Set(report.sessionIds || []);
127
- const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 100);
128
- const records = await chosenRecords(ids, { includePrivateToolDetails: true });
129
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
149
+ const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 100);
150
+ const records = await chosenRecords(ids, { includePrivateToolDetails: true }, availableCatalog);
151
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
130
152
  return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
131
153
  }
132
154
  const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
133
155
  if (request.method === "GET" && interactionEvidenceMatch) {
134
156
  const report = loadReport(interactionEvidenceMatch[1]);
135
157
  if (!report) return json(response, 404, { error: "Saved report not found" });
158
+ const availableCatalog = await catalogForRequest();
136
159
  const allowed = new Set(report.sessionIds || []);
137
160
  const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || []), ...(report.apologyReview?.user || []), ...(report.apologyReview?.agent || [])];
138
- const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 200);
139
- const records = await chosenRecords(ids);
140
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
161
+ const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 200);
162
+ const records = await chosenRecords(ids, {}, availableCatalog);
163
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
141
164
  return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
142
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
+ }
143
179
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
144
180
  const body = await readBody(request);
145
181
  const report = loadReport(body.reportId);
146
182
  if (!report) return json(response, 404, { error: "Saved report not found" });
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." });
147
186
  const allowed = new Set(report.sessionIds || []);
148
- const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && catalog.index.has(id)).slice(0, 250) : [];
149
- const records = await chosenRecords(ids);
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) : [];
190
+ const records = await chosenRecords(ids, {}, availableCatalog);
150
191
  if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
151
- const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
192
+ const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
152
193
  const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
153
194
  const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
154
195
  const unredacted = body.previewMode === "unredacted";
@@ -158,8 +199,26 @@ const server = http.createServer(async (request, response) => {
158
199
  const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
159
200
  const report = loadReport(body?.donation?.reportId);
160
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 };
161
220
  if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
162
- const result = await submitResearchDonation(body.donation, {
221
+ const result = await submitResearchDonation(donation, {
163
222
  clientId: getOrCreateClientId(),
164
223
  endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
165
224
  });
@@ -208,6 +208,14 @@ export function buildLocalPhraseCard(candidates) {
208
208
  });
209
209
  }
210
210
 
211
+ export async function phraseCardWithLocalFallback(candidates, judgedCard, { onFallback } = {}) {
212
+ try { return await judgedCard; }
213
+ catch (error) {
214
+ onFallback?.(error);
215
+ return buildLocalPhraseCard(candidates);
216
+ }
217
+ }
218
+
211
219
  function timeoutMessage(error, timeoutMs) {
212
220
  if (error?.name === "TimeoutError" || error?.name === "AbortError") return new Error(`${PHRASE_JUDGE_NAME} timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
213
221
  return error;
@@ -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(),