behavior-wrapped 0.2.11
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/LICENSE +201 -0
- package/README.md +81 -0
- package/dist/assets/index-0bjvY6uC.js +11 -0
- package/dist/assets/index-Cjdrcfkw.css +1 -0
- package/dist/index.html +15 -0
- package/fixtures/codex-sessions/2026/06/07/rollout-2026-06-07T10-00-00-synthetic.jsonl +8 -0
- package/fixtures/projects/notes-lab/33333333-3333-4333-8333-333333333333.jsonl +6 -0
- package/fixtures/projects/synthetic-studio/11111111-1111-4111-8111-111111111111.jsonl +10 -0
- package/fixtures/projects/synthetic-studio/22222222-2222-4222-8222-222222222222.jsonl +10 -0
- package/package.json +68 -0
- package/scripts/benchmark-ngram-extraction.mjs +204 -0
- package/scripts/mine-phrase-families.mjs +419 -0
- package/scripts/review-interaction-tone.mjs +40 -0
- package/scripts/review-workaround-judge.mjs +148 -0
- package/server/analysis.mjs +475 -0
- package/server/cli.mjs +287 -0
- package/server/consent.mjs +19 -0
- package/server/discovery.mjs +370 -0
- package/server/frustration-card.mjs +174 -0
- package/server/instrumental-workarounds.mjs +590 -0
- package/server/interaction-tone.mjs +339 -0
- package/server/judge-debug.mjs +58 -0
- package/server/launcher.mjs +151 -0
- package/server/leaderboard.mjs +98 -0
- package/server/model-names.mjs +14 -0
- package/server/phrase-card.mjs +278 -0
- package/server/privacy.mjs +60 -0
- package/server/progress.mjs +71 -0
- package/server/public-report-schema.mjs +108 -0
- package/server/public-report.mjs +38 -0
- package/server/research-donation-schema.mjs +50 -0
- package/server/research-donation.mjs +28 -0
- package/server/session-topics.mjs +263 -0
- package/server/store.mjs +57 -0
- package/server/tool-semantics.mjs +61 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { safeEvidenceText, redactText } from "./privacy.mjs";
|
|
3
|
+
import { isFrustratedMessage, isGratefulMessage } from "./frustration-card.mjs";
|
|
4
|
+
import { displayModelName } from "./model-names.mjs";
|
|
5
|
+
|
|
6
|
+
export { displayModelName } from "./model-names.mjs";
|
|
7
|
+
|
|
8
|
+
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });
|
|
9
|
+
const stockPhraseDefinitions = [
|
|
10
|
+
{ phrase: "You're right", expression: /\byou['’]re right\b/giu },
|
|
11
|
+
{ phrase: "Say the word", expression: /\bsay the word\b/giu },
|
|
12
|
+
{ phrase: "genuinely", expression: /\bgenuinely\b/giu },
|
|
13
|
+
{ phrase: "one wrinkle", expression: /\bone wrinkle\b/giu },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const languageLexicons = [
|
|
17
|
+
["Spanish", new Set("el la los las una unas para pero porque como esto esta este muy más con del quiero puede puedes hacer gracias ahora".split(" "))],
|
|
18
|
+
["French", new Set("le la les des une pour mais parce avec dans est sont cette ça très plus vous peux peut faire merci maintenant".split(" "))],
|
|
19
|
+
["German", new Set("der die das den dem ein eine für aber weil mit ist sind diese sehr mehr ich du können bitte danke jetzt".split(" "))],
|
|
20
|
+
["Portuguese", new Set("uma para mas porque com isso esta este muito mais você pode fazer obrigado agora não".split(" "))],
|
|
21
|
+
["Italian", new Set("il lo la gli le una per ma perché con questo questa molto più puoi fare grazie adesso non".split(" "))],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const topicRules = [
|
|
25
|
+
["Coding", /\b(?:code|coding|bug|function|class|api|database|component|frontend|backend|deploy|repository|repo|git|test|typescript|javascript|python|react|npm|css|html|sql|terminal|command|build|implement|refactor|debug|package|server|cli|script|compile|lint|endpoint|schema|migration)\b|\.(?:js|jsx|ts|tsx|py|rs|go|java|rb|css|html|sql|json|yaml|yml)\b/gi],
|
|
26
|
+
["Writing", /\b(?:write|rewrite|edit|draft|copy|essay|article|email|post|tone|grammar|wording|proofread|document|memo|blog|story|resume|cover letter|headline|paragraph)\b/gi],
|
|
27
|
+
["Personal advice", /\b(?:personal advice|relationship|partner|friend|family|career|life advice|anxious|anxiety|stressed|feel|feeling|should i|help me decide|therapy|therapist|breakup|dating)\b/gi],
|
|
28
|
+
["Research & search", /\b(?:search|find|look up|research|compare|comparison|what is|who is|when did|sources?|citations?|latest|recommend|recommendation|investigate|explain|overview)\b/gi],
|
|
29
|
+
["Planning", /\b(?:plan|roadmap|schedule|itinerary|organize|prioritize|steps|strategy|milestones?|timeline|prepare|checklist|agenda|project plan)\b/gi],
|
|
30
|
+
["Data & analysis", /\b(?:analyze|analysis|data|dataset|spreadsheet|csv|metrics?|chart|statistics?|trend|distribution|correlation|survey|dashboard|visualization)\b/gi],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function contentBlocks(record) {
|
|
34
|
+
const content = record?.message?.content ?? record?.content;
|
|
35
|
+
if (Array.isArray(content)) return content;
|
|
36
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function visibleText(record) {
|
|
41
|
+
return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function wordCount(value) {
|
|
45
|
+
let count = 0;
|
|
46
|
+
for (const part of wordSegmenter.segment(value)) if (part.isWordLike) count++;
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function proseText(value) {
|
|
51
|
+
return String(value || "")
|
|
52
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
53
|
+
.replace(/`[^`\n]+`/g, " ")
|
|
54
|
+
.replace(/^\s*>.*$/gm, " ")
|
|
55
|
+
.replace(/https?:\/\/\S+/g, " ")
|
|
56
|
+
.replace(/\b(?:[A-Za-z]:)?[/.~][^\s]+/g, " ")
|
|
57
|
+
.replace(/<[^>]+>/g, " ")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stockPhraseCounts(texts) {
|
|
63
|
+
return stockPhraseDefinitions.map(({ phrase, expression }) => ({
|
|
64
|
+
phrase,
|
|
65
|
+
count: texts.reduce((sum, value) => {
|
|
66
|
+
expression.lastIndex = 0;
|
|
67
|
+
return sum + [...proseText(value).matchAll(expression)].length;
|
|
68
|
+
}, 0),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const anomalyScripts = [
|
|
73
|
+
{ language: "Japanese", locale: "ja", expression: /[\p{Script=Hiragana}\p{Script=Katakana}]/gu },
|
|
74
|
+
{ language: "Korean", locale: "ko", expression: /\p{Script=Hangul}/gu },
|
|
75
|
+
{ language: "Chinese", locale: "zh", expression: /\p{Script=Han}/gu },
|
|
76
|
+
{ language: "Arabic", locale: "ar", expression: /\p{Script=Arabic}/gu },
|
|
77
|
+
{ language: "Hebrew", locale: "he", expression: /\p{Script=Hebrew}/gu },
|
|
78
|
+
{ language: "Hindi", locale: "hi", expression: /\p{Script=Devanagari}/gu },
|
|
79
|
+
{ language: "Thai", locale: "th", expression: /\p{Script=Thai}/gu },
|
|
80
|
+
{ language: "Cyrillic", locale: "ru", expression: /\p{Script=Cyrillic}/gu },
|
|
81
|
+
];
|
|
82
|
+
const anomalySegmenters = new Map(anomalyScripts.map(({ language, locale }) => [language, new Intl.Segmenter(locale, { granularity: "word" })]));
|
|
83
|
+
|
|
84
|
+
function scriptWords(value, script) {
|
|
85
|
+
script.expression.lastIndex = 0;
|
|
86
|
+
if (!script.expression.test(value)) return 0;
|
|
87
|
+
if (script.language === "Chinese" && /[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(value)) return 0;
|
|
88
|
+
let words = 0;
|
|
89
|
+
for (const part of anomalySegmenters.get(script.language).segment(value)) {
|
|
90
|
+
script.expression.lastIndex = 0;
|
|
91
|
+
if (part.isWordLike && script.expression.test(part.segment)) words++;
|
|
92
|
+
}
|
|
93
|
+
return words;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function containsScript(value, script) {
|
|
97
|
+
script.expression.lastIndex = 0;
|
|
98
|
+
return script.expression.test(String(value || ""));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function languageAnomalyBreakdown(sessionRecords) {
|
|
102
|
+
const totals = new Map();
|
|
103
|
+
for (const { records } of sessionRecords) {
|
|
104
|
+
const allAssistantProse = records.filter((record) => record.type === "assistant").map((record) => proseText(visibleText(record))).filter(Boolean).join(" ");
|
|
105
|
+
const dominantLanguage = languageForChunk(allAssistantProse);
|
|
106
|
+
if (!dominantLanguage) continue;
|
|
107
|
+
let precedingUser = "";
|
|
108
|
+
let responseParts = [];
|
|
109
|
+
const inspectResponse = () => {
|
|
110
|
+
const response = responseParts.join(" ");
|
|
111
|
+
responseParts = [];
|
|
112
|
+
if (!response) return;
|
|
113
|
+
for (const script of anomalyScripts) {
|
|
114
|
+
if (script.language === dominantLanguage || containsScript(precedingUser, script)) continue;
|
|
115
|
+
const words = scriptWords(response, script);
|
|
116
|
+
if (words < 2) continue;
|
|
117
|
+
const item = totals.get(script.language) || { language: script.language, words: 0, occurrences: 0 };
|
|
118
|
+
item.words += words;
|
|
119
|
+
item.occurrences++;
|
|
120
|
+
totals.set(script.language, item);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for (const record of records) {
|
|
124
|
+
const text = visibleText(record);
|
|
125
|
+
if (record.type === "user" && !record.isMeta && text) {
|
|
126
|
+
inspectResponse();
|
|
127
|
+
precedingUser = proseText(text);
|
|
128
|
+
} else if (record.type === "assistant" && text) responseParts.push(proseText(text));
|
|
129
|
+
}
|
|
130
|
+
inspectResponse();
|
|
131
|
+
}
|
|
132
|
+
const languages = [...totals.values()].sort((left, right) => right.words - left.words || right.occurrences - left.occurrences);
|
|
133
|
+
return languages.length ? { ...languages[0], languages } : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function scriptCount(value, expression) {
|
|
137
|
+
return value.match(expression)?.length || 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function languageForChunk(value) {
|
|
141
|
+
const letters = scriptCount(value, /\p{L}/gu);
|
|
142
|
+
if (!letters) return null;
|
|
143
|
+
const scripts = [
|
|
144
|
+
["Japanese", scriptCount(value, /[\p{Script=Hiragana}\p{Script=Katakana}]/gu)],
|
|
145
|
+
["Korean", scriptCount(value, /\p{Script=Hangul}/gu)],
|
|
146
|
+
["Chinese", scriptCount(value, /\p{Script=Han}/gu)],
|
|
147
|
+
["Arabic", scriptCount(value, /\p{Script=Arabic}/gu)],
|
|
148
|
+
["Hebrew", scriptCount(value, /\p{Script=Hebrew}/gu)],
|
|
149
|
+
["Hindi", scriptCount(value, /\p{Script=Devanagari}/gu)],
|
|
150
|
+
["Thai", scriptCount(value, /\p{Script=Thai}/gu)],
|
|
151
|
+
["Cyrillic", scriptCount(value, /\p{Script=Cyrillic}/gu)],
|
|
152
|
+
];
|
|
153
|
+
const [script, count] = scripts.sort((left, right) => right[1] - left[1])[0];
|
|
154
|
+
if (count >= 2 && count / letters >= 0.15) return script;
|
|
155
|
+
const words = value.toLocaleLowerCase().match(/\p{Script=Latin}+(?:['’]\p{Script=Latin}+)*/gu) || [];
|
|
156
|
+
if (!words.length) return null;
|
|
157
|
+
const uniqueWords = new Set(words);
|
|
158
|
+
let best = ["English", 0];
|
|
159
|
+
for (const [language, lexicon] of languageLexicons) {
|
|
160
|
+
const score = [...uniqueWords].reduce((sum, word) => sum + (lexicon.has(word) ? 1 : 0), 0);
|
|
161
|
+
if (score > best[1]) best = [language, score];
|
|
162
|
+
}
|
|
163
|
+
return best[1] >= 2 ? best[0] : "English";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function languageBreakdown(texts) {
|
|
167
|
+
const counts = new Map();
|
|
168
|
+
for (const value of texts) {
|
|
169
|
+
const prose = proseText(value);
|
|
170
|
+
if (!prose) continue;
|
|
171
|
+
for (const chunk of prose.split(/(?<=[.!?。!?])\s+|\n+/)) {
|
|
172
|
+
const words = wordCount(chunk);
|
|
173
|
+
const language = words >= 2 ? languageForChunk(chunk) : null;
|
|
174
|
+
if (language) counts.set(language, (counts.get(language) || 0) + words);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const total = [...counts.values()].reduce((sum, value) => sum + value, 0);
|
|
178
|
+
return [...counts]
|
|
179
|
+
.sort((left, right) => right[1] - left[1])
|
|
180
|
+
.map(([language, words]) => ({ language, words, percentage: total ? Number((words / total * 100).toFixed(1)) : 0 }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function topicScores(value) {
|
|
184
|
+
return topicRules.map(([topic, pattern]) => {
|
|
185
|
+
pattern.lastIndex = 0;
|
|
186
|
+
return [topic, [...String(value || "").matchAll(pattern)].length];
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function topicBreakdown(sessionRecords) {
|
|
191
|
+
const counts = new Map();
|
|
192
|
+
let total = 0;
|
|
193
|
+
for (const { records } of sessionRecords) {
|
|
194
|
+
let previousTopic = null;
|
|
195
|
+
for (const record of records) {
|
|
196
|
+
if (record.type !== "user" || record.isMeta) continue;
|
|
197
|
+
const text = visibleText(record);
|
|
198
|
+
if (!text) continue;
|
|
199
|
+
const scores = topicScores(text).sort((left, right) => right[1] - left[1]);
|
|
200
|
+
let topic = scores[0][1] > 0 ? scores[0][0] : null;
|
|
201
|
+
if (!topic && wordCount(text) <= 8) topic = previousTopic;
|
|
202
|
+
topic ||= "Other";
|
|
203
|
+
if (topic !== "Other") previousTopic = topic;
|
|
204
|
+
counts.set(topic, (counts.get(topic) || 0) + 1);
|
|
205
|
+
total++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...counts]
|
|
209
|
+
.sort((left, right) => right[1] - left[1])
|
|
210
|
+
.map(([topic, prompts]) => ({ topic, prompts, percentage: total ? Number((prompts / total * 100).toFixed(1)) : 0 }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toolUses(record) {
|
|
214
|
+
return contentBlocks(record).filter((block) => block?.type === "tool_use");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isToolError(record) {
|
|
218
|
+
if (record?.type !== "user") return false;
|
|
219
|
+
return contentBlocks(record).some((block) => block?.type === "tool_result" && (block.is_error || block.isError));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function day(timestamp) {
|
|
223
|
+
return timestamp ? new Date(timestamp).toISOString().slice(0, 10) : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function confidence(score) {
|
|
227
|
+
return { score, label: score >= 0.78 ? "High" : score >= 0.56 ? "Medium" : "Low" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function excerptAround(records, center, sessionId) {
|
|
231
|
+
const lines = [];
|
|
232
|
+
for (let i = Math.max(0, center - 1); i <= Math.min(records.length - 1, center + 2); i++) {
|
|
233
|
+
const role = records[i].type;
|
|
234
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
235
|
+
const text = visibleText(records[i]);
|
|
236
|
+
if (!text) continue;
|
|
237
|
+
lines.push({ role, text: safeEvidenceText(text) });
|
|
238
|
+
}
|
|
239
|
+
return { id: crypto.randomUUID(), sessionId, lines };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function finding(kind, title, summary, method, score, evidence) {
|
|
243
|
+
return { id: crypto.randomUUID(), kind, title, summary, method, confidence: confidence(score), evidence };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ratesFor(model, agent) {
|
|
247
|
+
const value = String(model || "").toLowerCase();
|
|
248
|
+
if (value.includes("opus")) return { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 };
|
|
249
|
+
if (value.includes("sonnet")) return { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
250
|
+
if (value.includes("haiku")) return { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 };
|
|
251
|
+
if (agent === "codex" || value.startsWith("gpt")) return { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 };
|
|
252
|
+
return { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function analyzeBehavior(sessionRecords) {
|
|
256
|
+
const findings = [];
|
|
257
|
+
for (const { sessionId, records } of sessionRecords) {
|
|
258
|
+
for (let i = 0; i < records.length; i++) {
|
|
259
|
+
const record = records[i];
|
|
260
|
+
const text = visibleText(record);
|
|
261
|
+
|
|
262
|
+
if (record.type === "assistant" && /\b(done|completed|fixed|implemented|all set|finished)\b/i.test(text)) {
|
|
263
|
+
const nearby = records.slice(Math.max(0, i - 7), i + 1).flatMap(toolUses).map((t) => String(t.name || "").toLowerCase());
|
|
264
|
+
const verified = nearby.some((name) => /(test|check|lint|build|verify|browser|screenshot)/.test(name));
|
|
265
|
+
if (!verified) findings.push(finding(
|
|
266
|
+
"verification", "Completion claim lacked visible verification", "The agent used completion language without a nearby visible test or verification tool call.",
|
|
267
|
+
"Looks for completion phrases, then checks the preceding seven records for test, build, lint, browser, or verification tools. This can miss verification described only in prose.",
|
|
268
|
+
0.72, excerptAround(records, i, sessionId)
|
|
269
|
+
));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (record.type === "user" && /\b(no[, ]|actually|that(?:'s| is) (?:wrong|not)|stop|you missed|instead)\b/i.test(text)) {
|
|
273
|
+
const next = records.slice(i + 1).findIndex((r) => r.type === "assistant" && visibleText(r));
|
|
274
|
+
if (next >= 0) {
|
|
275
|
+
const index = i + 1 + next;
|
|
276
|
+
const response = visibleText(records[index]);
|
|
277
|
+
const adapted = /\b(sorry|you're right|you are right|understood|thanks for|let me correct|i'll adjust|i will adjust)\b/i.test(response);
|
|
278
|
+
findings.push(finding(
|
|
279
|
+
"correction", adapted ? "Agent visibly reset after pushback" : "Correction received without an explicit reset",
|
|
280
|
+
adapted ? "After user pushback, the next response acknowledged or reframed the approach." : "After user pushback, the next response did not visibly acknowledge the correction.",
|
|
281
|
+
"Detects correction language in a user message and checks the next assistant message for acknowledgment or course-correction phrases. Tone and implicit adaptation are hard to infer.",
|
|
282
|
+
adapted ? 0.81 : 0.58, excerptAround(records, i, sessionId)
|
|
283
|
+
));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (isToolError(record)) {
|
|
288
|
+
const failedUse = [...records.slice(Math.max(0, i - 3), i).flatMap(toolUses)].at(-1);
|
|
289
|
+
if (failedUse) {
|
|
290
|
+
const failedName = failedUse.name;
|
|
291
|
+
const repeatedAt = records.slice(i + 1, i + 7).findIndex((r) => toolUses(r).some((t) => t.name === failedName));
|
|
292
|
+
if (repeatedAt >= 0) findings.push(finding(
|
|
293
|
+
"repetition", "An unsuccessful tool approach was repeated", `After a tool error, the agent used ${failedName || "the same tool"} again within six records.`,
|
|
294
|
+
"Pairs an explicit tool-result error with another call to the same tool shortly afterward. It does not inspect raw tool inputs, so a materially improved retry may be counted.",
|
|
295
|
+
0.66, excerptAround(records, i, sessionId)
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (record.type === "user" && /\b(delete|remove|publish|deploy|send|email|pay|purchase|production|all files|everything)\b/i.test(text)) {
|
|
301
|
+
const nextAssistantIndex = records.slice(i + 1, i + 5).findIndex((r) => r.type === "assistant" && visibleText(r));
|
|
302
|
+
if (nextAssistantIndex >= 0) {
|
|
303
|
+
const index = i + 1 + nextAssistantIndex;
|
|
304
|
+
const response = visibleText(records[index]);
|
|
305
|
+
if (/\?/.test(response) && /\b(confirm|which|should|do you want|before i|scope|exactly)\b/i.test(response)) findings.push(finding(
|
|
306
|
+
"clarification", "Clarified before potentially risky work", "The agent asked a scoping or confirmation question before proceeding with a potentially consequential request.",
|
|
307
|
+
"Flags risk-related verbs in the request, then looks for a question with confirmation or scope language in the next assistant response.",
|
|
308
|
+
0.76, excerptAround(records, i, sessionId)
|
|
309
|
+
));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (record.type === "assistant" && /\b(while i(?:'m| am) at it|also went ahead|additionally,? i|beyond that|as a bonus)\b/i.test(text)) findings.push(finding(
|
|
314
|
+
"scope", "Agent signaled a possible scope expansion", "The agent described additional work beyond the immediate task; whether it was helpful or unwanted needs human review.",
|
|
315
|
+
"Looks for explicit phrases that introduce extra work. It does not decide whether that extra work was appropriate.",
|
|
316
|
+
0.61, excerptAround(records, i, sessionId)
|
|
317
|
+
));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const deduped = [];
|
|
321
|
+
const counts = new Map();
|
|
322
|
+
for (const item of findings) {
|
|
323
|
+
const count = counts.get(item.kind) || 0;
|
|
324
|
+
if (count < 3) deduped.push(item);
|
|
325
|
+
counts.set(item.kind, count + 1);
|
|
326
|
+
}
|
|
327
|
+
return deduped;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function analyzeSessions(sessionRecords) {
|
|
331
|
+
const toolCounts = new Map();
|
|
332
|
+
const agentCounts = new Map([["claude", 0], ["codex", 0]]);
|
|
333
|
+
const modelTokens = new Map();
|
|
334
|
+
const activeDays = new Set();
|
|
335
|
+
let prompts = 0;
|
|
336
|
+
let toolCalls = 0;
|
|
337
|
+
let interruptions = 0;
|
|
338
|
+
let totalDurationMs = 0;
|
|
339
|
+
let tokens = 0;
|
|
340
|
+
let estimatedCostUsd = 0;
|
|
341
|
+
let userInputWords = 0;
|
|
342
|
+
let userInputCount = 0;
|
|
343
|
+
let agentResponseWords = 0;
|
|
344
|
+
let agentResponseCount = 0;
|
|
345
|
+
let frustratedMessages = 0;
|
|
346
|
+
let gratefulMessages = 0;
|
|
347
|
+
const assistantProse = [];
|
|
348
|
+
const sessionTurnCounts = [];
|
|
349
|
+
for (const { records, agent = "claude" } of sessionRecords) {
|
|
350
|
+
agentCounts.set(agent, (agentCounts.get(agent) || 0) + 1);
|
|
351
|
+
const timestamps = records.map((r) => r.timestamp).filter(Boolean).map((value) => new Date(value).getTime()).filter(Number.isFinite);
|
|
352
|
+
if (timestamps.length > 1) totalDurationMs += Math.max(...timestamps) - Math.min(...timestamps);
|
|
353
|
+
let currentResponseWords = 0;
|
|
354
|
+
let hasCurrentPrompt = false;
|
|
355
|
+
let sessionTurns = 0;
|
|
356
|
+
const finishResponse = () => {
|
|
357
|
+
if (hasCurrentPrompt && currentResponseWords > 0) {
|
|
358
|
+
agentResponseWords += currentResponseWords;
|
|
359
|
+
agentResponseCount++;
|
|
360
|
+
}
|
|
361
|
+
currentResponseWords = 0;
|
|
362
|
+
};
|
|
363
|
+
for (const record of records) {
|
|
364
|
+
const text = visibleText(record);
|
|
365
|
+
if (record.type === "user" && !record.isMeta && text) {
|
|
366
|
+
finishResponse();
|
|
367
|
+
hasCurrentPrompt = true;
|
|
368
|
+
userInputWords += wordCount(text);
|
|
369
|
+
userInputCount++;
|
|
370
|
+
sessionTurns++;
|
|
371
|
+
if (isFrustratedMessage(text)) frustratedMessages++;
|
|
372
|
+
if (isGratefulMessage(text)) gratefulMessages++;
|
|
373
|
+
} else if (record.type === "assistant" && hasCurrentPrompt && text) {
|
|
374
|
+
currentResponseWords += wordCount(text);
|
|
375
|
+
}
|
|
376
|
+
if (record.type === "assistant" && text) assistantProse.push(text);
|
|
377
|
+
const d = day(record.timestamp);
|
|
378
|
+
if (d) activeDays.add(d);
|
|
379
|
+
if (record.type === "user" && !record.isMeta && text) prompts++;
|
|
380
|
+
if (record.type === "system" && /interrupt/i.test(`${record.subtype || ""} ${record.content || ""}`)) interruptions++;
|
|
381
|
+
const usage = record?.message?.usage;
|
|
382
|
+
if (usage) {
|
|
383
|
+
const input = Number(usage.input_tokens) || 0;
|
|
384
|
+
const output = Number(usage.output_tokens) || 0;
|
|
385
|
+
const cacheWrite = Number(usage.cache_creation_input_tokens) || 0;
|
|
386
|
+
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
387
|
+
const recordTokens = input + output + cacheWrite + cacheRead;
|
|
388
|
+
tokens += recordTokens;
|
|
389
|
+
const model = record?.message?.model || `${agent === "codex" ? "Codex" : "Claude"} model`;
|
|
390
|
+
modelTokens.set(model, (modelTokens.get(model) || 0) + recordTokens);
|
|
391
|
+
const rates = ratesFor(model, agent);
|
|
392
|
+
estimatedCostUsd += (input * rates.input + output * rates.output + cacheWrite * rates.cacheWrite + cacheRead * rates.cacheRead) / 1_000_000;
|
|
393
|
+
}
|
|
394
|
+
for (const tool of toolUses(record)) {
|
|
395
|
+
toolCalls++;
|
|
396
|
+
const name = String(tool.name || "Unknown tool");
|
|
397
|
+
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
finishResponse();
|
|
401
|
+
sessionTurnCounts.push(sessionTurns);
|
|
402
|
+
}
|
|
403
|
+
const tools = [...toolCounts].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([name, count]) => ({ name, count }));
|
|
404
|
+
const totalSessions = sessionRecords.length;
|
|
405
|
+
const claudePercentage = totalSessions ? Number(((agentCounts.get("claude") || 0) / totalSessions * 100).toFixed(1)) : 0;
|
|
406
|
+
const agents = [
|
|
407
|
+
{ agent: "claude", name: "Claude Code", count: agentCounts.get("claude") || 0, percentage: claudePercentage },
|
|
408
|
+
{ agent: "codex", name: "Codex", count: agentCounts.get("codex") || 0, percentage: totalSessions ? Number((100 - claudePercentage).toFixed(1)) : 0 },
|
|
409
|
+
].sort((left, right) => right.percentage - left.percentage || right.count - left.count || left.name.localeCompare(right.name));
|
|
410
|
+
const models = [...modelTokens].sort((left, right) => right[1] - left[1]).map(([model, modelTokenCount]) => ({
|
|
411
|
+
model: String(model),
|
|
412
|
+
name: displayModelName(model),
|
|
413
|
+
tokens: modelTokenCount,
|
|
414
|
+
percentage: tokens ? Number((modelTokenCount / tokens * 100).toFixed(1)) : 0,
|
|
415
|
+
}));
|
|
416
|
+
const stats = {
|
|
417
|
+
sessions: totalSessions,
|
|
418
|
+
activeDays: activeDays.size,
|
|
419
|
+
durationMinutes: Math.round(totalDurationMs / 60000),
|
|
420
|
+
prompts,
|
|
421
|
+
toolCalls,
|
|
422
|
+
interruptions,
|
|
423
|
+
tokens,
|
|
424
|
+
agentWords: agentResponseWords,
|
|
425
|
+
userWords: userInputWords,
|
|
426
|
+
agentUserWordRatio: userInputWords ? Number((agentResponseWords / userInputWords).toFixed(2)) : null,
|
|
427
|
+
averageAgentResponseWords: agentResponseCount ? Math.round(agentResponseWords / agentResponseCount) : 0,
|
|
428
|
+
averageUserInputWords: userInputCount ? Math.round(userInputWords / userInputCount) : 0,
|
|
429
|
+
longestSessionTurns: Math.max(0, ...sessionTurnCounts),
|
|
430
|
+
sessionTurnCounts: [...sessionTurnCounts].sort((left, right) => left - right),
|
|
431
|
+
sessionTurnMethod: "Counts each visible, non-meta user message as one turn.",
|
|
432
|
+
interactionTone: {
|
|
433
|
+
frustratedMessages,
|
|
434
|
+
gratefulMessages,
|
|
435
|
+
analyzedMessages: userInputCount,
|
|
436
|
+
method: "Counts user messages matching conservative frustration or gratitude phrase patterns; this is an approximate tone signal, not a judgment of emotion.",
|
|
437
|
+
},
|
|
438
|
+
stockPhrases: stockPhraseCounts(assistantProse),
|
|
439
|
+
outputLanguages: languageBreakdown(assistantProse),
|
|
440
|
+
languageAnomaly: languageAnomalyBreakdown(sessionRecords),
|
|
441
|
+
languageMethod: "Estimates natural-language word share in assistant text after removing fenced code, inline code, URLs, paths, and markup. Script detection and small Latin-language lexicons are approximate.",
|
|
442
|
+
topics: topicBreakdown(sessionRecords),
|
|
443
|
+
topicMethod: "Assigns each user prompt to its highest-scoring local keyword category; short follow-ups inherit the preceding topic in that session.",
|
|
444
|
+
tools,
|
|
445
|
+
agents,
|
|
446
|
+
models,
|
|
447
|
+
estimatedCostUsd: Number(estimatedCostUsd.toFixed(2)),
|
|
448
|
+
costEstimateMethod: "API-equivalent estimate using a local, inspectable model-family rate table.",
|
|
449
|
+
};
|
|
450
|
+
return { stats, findings: analyzeBehavior(sessionRecords) };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function donationText(value) {
|
|
454
|
+
return String(value || "")
|
|
455
|
+
.replace(/```[\s\S]*?```/g, "[CODE REMOVED]")
|
|
456
|
+
.replace(/`[^`\n]+`/g, "[INLINE CODE REMOVED]")
|
|
457
|
+
.replace(/https?:\/\/\S+/g, "[URL REMOVED]")
|
|
458
|
+
.replace(/(?:[A-Za-z]:\\|\/(?:Users|home|private|tmp|var|opt)\/)[^\s,;:)]+/g, "[PATH REMOVED]");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function makeDonationPreview(sessionRecords, metadataById) {
|
|
462
|
+
let detectionCount = 0;
|
|
463
|
+
const sessions = sessionRecords.map(({ sessionId, records }) => {
|
|
464
|
+
const messages = records.flatMap((record) => {
|
|
465
|
+
if (record.type !== "user" && record.type !== "assistant") return [];
|
|
466
|
+
const value = visibleText(record);
|
|
467
|
+
if (!value) return [];
|
|
468
|
+
const redacted = redactText(donationText(value));
|
|
469
|
+
detectionCount += redacted.detections.length;
|
|
470
|
+
return [{ role: record.type, timestamp: record.timestamp || null, text: redacted.text }];
|
|
471
|
+
});
|
|
472
|
+
return { sessionId, label: metadataById.get(sessionId)?.label || `Session ${sessionId.slice(0, 6)}`, messages };
|
|
473
|
+
});
|
|
474
|
+
return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, detectionCount, sessions };
|
|
475
|
+
}
|