behavior-wrapped 0.5.21 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/assets/{index-BqN509pc.js → index-CtRLyktk.js} +2 -2
- package/dist/assets/{index-a017khge.css → index-hFREdA4j.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/server/cli.mjs +9 -2
- package/server/consent.mjs +1 -1
- package/server/discovery.mjs +6 -7
- package/server/interaction-evidence.mjs +77 -0
- package/server/interaction-tone.mjs +23 -5
- package/server/launcher.mjs +20 -6
- package/server/local-helper-runtime.mjs +25 -10
- package/server/platform.mjs +39 -0
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CtRLyktk.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-hFREdA4j.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "behavior-wrapped",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A private, local-first Wrapped report for Claude Code, Cowork, and Codex behavior.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"wrapped"
|
|
24
24
|
],
|
|
25
25
|
"os": [
|
|
26
|
-
"darwin"
|
|
26
|
+
"darwin",
|
|
27
|
+
"linux"
|
|
27
28
|
],
|
|
28
29
|
"files": [
|
|
29
30
|
"dist/",
|
package/server/cli.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { judgeErrorDetails } from "./judge-debug.mjs";
|
|
|
16
16
|
import { createCliProgress } from "./progress.mjs";
|
|
17
17
|
import { helperHealthMatches, stopVerifiedStaleHelper } from "./local-helper-runtime.mjs";
|
|
18
18
|
import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
|
|
19
|
+
import { openExternalUrl } from "./platform.mjs";
|
|
19
20
|
|
|
20
21
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const root = path.dirname(here);
|
|
@@ -56,6 +57,11 @@ function formatRange(sessions) {
|
|
|
56
57
|
return `${format.format(values[0])} – ${format.format(values.at(-1))}, ${year}`;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function formatAgentSource(sessions) {
|
|
61
|
+
const present = new Set(sessions.map((session) => session.agent));
|
|
62
|
+
return [["claude", "Claude Code"], ["cowork", "Cowork"], ["codex", "Codex"]].filter(([agent]) => present.has(agent)).map(([, name]) => name).join(" + ");
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
async function optionalAnalysis(promise, label, warnings) {
|
|
60
66
|
try { return await promise; }
|
|
61
67
|
catch (error) {
|
|
@@ -101,7 +107,7 @@ async function ensureServer(demo = false) {
|
|
|
101
107
|
|
|
102
108
|
function openUrl(url) {
|
|
103
109
|
if (process.argv.includes("--no-open")) return;
|
|
104
|
-
|
|
110
|
+
openExternalUrl(url);
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
function printList() {
|
|
@@ -242,6 +248,7 @@ async function createWrapped() {
|
|
|
242
248
|
delete analyzed.stats.topics;
|
|
243
249
|
delete analyzed.stats.topicMethod;
|
|
244
250
|
delete analyzed.interactionCard;
|
|
251
|
+
delete analyzed.interactionReview;
|
|
245
252
|
delete analyzed.workaroundCard;
|
|
246
253
|
delete analyzed.workaroundReview;
|
|
247
254
|
}
|
|
@@ -252,7 +259,7 @@ async function createWrapped() {
|
|
|
252
259
|
const id = createReportId();
|
|
253
260
|
const safeFindings = analyzed.findings.map(({ evidence, method, ...finding }) => finding);
|
|
254
261
|
const hasPrivateWorkaroundEvidence = Boolean(analyzed.workaroundReview?.occurrences?.length || analyzed.workaroundReview?.borderline?.length);
|
|
255
|
-
const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source:
|
|
262
|
+
const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: formatAgentSource(chosenSessions), stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, interactionReview: analyzed.interactionReview, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !localOnly, analysisMode: localOnly ? "local-only" : "remote", leaderboardParticipation: localOnly ? "excluded" : "included-by-default", ...(localOnly ? { transmittedData: `None; ${testMode ? "test" : "local-only"} mode stays on this device.`, externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
|
|
256
263
|
let publicUrl = null;
|
|
257
264
|
if (!localOnly) {
|
|
258
265
|
progress.start("Publishing share-safe Wrapped", "strict aggregate-only schema");
|
package/server/consent.mjs
CHANGED
|
@@ -6,7 +6,7 @@ const purple = "\x1b[38;2;141;92;255m";
|
|
|
6
6
|
const reset = "\x1b[0m";
|
|
7
7
|
|
|
8
8
|
export const remoteAnalysisConsentText = "Behavior Wrapped will send redacted excerpts from your session history to GPT-5.6 Luna via OpenRouter using zero-data-retention providers for analysis. OK to proceed?";
|
|
9
|
-
export const localOnlyAnalysisText = "Local-only analysis keeps all session data on this
|
|
9
|
+
export const localOnlyAnalysisText = "Local-only analysis keeps all session data on this device. It uses deterministic statistics and a locally counted favorite phrase, but omits AI-judged interaction tone, usage topics, and instrumental workarounds. Those omissions leave leaderboard plots 2 and 3 incomplete, so the report will not be published or included in the leaderboard.";
|
|
10
10
|
|
|
11
11
|
export async function requestAnalysisMode({ input = process.stdin, output = process.stdout } = {}) {
|
|
12
12
|
const prompt = createInterface({ input, output });
|
package/server/discovery.mjs
CHANGED
|
@@ -4,10 +4,9 @@ import os from "node:os";
|
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import readline from "node:readline";
|
|
6
6
|
import { semanticToolUse } from "./tool-semantics.mjs";
|
|
7
|
+
import { canonicalSessionRoots } from "./platform.mjs";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
-
const canonicalCodexRoots = [path.join(os.homedir(), ".codex", "sessions"), path.join(os.homedir(), ".codex", "archived_sessions")];
|
|
10
|
-
const canonicalCoworkRoot = path.join(os.homedir(), "Library", "Application Support", "Claude", "local-agent-mode-sessions");
|
|
9
|
+
const { claudeRoot: canonicalClaudeRoot, codexRoots: canonicalCodexRoots, coworkRoot: canonicalCoworkRoot } = canonicalSessionRoots();
|
|
11
10
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
12
11
|
const metadataCacheFile = path.join(process.env.BEHAVIOR_WRAPPED_STORE_ROOT || path.join(os.homedir(), ".agent-behavior-wrapped"), "session-index-v1.json");
|
|
13
12
|
|
|
@@ -247,7 +246,7 @@ async function readCoworkSessionMetadataAsync(file, cache) {
|
|
|
247
246
|
}
|
|
248
247
|
|
|
249
248
|
function recursiveJsonl(root) {
|
|
250
|
-
if (!fs.existsSync(root)) return [];
|
|
249
|
+
if (!root || !fs.existsSync(root)) return [];
|
|
251
250
|
const files = [];
|
|
252
251
|
const stack = [root];
|
|
253
252
|
while (stack.length) {
|
|
@@ -306,7 +305,7 @@ function coworkAuditFiles(root) {
|
|
|
306
305
|
}
|
|
307
306
|
|
|
308
307
|
export function discoverCoworkSessions(root = canonicalCoworkRoot) {
|
|
309
|
-
if (!fs.existsSync(root)) return finishCatalog([], false);
|
|
308
|
+
if (!root || !fs.existsSync(root)) return finishCatalog([], false);
|
|
310
309
|
const sessions = [];
|
|
311
310
|
for (const file of coworkAuditFiles(root)) {
|
|
312
311
|
try { sessions.push(readCoworkSessionMetadata(file)); } catch { /* Skip unreadable sessions. */ }
|
|
@@ -336,14 +335,14 @@ export async function discoverAllSessionsAsync(options = {}) {
|
|
|
336
335
|
}
|
|
337
336
|
}
|
|
338
337
|
}
|
|
339
|
-
if (fs.existsSync(coworkRoot)) for (const file of coworkAuditFiles(coworkRoot)) {
|
|
338
|
+
if (coworkRoot && fs.existsSync(coworkRoot)) for (const file of coworkAuditFiles(coworkRoot)) {
|
|
340
339
|
try { sessions.push(await readCoworkSessionMetadataAsync(file, cache)); } catch { /* Skip unreadable sessions. */ }
|
|
341
340
|
}
|
|
342
341
|
for (const root of codexRoots) for (const file of recursiveJsonl(root)) {
|
|
343
342
|
try { sessions.push(await readSessionMetadata(file, "codex", "Codex project", cache)); } catch { /* Skip unreadable sessions. */ }
|
|
344
343
|
}
|
|
345
344
|
if (persistCache) writeMetadataCache(cache);
|
|
346
|
-
return finishCatalog(sessions, fs.existsSync(claudeRoot) || fs.existsSync(coworkRoot) || codexRoots.some((root) => fs.existsSync(root)));
|
|
345
|
+
return finishCatalog(sessions, fs.existsSync(claudeRoot) || Boolean(coworkRoot && fs.existsSync(coworkRoot)) || codexRoots.some((root) => fs.existsSync(root)));
|
|
347
346
|
}
|
|
348
347
|
|
|
349
348
|
function isoDay(value) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const MAX_OCCURRENCES_PER_KIND = 100;
|
|
2
|
+
|
|
3
|
+
function contentBlocks(record) {
|
|
4
|
+
const content = record?.message?.content ?? record?.content;
|
|
5
|
+
if (Array.isArray(content)) return content;
|
|
6
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function visibleText(record) {
|
|
11
|
+
return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function transcriptRole(record) {
|
|
15
|
+
return record?.type === "assistant" ? "assistant" : record?.type === "user" && !record?.isMeta ? "user" : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function matchingRecordIndex(reference, records) {
|
|
19
|
+
const index = reference?.location?.recordIndex;
|
|
20
|
+
if (Number.isInteger(index) && index >= 0 && index < records.length) return index;
|
|
21
|
+
const timestamp = reference?.location?.timestamp;
|
|
22
|
+
if (!timestamp) return null;
|
|
23
|
+
const fallback = records.findIndex((record) => record?.timestamp === timestamp && record?.type === "user" && !record?.isMeta);
|
|
24
|
+
return fallback >= 0 ? fallback : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function adjacentMessage(records, fromIndex, direction) {
|
|
28
|
+
for (let index = fromIndex + direction; index >= 0 && index < records.length; index += direction) {
|
|
29
|
+
const role = transcriptRole(records[index]);
|
|
30
|
+
const text = visibleText(records[index]);
|
|
31
|
+
if (role && text) return { role, text, timestamp: records[index]?.timestamp || null, highlighted: false };
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exactOccurrence(reference, index, records, metadata) {
|
|
37
|
+
const recordIndex = matchingRecordIndex(reference, records);
|
|
38
|
+
if (recordIndex === null) return null;
|
|
39
|
+
const record = records[recordIndex];
|
|
40
|
+
const text = visibleText(record);
|
|
41
|
+
if (record?.type !== "user" || record?.isMeta || !text) return null;
|
|
42
|
+
const before = adjacentMessage(records, recordIndex, -1);
|
|
43
|
+
const after = adjacentMessage(records, recordIndex, 1);
|
|
44
|
+
return {
|
|
45
|
+
index: index + 1,
|
|
46
|
+
candidateId: reference.candidateId,
|
|
47
|
+
session: {
|
|
48
|
+
label: metadata?.label || `Session ${index + 1}`,
|
|
49
|
+
agentName: metadata?.agentName || "AI agent",
|
|
50
|
+
startedAt: metadata?.startedAt || records.find((item) => item?.timestamp)?.timestamp || null,
|
|
51
|
+
},
|
|
52
|
+
timestamp: record.timestamp || null,
|
|
53
|
+
messages: [before, { role: "user", text, timestamp: record.timestamp || null, highlighted: true }, after].filter(Boolean),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildKind(report, kind, recordsById, metadataById) {
|
|
58
|
+
return (report?.interactionReview?.[kind] || []).slice(0, MAX_OCCURRENCES_PER_KIND).flatMap((reference, index) => {
|
|
59
|
+
const sessionId = reference?.location?.sessionId;
|
|
60
|
+
const records = recordsById.get(sessionId);
|
|
61
|
+
if (!records) return [];
|
|
62
|
+
const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId));
|
|
63
|
+
return occurrence ? [occurrence] : [];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function makeInteractionEvidencePreview(report, sessionRecords, metadataById) {
|
|
68
|
+
const recordsById = new Map(sessionRecords.map((session) => [session.sessionId, session.records]));
|
|
69
|
+
return {
|
|
70
|
+
format: "behavior-wrapped-interaction-evidence-v1",
|
|
71
|
+
localPrivate: true,
|
|
72
|
+
standardRedactionsApplied: false,
|
|
73
|
+
reportId: report?.id,
|
|
74
|
+
frustrated: buildKind(report, "frustrated", recordsById, metadataById),
|
|
75
|
+
grateful: buildKind(report, "grateful", recordsById, metadataById),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -69,8 +69,8 @@ function priority(text, occurrences) {
|
|
|
69
69
|
export function buildInteractionToneCandidates(sessionRecords, { maximumCandidates = INTERACTION_TONE_MAX_CANDIDATES } = {}) {
|
|
70
70
|
const messages = new Map();
|
|
71
71
|
let order = 0;
|
|
72
|
-
for (const { records } of sessionRecords) {
|
|
73
|
-
for (const record of records) {
|
|
72
|
+
for (const { sessionId, records } of sessionRecords) {
|
|
73
|
+
for (const [recordIndex, record] of records.entries()) {
|
|
74
74
|
if (record.type !== "user" || record.isMeta) continue;
|
|
75
75
|
const text = safeInteractionExcerpt(visibleText(record));
|
|
76
76
|
if (!text) continue;
|
|
@@ -78,15 +78,23 @@ export function buildInteractionToneCandidates(sessionRecords, { maximumCandidat
|
|
|
78
78
|
if (!likelyTonePattern.test(text) && words > 40 && !/[!?]{2,}/.test(text)) continue;
|
|
79
79
|
const key = text.normalize("NFKC").toLocaleLowerCase();
|
|
80
80
|
const existing = messages.get(key);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const location = { sessionId, recordIndex, timestamp: record.timestamp || null };
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.occurrences++;
|
|
84
|
+
existing.locations.push(location);
|
|
85
|
+
} else messages.set(key, { text, occurrences: 1, order: order++, locations: [location] });
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
return [...messages.values()]
|
|
86
89
|
.sort((left, right) => priority(right.text, right.occurrences) - priority(left.text, left.occurrences)
|
|
87
90
|
|| right.occurrences - left.occurrences || left.order - right.order)
|
|
88
91
|
.slice(0, Math.min(INTERACTION_TONE_MAX_CANDIDATES, maximumCandidates))
|
|
89
|
-
.map(({ text, occurrences }, index) =>
|
|
92
|
+
.map(({ text, occurrences, locations }, index) => {
|
|
93
|
+
const candidate = { candidate_id: `interaction-${index + 1}`, text, occurrences };
|
|
94
|
+
// Local transcript locations must survive judging without entering the relay payload.
|
|
95
|
+
Object.defineProperty(candidate, "locations", { value: locations, enumerable: false });
|
|
96
|
+
return candidate;
|
|
97
|
+
});
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
export const interactionToneJudgePrompt = `You classify how a user speaks to an AI agent for a playful "Behavior Wrapped" report. Evaluate every supplied excerpt independently.
|
|
@@ -222,6 +230,10 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
|
|
|
222
230
|
occurrences: byId.get(item.candidate_id).occurrences,
|
|
223
231
|
confidence: item.confidence,
|
|
224
232
|
}));
|
|
233
|
+
const reviewRefs = (items) => items.flatMap((item) => (byId.get(item.candidate_id).locations || []).map((location) => ({
|
|
234
|
+
candidateId: item.candidate_id,
|
|
235
|
+
location,
|
|
236
|
+
})));
|
|
225
237
|
return {
|
|
226
238
|
frustratedMessages: count(selection.frustrated),
|
|
227
239
|
gratefulMessages: count(selection.grateful),
|
|
@@ -230,6 +242,11 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
|
|
|
230
242
|
candidateMessages: candidates.reduce((sum, candidate) => sum + candidate.occurrences, 0),
|
|
231
243
|
frustrationQuote: funniest?.text || null,
|
|
232
244
|
privateMatches: { frustrated: matches(selection.frustrated), grateful: matches(selection.grateful) },
|
|
245
|
+
review: {
|
|
246
|
+
format: "behavior-wrapped-interaction-review-v1",
|
|
247
|
+
frustrated: reviewRefs(selection.frustrated),
|
|
248
|
+
grateful: reviewRefs(selection.grateful),
|
|
249
|
+
},
|
|
233
250
|
model,
|
|
234
251
|
provider,
|
|
235
252
|
latencyMs,
|
|
@@ -325,6 +342,7 @@ export function applyInteractionToneJudgment(analyzed, judgment) {
|
|
|
325
342
|
method: judgment.method,
|
|
326
343
|
};
|
|
327
344
|
analyzed.interactionCard = judgment.frustrationQuote ? { frustrationQuote: judgment.frustrationQuote } : null;
|
|
345
|
+
analyzed.interactionReview = judgment.review;
|
|
328
346
|
return analyzed;
|
|
329
347
|
}
|
|
330
348
|
|
package/server/launcher.mjs
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import os from "node:os";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
6
|
import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
|
|
9
7
|
import { makeDonationPreview } from "./analysis.mjs";
|
|
10
8
|
import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
|
|
11
9
|
import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
|
|
12
10
|
import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
|
|
13
11
|
import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
|
|
12
|
+
import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
|
|
14
13
|
import { createIdleShutdownController } from "./local-helper-runtime.mjs";
|
|
14
|
+
import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
|
|
15
15
|
|
|
16
16
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const root = path.dirname(here);
|
|
@@ -80,13 +80,15 @@ async function chosenRecords(ids, options = {}) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function publicCatalog() {
|
|
83
|
+
const agentNames = supportedAgentNames(process.platform, { includeCowork: demo || process.platform === "darwin" });
|
|
83
84
|
return {
|
|
84
85
|
rootAvailable: catalog.rootAvailable,
|
|
85
86
|
demo,
|
|
86
87
|
projects: catalog.projects,
|
|
87
88
|
sessions: catalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
|
|
88
89
|
defaultRange: defaultDateRange(catalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
|
|
89
|
-
|
|
90
|
+
agentNames,
|
|
91
|
+
privacy: { canonicalDirectories: canonicalSessionDirectoryLabels(), networkRequests: "only-after-final-donation-consent" },
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -105,7 +107,7 @@ const server = http.createServer(async (request, response) => {
|
|
|
105
107
|
if (request.method === "GET" && reportMatch) {
|
|
106
108
|
const report = loadReport(reportMatch[1]);
|
|
107
109
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
108
|
-
const { sessionIds, workaroundReview, ...shareSafeReport } = report;
|
|
110
|
+
const { sessionIds, workaroundReview, interactionReview, ...shareSafeReport } = report;
|
|
109
111
|
shareSafeReport.privacy = { ...shareSafeReport.privacy, shareSafe: true, containsTranscriptText: false };
|
|
110
112
|
return json(response, 200, shareSafeReport);
|
|
111
113
|
}
|
|
@@ -126,6 +128,17 @@ const server = http.createServer(async (request, response) => {
|
|
|
126
128
|
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
|
|
127
129
|
return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
|
|
128
130
|
}
|
|
131
|
+
const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
|
|
132
|
+
if (request.method === "GET" && interactionEvidenceMatch) {
|
|
133
|
+
const report = loadReport(interactionEvidenceMatch[1]);
|
|
134
|
+
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
135
|
+
const allowed = new Set(report.sessionIds || []);
|
|
136
|
+
const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || [])];
|
|
137
|
+
const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 200);
|
|
138
|
+
const records = await chosenRecords(ids);
|
|
139
|
+
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
|
|
140
|
+
return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
|
|
141
|
+
}
|
|
129
142
|
if (request.method === "POST" && url.pathname === "/api/donation-preview") {
|
|
130
143
|
const body = await readBody(request);
|
|
131
144
|
const report = loadReport(body.reportId);
|
|
@@ -182,6 +195,7 @@ const idleShutdown = createIdleShutdownController({
|
|
|
182
195
|
server.listen(port, "127.0.0.1", () => {
|
|
183
196
|
const url = `http://localhost:${port}`;
|
|
184
197
|
console.log(`Behavior Wrapped donation helper is ready at ${url}`);
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
const sessionDirectories = canonicalSessionDirectoryLabels().join(", ");
|
|
199
|
+
console.log(demo ? "Using synthetic demo sessions." : `Donation review reads selected sessions locally from ${sessionDirectories}.`);
|
|
200
|
+
if (!process.argv.includes("--no-open") && process.env.NODE_ENV !== "test") openExternalUrl(url);
|
|
187
201
|
});
|
|
@@ -47,21 +47,36 @@ export function isVerifiedLauncherCommand(command, port) {
|
|
|
47
47
|
&& new RegExp(`(?:^|\\s)--port=${escapedPort}(?:\\s|$)`).test(command || "");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return String(output).split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number);
|
|
54
|
-
} catch { return []; }
|
|
50
|
+
function processTools(platform) {
|
|
51
|
+
if (platform === "darwin") return { lsof: ["/usr/sbin/lsof", "lsof"], ps: ["/bin/ps", "ps"] };
|
|
52
|
+
return { lsof: ["lsof", "/usr/bin/lsof"], ps: ["ps", "/bin/ps"] };
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
const
|
|
55
|
+
async function listeningPids(port, runCommand, platform) {
|
|
56
|
+
for (const file of processTools(platform).lsof) {
|
|
57
|
+
try {
|
|
58
|
+
const output = await runCommand(file, ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"]);
|
|
59
|
+
return String(output).split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number);
|
|
60
|
+
} catch { /* Try the next standard location. */ }
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function processCommand(pid, runCommand, platform) {
|
|
66
|
+
for (const file of processTools(platform).ps) {
|
|
67
|
+
try { return await runCommand(file, ["-p", String(pid), "-o", "command="]); }
|
|
68
|
+
catch { /* Try the next standard location. */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function stopVerifiedStaleHelper(port, advertisedPid, { runCommand = run, kill = process.kill, platform = process.platform } = {}) {
|
|
74
|
+
const listeners = await listeningPids(port, runCommand, platform);
|
|
59
75
|
const candidates = Number.isInteger(advertisedPid) && advertisedPid > 1 ? listeners.filter((pid) => pid === advertisedPid) : listeners;
|
|
60
76
|
let stopped = false;
|
|
61
77
|
for (const pid of candidates) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
catch { continue; }
|
|
78
|
+
const command = await processCommand(pid, runCommand, platform);
|
|
79
|
+
if (command === null) continue;
|
|
65
80
|
if (!isVerifiedLauncherCommand(String(command).trim(), port)) continue;
|
|
66
81
|
try { kill(pid, "SIGTERM"); stopped = true; }
|
|
67
82
|
catch (error) { if (error?.code !== "ESRCH") throw error; }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function canonicalSessionRoots({ home = os.homedir(), platform = process.platform } = {}) {
|
|
6
|
+
return {
|
|
7
|
+
claudeRoot: path.join(home, ".claude", "projects"),
|
|
8
|
+
codexRoots: [path.join(home, ".codex", "sessions"), path.join(home, ".codex", "archived_sessions")],
|
|
9
|
+
coworkRoot: platform === "darwin" ? path.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions") : null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function canonicalSessionDirectoryLabels(platform = process.platform) {
|
|
14
|
+
return [
|
|
15
|
+
"~/.claude/projects",
|
|
16
|
+
...(platform === "darwin" ? ["~/Library/Application Support/Claude/local-agent-mode-sessions"] : []),
|
|
17
|
+
"~/.codex/sessions",
|
|
18
|
+
"~/.codex/archived_sessions",
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function supportedAgentNames(platform = process.platform, { includeCowork = platform === "darwin" } = {}) {
|
|
23
|
+
return ["Claude Code", ...(includeCowork ? ["Cowork"] : []), "Codex"];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
27
|
+
if (platform === "darwin") return { file: "open", args: [url] };
|
|
28
|
+
if (platform === "linux") return { file: "xdg-open", args: [url] };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function openExternalUrl(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
33
|
+
const command = browserOpenCommand(url, platform);
|
|
34
|
+
if (!command) return false;
|
|
35
|
+
const child = spawnImpl(command.file, command.args, { detached: true, stdio: "ignore" });
|
|
36
|
+
child.on?.("error", () => {});
|
|
37
|
+
child.unref?.();
|
|
38
|
+
return true;
|
|
39
|
+
}
|