behavior-wrapped 0.9.1 → 0.10.1

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.
@@ -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,8 @@ 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";
15
+ import { donationSessionIntegrityError } from "./donation-session-integrity.mjs";
14
16
  import { createIdleShutdownController } from "./local-helper-runtime.mjs";
15
17
  import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
16
18
 
@@ -162,13 +164,30 @@ const server = http.createServer(async (request, response) => {
162
164
  const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
163
165
  return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
164
166
  }
167
+ const interactionFeedbackMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interaction-feedback\/(yelling|thanking)-([1-9][0-9]{0,2})$/);
168
+ if (request.method === "GET" && interactionFeedbackMatch) {
169
+ const report = loadReport(interactionFeedbackMatch[1]);
170
+ if (!report) return json(response, 404, { error: "Saved report not found" });
171
+ const feedbackId = `${interactionFeedbackMatch[2]}-${interactionFeedbackMatch[3]}`;
172
+ const reference = resolveInteractionFeedback(report, feedbackId);
173
+ if (!reference) return json(response, 404, { error: "That interaction classification is no longer available." });
174
+ const availableCatalog = await catalogForRequest();
175
+ if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
176
+ const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
177
+ const trusted = resolveInteractionFeedback(report, feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
178
+ return json(response, 200, { sessionIds: [reference.sessionId], feedback: publicInteractionFeedback(trusted), localPrivateSelection: true });
179
+ }
165
180
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
166
181
  const body = await readBody(request);
167
182
  const report = loadReport(body.reportId);
168
183
  if (!report) return json(response, 404, { error: "Saved report not found" });
169
184
  const availableCatalog = await catalogForRequest();
185
+ const feedback = body.feedbackId ? resolveInteractionFeedback(report, body.feedbackId) : null;
186
+ if (body.feedbackId && !feedback) return json(response, 400, { error: "Invalid classifier-feedback selection." });
170
187
  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) : [];
188
+ const ids = feedback
189
+ ? [feedback.sessionId].filter((id) => availableCatalog.index.has(id))
190
+ : Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
172
191
  const records = await chosenRecords(ids, {}, availableCatalog);
173
192
  if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
174
193
  const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
@@ -181,8 +200,33 @@ const server = http.createServer(async (request, response) => {
181
200
  const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
182
201
  const report = loadReport(body?.donation?.reportId);
183
202
  if (!report) return json(response, 404, { error: "Saved report not found" });
203
+ let donation = body.donation;
204
+ const suppliedSessions = Array.isArray(donation?.sessions) ? donation.sessions : [];
205
+ const suppliedIds = suppliedSessions.map((session) => session?.sessionId);
206
+ const allowed = new Set(report.sessionIds || []);
207
+ const uniqueIds = new Set(suppliedIds);
208
+ if (!suppliedIds.length || uniqueIds.size !== suppliedIds.length || suppliedIds.some((id) => !allowed.has(id))) return json(response, 400, { error: "Donated sessions must come from this report." });
209
+ const feedbackReference = body.feedback ? resolveInteractionFeedback(report, body.feedback.feedbackId) : null;
210
+ if (body.feedback && (!feedbackReference || suppliedSessions.length !== 1 || suppliedIds[0] !== feedbackReference.sessionId)) return json(response, 400, { error: "Classifier feedback must contain only its original session." });
211
+ const availableCatalog = await catalogForRequest();
212
+ if (suppliedIds.some((id) => !availableCatalog.index.has(id))) return json(response, 404, { error: "A selected source session is no longer available on this device." });
213
+ const records = await chosenRecords(suppliedIds, {}, availableCatalog);
214
+ const sourceSessions = makeDonationPreview(records, new Map(), { unredacted: true }).sessions;
215
+ const integrityError = donationSessionIntegrityError(suppliedSessions, sourceSessions);
216
+ if (integrityError) return json(response, 400, { error: integrityError });
217
+ if (body.feedback) {
218
+ const trusted = resolveInteractionFeedback(report, body.feedback.feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
219
+ const classifierFeedback = sanitizeInteractionFeedbackSubmission(body.feedback, trusted);
220
+ if (!classifierFeedback) return json(response, 400, { error: "Choose a valid corrected classification before donating." });
221
+ donation = {
222
+ ...donation,
223
+ purpose: "classifier_feedback",
224
+ classifierFeedback,
225
+ consent: { ...donation.consent, classifierFeedback: true },
226
+ };
227
+ } else donation = { ...donation, purpose: "general_research", classifierFeedback: undefined };
184
228
  if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
185
- const result = await submitResearchDonation(body.donation, {
229
+ const result = await submitResearchDonation(donation, {
186
230
  clientId: getOrCreateClientId(),
187
231
  endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
188
232
  });
@@ -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(),