behavior-wrapped 0.11.0 → 0.12.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-By4wq-jG.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-CkqI9mbd.css">
9
+ <script type="module" crossorigin src="/assets/index-89_b8V4F.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-Dhat2zC7.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -7,11 +7,11 @@ npm install
7
7
  npm run wrapped -- --demo
8
8
  ```
9
9
 
10
- The normal CLI scans local session sources, creates a report, starts the donation-only localhost helper, and opens the report. Add `--no-open` to leave the browser closed or `--days=N` to change the rolling window.
10
+ The normal CLI scans local session sources, creates a report, starts the localhost evidence helper, and opens the report. Add `--no-open` to leave the browser closed or `--days=N` to change the rolling window.
11
11
 
12
12
  For fast formatting work, use `npm run wrapped -- --test`. Test mode skips consent and all LLM calls, uses deterministic local fallbacks, keeps the report on localhost, and does not publish it. `--no-llm` is an alias for `--test`.
13
13
 
14
- Use `npm run dev` for hosted-page UI development. Run `npm start -- --no-open` to develop the loopback donation helper directly. Before publishing, run:
14
+ Use `npm run dev` for hosted-page UI development. Run `npm start -- --no-open` to develop the loopback evidence and sharing helper directly. Before publishing, run:
15
15
 
16
16
  ```bash
17
17
  npm run check
@@ -32,3 +32,5 @@ npm run analyze:phrases -- ~/.claude/projects analysis-output/local-phrase-famil
32
32
  ```
33
33
 
34
34
  Use `--limit=100` to retain more than the default 50 families. The miner uses rare token-shingle postings, bounded token edit distance, and union-find clustering. Its versioned output contains aggregate phrases and benchmark data, never raw transcripts or tool output. The gitignored `analysis-output` directory is created with private file permissions.
35
+
36
+ Donation collection lives in the pinned `share-with-susan-calvin` dependency. Its public integration entry point starts the independent local review app. Tests use synthetic fixture roots and never transmit demo donations.
package/docs/privacy.md CHANGED
@@ -28,9 +28,9 @@ Local-only reports omit AI-judged interaction tone, topics, and workarounds. The
28
28
 
29
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
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 text and customize redactions, then provides purpose-specific consent. Every message remains in its original sequence. Clicking the feedback link alone transmits nothing.
31
+ Yelling and thanking occurrences include an optional **Is this inaccurate?** path. It resolves the occurrence to its original session on localhost and opens Share with Susan Calvin restricted to that one session. There, the user chooses a corrected label, customizes redactions, reviews the correction, then provides purpose-specific consent. Transcript messages are read-only except for supported redactions. Every message remains in its original sequence. Clicking the feedback link alone transmits nothing.
32
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.
33
+ Susan handles local encryption, private ciphertext storage, deletion receipts, and deletion controls for both classifier feedback and ordinary donations. 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.
34
34
 
35
35
  ## Boundaries
36
36
 
@@ -38,3 +38,5 @@ Accepted classifier feedback receives the same local encryption, private ciphert
38
38
  - Hosted reports currently rely on unguessable URLs and installation-scoped management tokens rather than user accounts.
39
39
  - Heuristic coverage can be incomplete; uncertain findings are labeled with confidence.
40
40
  - The public data-use and storage policy is available at [susancalvin.org/data-policy](https://susancalvin.org/data-policy).
41
+
42
+ Ordinary donation invitations open Susan's own recent-session picker without transferring report data or selection. It obtains fresh consent, includes available message timestamps, and transmits only its reviewed snapshot. Existing Wrapped receipts can be managed with Susan's list/delete commands. Historical donations retain their original storage and consent, with deletion forwarded through the original URL.
@@ -1,14 +1,12 @@
1
1
  # Research donations
2
2
 
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**.
3
+ Behavior Wrapped launches **Share with Susan Calvin** for optional donation. Ordinary sharing opens the standalone app's normal recent-session picker; the report and its selection are not transferred. Susan owns discovery, review, redaction, consent, encryption, uploads and deletion receipts. Nothing is donated until consent is given inside Susan.
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.
5
+ From a private yelling or thanking occurrence, **Correct with Susan Calvin** opens the original session only. Wrapped resolves the classification locally and passes the source reference and original judge context through process IPC. Susan collects and reviews the correction and optional explanation, applies the selected redactions, and requires consent for research and evaluating and improving Wrapped. No report ID, source path or local session ID enters the donation. A missing source produces an error rather than substituting a different session.
6
6
 
7
- The localhost helper can construct a standard-redacted preview, a customizable redaction review, or a deliberately unredacted copy. Detailed modes let users exclude whole sessions, edit or redact text, and keep timestamps off by default. Every message in an included session is preserved in its original sequence, and the localhost submission endpoint rejects incomplete transcripts. The unredacted path shows every included line and requires a separate warning and explicit acknowledgement that credentials and private details may be transmitted.
7
+ The local `/donate/:reportId` route is a compatibility bridge. It starts an independent Susan process on an available loopback port; Wrapped's evidence helper can expire without stopping review. Old report links and private evidence links retain their existing structure. A stopped helper can be restarted with `npx behavior-wrapped@latest open <report-id>`. Ordinary donations can also start directly with `npx share-with-susan-calvin@latest`.
8
8
 
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.
10
-
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.
9
+ New donations use Susan's streamed encrypted receiver and grouped deletion receipts. Old uploads to `/v1/research-donations` return an upgrade instruction. Old deletion URLs continue forwarding to Susan, which owns the legacy storage bindings. Existing ciphertext and metadata remain in their original resources. `npx share-with-susan-calvin@latest list` finds receipts from both apps; its `delete` command dispatches by receipt origin and preserves credentials on failure.
12
10
 
13
11
  ## Maintainer operations
14
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -70,6 +70,7 @@
70
70
  "vite": "^8.2.0"
71
71
  },
72
72
  "dependencies": {
73
- "@fontsource-variable/inter": "^5.3.0"
73
+ "@fontsource-variable/inter": "^5.3.0",
74
+ "share-with-susan-calvin": "0.6.0"
74
75
  }
75
76
  }
package/server/cli.mjs CHANGED
@@ -118,7 +118,7 @@ async function ensureServer(demo = false) {
118
118
  catch (error) { if (error?.code !== "ESRCH") throw error; }
119
119
  }
120
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.`);
121
+ throw new Error(`Could not start the local helper on port ${port}. ${reason} Another application may already be using that port.`);
122
122
  }
123
123
 
124
124
  function openUrl(url) {
@@ -148,9 +148,9 @@ async function createWrapped() {
148
148
  console.log(`\n ${bright}behavior-wrapped${reset} ${muted}· the wrapped for your AI agents${reset}\n`);
149
149
  const demo = process.argv.includes("--demo");
150
150
  const testMode = process.argv.includes("--test") || process.argv.includes("--no-llm");
151
- progress.start("Preparing local donation helper", `localhost:${port}`);
151
+ progress.start("Preparing local evidence helper", `localhost:${port}`);
152
152
  await ensureServer(demo);
153
- progress.succeed("Local donation helper ready");
153
+ progress.succeed("Local evidence helper ready");
154
154
  const helperHeartbeat = setInterval(() => { void fetch(`${loopbackUrl}/api/health`).catch(() => {}); }, 30_000);
155
155
  helperHeartbeat.unref();
156
156
  const daysArgument = process.argv.find((argument) => argument.startsWith("--days="));
@@ -1,18 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import { launchReview } from "share-with-susan-calvin/integration";
2
3
  import http from "node:http";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
7
- import { makeDonationPreview } from "./analysis.mjs";
8
- import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
9
- import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
10
- import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
8
+ import { loadReport } from "./store.mjs";
11
9
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
12
10
  import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
13
11
  import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
14
- import { publicInteractionFeedback, resolveInteractionFeedback, sanitizeInteractionFeedbackSubmission } from "./interaction-feedback.mjs";
15
- import { donationSessionIntegrityError } from "./donation-session-integrity.mjs";
12
+ import { publicInteractionFeedback, resolveInteractionFeedback } from "./interaction-feedback.mjs";
16
13
  import { createIdleShutdownController } from "./local-helper-runtime.mjs";
17
14
  import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
18
15
 
@@ -31,6 +28,7 @@ const configuredTestCatalogDelayMs = process.env.NODE_ENV === "test" ? Number(pr
31
28
  const testCatalogDelayMs = Number.isFinite(configuredTestCatalogDelayMs) && configuredTestCatalogDelayMs > 0 ? configuredTestCatalogDelayMs : 0;
32
29
  let catalog = null;
33
30
  let catalogPromise = null;
31
+ const susanLaunches = new Map();
34
32
 
35
33
  async function loadCatalog() {
36
34
  if (testCatalogDelayMs) await new Promise((resolve) => setTimeout(resolve, testCatalogDelayMs));
@@ -177,71 +175,34 @@ const server = http.createServer(async (request, response) => {
177
175
  const trusted = resolveInteractionFeedback(report, feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
178
176
  return json(response, 200, { sessionIds: [reference.sessionId], feedback: publicInteractionFeedback(trusted), localPrivateSelection: true });
179
177
  }
180
- if (request.method === "POST" && url.pathname === "/api/donation-preview") {
181
- const body = await readBody(request);
182
- const report = loadReport(body.reportId);
183
- if (!report) return json(response, 404, { error: "Saved report not found" });
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." });
187
- const allowed = new Set(report.sessionIds || []);
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) : [];
191
- const records = await chosenRecords(ids, {}, availableCatalog);
192
- if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
193
- const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
194
- const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
195
- const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
196
- const unredacted = body.previewMode === "unredacted";
197
- return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches, unredacted }));
198
- }
199
- if (request.method === "POST" && url.pathname === "/api/research-donations") {
200
- const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
201
- const report = loadReport(body?.donation?.reportId);
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 };
228
- if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
229
- const result = await submitResearchDonation(donation, {
230
- clientId: getOrCreateClientId(),
231
- endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
232
- });
233
- saveDonationReceipt(result);
234
- return json(response, 201, { accepted: true, donation_id: result.donation_id, encrypted: true });
235
- }
236
- const donationMatch = url.pathname.match(/^\/api\/research-donations\/([0-9a-f-]{36})$/);
237
- if (request.method === "DELETE" && donationMatch) {
238
- const receipt = loadDonationReceipt(donationMatch[1]);
239
- if (!receipt) return json(response, 404, { error: "Local deletion receipt not found." });
240
- if (demo) { deleteDonationReceipt(donationMatch[1]); return json(response, 200, { deleted: true, demo: true }); }
241
- const result = await deleteResearchDonation(receipt.donationId, receipt.deletionToken, { endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL });
242
- deleteDonationReceipt(donationMatch[1]);
243
- return json(response, 200, result);
178
+ if (request.method === "POST" && url.pathname === "/api/share-with-susan") {
179
+ if (!new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]).has(request.headers.origin || "")) return json(response, 403, { error: "Open sharing from the local app." });
180
+ const body = await readBody(request, 2_000);
181
+ let feedback = null;
182
+ if (body.feedbackId) {
183
+ const report = loadReport(body.reportId);
184
+ const reference = resolveInteractionFeedback(report, body.feedbackId);
185
+ if (!reference) return json(response, 404, { error: "That classification is no longer available. Reopen the local evidence page." });
186
+ const available = await catalogForRequest();
187
+ if (!available.index.has(reference.sessionId)) return json(response, 404, { error: "The original session is no longer available on this device." });
188
+ const records = await chosenRecords([reference.sessionId], {}, available);
189
+ feedback = resolveInteractionFeedback(report, body.feedbackId, new Map(records.map(session => [session.sessionId, session.records])));
190
+ }
191
+ const key = feedback ? `${body.reportId}:${body.feedbackId}` : "general";
192
+ let pending = susanLaunches.get(key);
193
+ if (pending) {
194
+ const previous = await pending;
195
+ const alive = await fetch(`${previous.url}/api/health`, { signal: AbortSignal.timeout(500) }).then(async result => result.ok && (await result.json()).app === "share-with-susan-calvin").catch(() => false);
196
+ if (!alive) { susanLaunches.delete(key); pending = null; }
197
+ }
198
+ if (!pending) {
199
+ pending = launchReview({ demo, feedback, ...(demo ? { demoRoots: { claudeRoot: fixtureRoot, coworkRoot: coworkFixtureRoot, codexRoots: [codexFixtureRoot] } } : {}) });
200
+ susanLaunches.set(key, pending);
201
+ pending.catch(() => { if (susanLaunches.get(key) === pending) susanLaunches.delete(key); });
202
+ }
203
+ return json(response, 200, await pending);
244
204
  }
205
+ if (request.method === "POST" && ["/api/donation-preview", "/api/research-donations"].includes(url.pathname)) return json(response, 410, { error: "Donation review has moved. Run npx behavior-wrapped@latest or npx share-with-susan-calvin@latest." });
245
206
  if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: "Method not allowed" });
246
207
  const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
247
208
  let file = path.resolve(dist, requested);
@@ -262,8 +223,8 @@ const idleShutdown = createIdleShutdownController({
262
223
 
263
224
  server.listen(port, "127.0.0.1", () => {
264
225
  const url = `http://localhost:${port}`;
265
- console.log(`Behavior Wrapped donation helper is ready at ${url}`);
226
+ console.log(`Behavior Wrapped local helper is ready at ${url}`);
266
227
  const sessionDirectories = canonicalSessionDirectoryLabels().join(", ");
267
- console.log(demo ? "Using synthetic demo sessions." : `Donation review reads selected sessions locally from ${sessionDirectories}.`);
228
+ console.log(demo ? "Using synthetic demo sessions." : `Private evidence reads selected sessions locally from ${sessionDirectories}.`);
268
229
  if (!process.argv.includes("--no-open") && process.env.NODE_ENV !== "test") openExternalUrl(url);
269
230
  });