@linxiraos/pi-stats 1.0.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Behavioral metrics extracted from a single user message.
3
+ *
4
+ * Pure, side-effect free. Designed for batch use during session ingestion
5
+ * and standalone testing.
6
+ */
7
+
8
+ export interface UserMessageMetrics {
9
+ /** Total characters of analyzed text. */
10
+ chars: number;
11
+ /** Whitespace-delimited word count. */
12
+ words: number;
13
+ /**
14
+ * Number of "yelling" sentences: sentences where more than half of the
15
+ * alphabetic characters are uppercase (and there are enough letters to
16
+ * make the ratio meaningful - short acronyms like "OK" don't count).
17
+ * A sentence also needs either two uppercase runs ("WHAT THE HELL") or
18
+ * one elongated run ("CMOOON") so a lone acronym/env var ("use JSON",
19
+ * "HOME=/tmp") doesn't register.
20
+ */
21
+ yelling: number;
22
+ /** Profanity hits (word-boundary, case-insensitive). */
23
+ profanity: number;
24
+ /**
25
+ * Catch-all "obviously upset" signal computed on a *prose-only* body
26
+ * (code fences, XML/HTML tags, URLs, file mentions, and quoted lines
27
+ * are stripped first; messages whose remaining prose is >=3 lines score
28
+ * zero because formatted prompts aren't tantrums).
29
+ *
30
+ * Sum of:
31
+ * - drama runs: 3+ `!` / `?` (with `1`-mishit fallout)
32
+ * - interjections, elongated where the short form is ambiguous:
33
+ * `noooo`, `ahhh`, `ugh(h)`, `argh`, `grr`, `stooop`, `whyyy`,
34
+ * `fuuu(ck)`, `wtfff`, `omggg`, `yesss`, `goddd`, `bruhh`
35
+ * - standalone `dude`
36
+ * - sad emoticons: `:(`, `;(`, `:-(((`
37
+ */
38
+ anguish: number;
39
+ /**
40
+ * Corrective negation: the user is telling us we got it wrong.
41
+ *
42
+ * Counted on the same prose-only body as {@link anguish}.
43
+ *
44
+ * - line-leading `nope` / `nah` / `nvm` / `wrong` / `incorrect`
45
+ * (word-bounded, so `now`, `nobody`, `north` don't match)
46
+ * - line-leading `no` when used as an interjection - followed by
47
+ * punctuation, end-of-text, or a discourse word (`no i meant`,
48
+ * `no, wait`). Determiner `no` (`no extensions to the page`,
49
+ * `no auto start`) doesn't count.
50
+ * - `that(?:'s)? not (what|right|it)` and `not what i (meant|asked|said|wanted)`
51
+ * - `makes (no|zero) sense`
52
+ */
53
+ negation: number;
54
+ /**
55
+ * The user is repeating themselves - strong signal the previous turn
56
+ * missed the ask. Counts hits for:
57
+ *
58
+ * - `i (meant|said|asked|told you|already (said|told|did|asked|wrote))`
59
+ * - `(like|as) i (said|told you|asked)`
60
+ * - `still (doesn't|isn't|not|broken|wrong|fails|failing|the same|same)`
61
+ *
62
+ * Bare `still` / `again` are too ambiguous to count alone (they show up
63
+ * in normal speech like "try again" or "still works").
64
+ */
65
+ repetition: number;
66
+ /**
67
+ * Direct second-person reproach pinned on the agent:
68
+ *
69
+ * - `you (didn't|did not|broke|missed|forgot|keep|always|never|still|ignored)`
70
+ * - `why (would|did) (you|u)`
71
+ * - sentence-leading `stop <verb>ing` imperatives
72
+ */
73
+ blame: number;
74
+ }
75
+
76
+ /**
77
+ * Words considered profane/aggressive. Word-boundary, case-insensitive.
78
+ *
79
+ * Broad English coverage: f-/s-word families and their censored variants,
80
+ * mild swears, intelligence-based insults, body-part insults, British/
81
+ * Australian/Irish slang, religious exclamations, and chat acronyms.
82
+ * Curated to exclude racial, homophobic, and other identity slurs, and
83
+ * words whose dominant use in a coding corpus is technical rather than
84
+ * profane (`dummy` data, `blast` radius, config `knob`, `trash` bin,
85
+ * CRUD, `garbage` files) or plain opinion (`useless`, `awful`, `meh`,
86
+ * `hate`). Pure frustration interjections (`ugh`, `argh`, `grr`) are
87
+ * scored as anguish instead.
88
+ */
89
+ const PROFANITY: readonly string[] = [
90
+ // f-word family
91
+ "fuck",
92
+ "fucks",
93
+ "fucked",
94
+ "fucking",
95
+ "fuckin",
96
+ "fucker",
97
+ "fuckers",
98
+ "fuckup",
99
+ "fuckups",
100
+ "fuckhead",
101
+ "fuckheads",
102
+ "fuckface",
103
+ "fuckwit",
104
+ "fuckwits",
105
+ "fucktard",
106
+ "fuckery",
107
+ "fuckoff",
108
+ "motherfucker",
109
+ "motherfuckers",
110
+ "motherfucking",
111
+ "clusterfuck",
112
+ "ratfuck",
113
+ "unfuck",
114
+ // censored / euphemistic f-word
115
+ "fk",
116
+ "fks",
117
+ "fking",
118
+ "fkin",
119
+ "fker",
120
+ "fck",
121
+ "fcks",
122
+ "fcking",
123
+ "fckin",
124
+ "fcker",
125
+ "fuk",
126
+ "fuking",
127
+ "fukin",
128
+ "eff",
129
+ "effs",
130
+ "effed",
131
+ "effing",
132
+ "frick",
133
+ "fricks",
134
+ "fricked",
135
+ "fricking",
136
+ "frickin",
137
+ "freaking",
138
+ "freakin",
139
+ "freaked",
140
+ // s-word family
141
+ "shit",
142
+ "shits",
143
+ "shat",
144
+ "shitty",
145
+ "shittier",
146
+ "shittiest",
147
+ "shite",
148
+ "shites",
149
+ "shited",
150
+ "shitting",
151
+ "shitter",
152
+ "shitters",
153
+ "shithead",
154
+ "shitheads",
155
+ "shitshow",
156
+ "shitstorm",
157
+ "shitstain",
158
+ "shitfaced",
159
+ "shitload",
160
+ "shitbag",
161
+ "shitcan",
162
+ "shitcanned",
163
+ "shitpost",
164
+ "shitposting",
165
+ "bullshit",
166
+ "bullshits",
167
+ "bullshitting",
168
+ "bullshitter",
169
+ "horseshit",
170
+ "batshit",
171
+ "dogshit",
172
+ "dipshit",
173
+ "jackshit",
174
+ "dumbshit",
175
+ "holyshit",
176
+ // mild swears
177
+ "damn",
178
+ "damns",
179
+ "damned",
180
+ "damning",
181
+ "dammit",
182
+ "goddamn",
183
+ "goddamned",
184
+ "goddamnit",
185
+ "goddammit",
186
+ "darn",
187
+ "darns",
188
+ "darned",
189
+ "darnit",
190
+ "dang",
191
+ "danged",
192
+ "dangit",
193
+ "hell",
194
+ "hells",
195
+ "heck",
196
+ "hecks",
197
+ "heckin",
198
+ "gosh",
199
+ "bloody",
200
+ "bollocks",
201
+ "bollox",
202
+ // crap family
203
+ "crap",
204
+ "craps",
205
+ "crappy",
206
+ "crappier",
207
+ "crappiest",
208
+ "crapped",
209
+ "crapping",
210
+ "crapload",
211
+ "crapola",
212
+ // piss family
213
+ "piss",
214
+ "pisses",
215
+ "pissed",
216
+ "pissing",
217
+ "pisser",
218
+ "pisspoor",
219
+ "pisstake",
220
+ "pisshead",
221
+ // ass family
222
+ "ass",
223
+ "asses",
224
+ "asshole",
225
+ "assholes",
226
+ "asshat",
227
+ "asshats",
228
+ "asswipe",
229
+ "asswipes",
230
+ "assclown",
231
+ "assbag",
232
+ "asskisser",
233
+ "dumbass",
234
+ "dumbasses",
235
+ "jackass",
236
+ "jackasses",
237
+ "smartass",
238
+ "smartasses",
239
+ "badass",
240
+ "badasses",
241
+ "lazyass",
242
+ "fatass",
243
+ "hardass",
244
+ "halfass",
245
+ "halfassed",
246
+ "arse",
247
+ "arsed",
248
+ "arsehole",
249
+ "arseholes",
250
+ "arsewipe",
251
+ // bitch family
252
+ "bitch",
253
+ "bitches",
254
+ "bitched",
255
+ "bitching",
256
+ "bitchy",
257
+ "bitchier",
258
+ "bitchiest",
259
+ "sonofabitch",
260
+ "biatch",
261
+ "biotch",
262
+ // strong vulgarity
263
+ "cunt",
264
+ "cunts",
265
+ "cunty",
266
+ "cuntish",
267
+ "twat",
268
+ "twats",
269
+ "twatty",
270
+ "bastard",
271
+ "bastards",
272
+ // body-part insults
273
+ "dick",
274
+ "dicks",
275
+ "dickhead",
276
+ "dickheads",
277
+ "dickish",
278
+ "dickwad",
279
+ "dickwads",
280
+ "dickface",
281
+ "dickbag",
282
+ "prick",
283
+ "pricks",
284
+ "prickish",
285
+ "cock",
286
+ "cocks",
287
+ "cocky",
288
+ "cockier",
289
+ "cockiest",
290
+ "cockhead",
291
+ "cockblock",
292
+ "cocksucker",
293
+ "cocksuckers",
294
+ "knobhead",
295
+ "knobheads",
296
+ "knobend",
297
+ "wanker",
298
+ "wankers",
299
+ "wankery",
300
+ "tosser",
301
+ "tossers",
302
+ "jerkoff",
303
+ "jerkoffs",
304
+ "douche",
305
+ "douches",
306
+ "douchebag",
307
+ "douchebags",
308
+ "douchey",
309
+ "scumbag",
310
+ "scumbags",
311
+ "scum",
312
+ "sleazebag",
313
+ "sleazeball",
314
+ "slimeball",
315
+ "lowlife",
316
+ "lowlifes",
317
+ "deadbeat",
318
+ // intelligence-based insults
319
+ "idiot",
320
+ "idiots",
321
+ "idiotic",
322
+ "idiocy",
323
+ "stupid",
324
+ "stupider",
325
+ "stupidest",
326
+ "stupidity",
327
+ "moron",
328
+ "morons",
329
+ "moronic",
330
+ "imbecile",
331
+ "imbeciles",
332
+ "retard",
333
+ "retards",
334
+ "retarded",
335
+ "dumb",
336
+ "dumber",
337
+ "dumbest",
338
+ "dumbo",
339
+ "fool",
340
+ "fools",
341
+ "foolish",
342
+ "foolery",
343
+ "clown",
344
+ "clowns",
345
+ "clownish",
346
+ "buffoon",
347
+ "buffoons",
348
+ "simpleton",
349
+ "halfwit",
350
+ "halfwits",
351
+ "nitwit",
352
+ "nitwits",
353
+ "dimwit",
354
+ "dimwits",
355
+ "dolt",
356
+ "dolts",
357
+ "doltish",
358
+ "knucklehead",
359
+ "knuckleheads",
360
+ "blockhead",
361
+ "blockheads",
362
+ "lamebrain",
363
+ "airhead",
364
+ "airheads",
365
+ "scatterbrain",
366
+ "numbnuts",
367
+ "numbskull",
368
+ "numpty",
369
+ "numpties",
370
+ "muppet",
371
+ "muppets",
372
+ "pillock",
373
+ "pillocks",
374
+ "plonker",
375
+ "plonkers",
376
+ "prat",
377
+ "prats",
378
+ "berk",
379
+ "berks",
380
+ "ninny",
381
+ "ninnies",
382
+ "dingbat",
383
+ "dingbats",
384
+ "putz",
385
+ "putzes",
386
+ "schmuck",
387
+ "schmucks",
388
+ "jerk",
389
+ "jerks",
390
+ "jerkface",
391
+ "gits",
392
+ "sod",
393
+ "sodding",
394
+ "bugger",
395
+ "buggered",
396
+ // generic aggression / dismissal
397
+ "suck",
398
+ "sucks",
399
+ "sucked",
400
+ "sucking",
401
+ "sucky",
402
+ "suckage",
403
+ "trashy",
404
+ // religious exclamations
405
+ "jesus",
406
+ "christ",
407
+ "jeez",
408
+ "jeezus",
409
+ "sheesh",
410
+ "godsake",
411
+ // chat acronyms
412
+ "wtf",
413
+ "wth",
414
+ "wtaf",
415
+ "stfu",
416
+ "gtfo",
417
+ "omfg",
418
+ "omg",
419
+ "ffs",
420
+ "jfc",
421
+ "kys",
422
+ "fml",
423
+ "smh",
424
+ "smdh",
425
+ "smfh",
426
+ "idgaf",
427
+ "idfc",
428
+ "lmfao",
429
+ "fubar",
430
+ "snafu",
431
+ ];
432
+
433
+ const PROFANITY_RE = new RegExp(String.raw`\b(?:${PROFANITY.join("|")})\b`, "gi");
434
+ const SENTENCE_RE = /[^.!?\n]+/g;
435
+ const LETTER_RE = /\p{L}/gu;
436
+ const UPPER_LETTER_RE = /\p{Lu}/gu;
437
+ const YELLING_MIN_LETTERS = 4;
438
+ const YELLING_THRESHOLD = 0.5;
439
+ // Runs starting with `!` or `?` followed by 2+ of `!?1`. The `1` is the
440
+ // classic shift-key mishit ("!!!111" / "!?!??111") so we count those as
441
+ // part of the same drama burst.
442
+ const DRAMA_RE = /[!?][!?1]{2,}/g;
443
+ const WORD_RE = /\S+/g;
444
+
445
+ // Anguish/exasperation interjections. Each alternative is a case-insensitive
446
+ // word-bounded pattern. Interjections whose short form collides with normal
447
+ // prose ("no", "ahh", "why", "yes", "god") require *real* elongation;
448
+ // unambiguous ones ("ugh", "argh", "grr") match their plain form too.
449
+ // Picked to avoid hex / base64 contamination via the surrounding `\b` plus
450
+ // letter-only alternatives.
451
+ const ANGUISH_PATTERNS: readonly string[] = [
452
+ "no{3,}", // nooo, noooooo
453
+ "a+h{2,}", // ahh, aaaahhh
454
+ "u+r?g+h+", // ugh, ughh, urgh, uuugh
455
+ "a+r+g+h+", // argh, aaargh, arrgghhh
456
+ "g+r{2,}", // grr, grrrr
457
+ "st+o{3,}p+", // stooop, sttooopp
458
+ "w+h+y{3,}", // whyyy, whyyyyy
459
+ "f+u{3,}c*k*", // fuuu, fuuuck
460
+ "wtf{3,}", // wtfff
461
+ "o+m+g{2,}", // omgg, omggg
462
+ "ye+s{3,}", // yesss, yeessss
463
+ "g+o+d{3,}", // goddd, goddddd
464
+ "br+u+h{2,}", // bruhh, bruuuhh
465
+ ];
466
+ const ANGUISH_RE = new RegExp(String.raw`\b(?:${ANGUISH_PATTERNS.join("|")})\b`, "gi");
467
+ const DUDE_RE = /\bdude\b/gi;
468
+ // Sad emoticons (`:(` and friends). Requires a leading boundary so pasted
469
+ // code/regex fragments like `foo:(bar)` don't fire; `\(+` folds `:(((` into
470
+ // one hit. 46 corpus hits, ~90% genuinely deflated.
471
+ const SAD_EMOTICON_RE = /(?<=^|[\s.!?])[:;]-?\(+/g;
472
+ // Dot runs (`..` / `...` / `....`) are deliberately NOT counted: on a real
473
+ // corpus they are dominated by neutral trail-offs, template placeholders
474
+ // (`{{href...}}`) and range syntax, not exasperation.
475
+
476
+ // --- Frustration signals ----------------------------------------------------
477
+ // Each set of patterns below is tuned against ~42k real user prompts so the
478
+ // short-prose hits are dominated by genuine frustration, not technical talk.
479
+
480
+ // Corrective negation. We deliberately anchor to the very start of the
481
+ // trimmed prose body (no `m` flag) - in practice mid-message lines that
482
+ // start with `no`/`Wrong`/`No JSDoc warning` are list items, pasted error
483
+ // text or descriptive statements, not actual corrections. Real frustration
484
+ // negation overwhelmingly opens the message. Bare `no` only counts as an
485
+ // interjection - followed by punctuation, end-of-text, or a discourse word -
486
+ // so determiner uses ("no extensions to the page", "no auto start") don't
487
+ // score. A hyphen only counts as a separator when it doesn't glue a compound
488
+ // (`no - not that` yes, `no-op change` no). Note: the bare-`no` branch
489
+ // deliberately has no trailing `\b`, so emphatic misspellings like `nou ...`
490
+ // / `nono ...` also count (via `no` + follower `u`/`no`) - on the real
491
+ // corpus every such hit is a genuine correction.
492
+ const NEGATION_LEAD_RE =
493
+ /^[ \t]*(?:(?:nope|nah|nvm|wrong|incorrect)\b|no(?=\s*(?:[,.!?;:\u2013\u2014]|-(?!\w)|$|(?:i|im|u|you|ur|we|it|its|that|thats|this|the|they|theyre|he|she|man|dude|bro|wait|dont|not|stop|just|again|please|plz|but|actually|literally|seriously|sorry|no|never|nothing|wtf|why|what|wrong)\b)))/gi;
494
+ const NEGATION_PHRASE_RE =
495
+ /\b(?:that['\u2019]?s\s+not\s+(?:what|right|it)|not\s+what\s+i\s+(?:meant|asked|said|wanted)|makes\s+(?:no|zero)\s+sense)\b/gi;
496
+
497
+ // User repeating themselves. The recall pattern accepts an optional
498
+ // `like ` / `as ` prefix so "like i said" doesn't double-count with bare
499
+ // "i said". Bare `i asked` is too noisy - it's overwhelmingly "i asked
500
+ // <some third party>" in this corpus (committee, experts, weaker LLM, ...) -
501
+ // so we require `i asked you` for that variant. Bare `still` / `again` are
502
+ // ambiguous so we only count `still` when followed by a negative or
503
+ // sameness marker.
504
+ const REPETITION_RECALL_RE =
505
+ /\b(?:(?:like|as)\s+i\s+(?:said|told\s+you|asked)|i\s+(?:meant|said|told\s+you|asked\s+you|already\s+(?:said|told|did|asked|wrote)))\b/gi;
506
+ const REPETITION_STILL_RE =
507
+ /\bstill\s+(?:doesn['\u2019]?t|doesnt|isn['\u2019]?t|isnt|not|broken|wrong|fails|failing|the\s+same|same)\b/gi;
508
+
509
+ // Direct second-person reproach. `you` alone is too generic (>7k hits in
510
+ // short prose), so we anchor it to a small set of accusatory verbs.
511
+ const BLAME_YOU_RE = /\byou\s+(?:didn['\u2019]?t|did\s+not|broke|missed|forgot|keep|always|never|still|ignored)\b/gi;
512
+ // `why would/did you ...` is reproach even when politely phrased. Bare
513
+ // `why you` / `why are you` are dominated by neutral how-does-this-work
514
+ // questions, so only the past/conditional forms count.
515
+ const BLAME_WHY_RE = /\bwhy\s+(?:would|did)\s+(?:you|u)\b/gi;
516
+ // `stop <verb>ing` is only frustration when it's an imperative - require it
517
+ // to start a sentence (line start or after a sentence-terminating punctuator).
518
+ const BLAME_STOP_RE = /(?:^|(?<=[.!?\n]))\s*stop\s+\w+ing\b/gim;
519
+
520
+ // Stripped from the analyzed body before scoring so that structured
521
+ // content (code, XML/HTML, URLs, file mentions, quoted blocks) doesn't
522
+ // pollute behavior signals. We replace with a newline so line counts
523
+ // reflect what was removed instead of merging neighbors.
524
+ const FENCED_CODE_RE = /```[\s\S]*?```/g;
525
+ const XML_TAG_PAIR_RE = /<([A-Za-z][\w-]*)\b[^>]*>[\s\S]*?<\/\1>/g;
526
+ const XML_TAG_BARE_RE = /<\/?[A-Za-z][\w-]*\b[^>]*\/?>/g;
527
+ const INLINE_CODE_RE = /`[^`\n]*`/g;
528
+ const URL_RE = /\bhttps?:\/\/\S+/gi;
529
+ const FILE_MENTION_RE = /(^|\s)@[\w./-]+/g;
530
+ // Dotted tokens: filenames (`AGENTS.md`), dotted identifiers (`Bun.file`),
531
+ // versions (`1.2.3`). Stripped so SENTENCE_RE doesn't split them into
532
+ // all-caps fragments ("Follow AGENTS.md and ..." -> "Follow AGENTS") that
533
+ // register as yelling.
534
+ const DOTTED_TOKEN_RE = /(?<=^|[\s("'[])[\w-]+(?:\.[\w-]+)+(?=$|[\s)"'\],:;!?]|\.(?!\w))/g;
535
+ const QUOTE_LINE_RE = /^[ \t]*>.*$/gm;
536
+ // Harness placeholders the TUI substitutes for binary/non-text user input.
537
+ // Strip them so real frustration signals on later lines aren't masked off
538
+ // by `[Image #1]` etc. consuming line 1.
539
+ const IMAGE_MARKER_RE = /\[Image #\d+\]/g;
540
+ // ANSI escape sequences sometimes leak in from terminal copy-paste
541
+ // (e.g. when the user pastes a bash transcript). Strip them.
542
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
543
+
544
+ // Users don't really get angry with super detailed and formatted prompts
545
+ // - if the remaining prose is this many lines or more, score zero.
546
+ const MAX_PROSE_LINES = 3;
547
+
548
+ /** Count regex hits without materializing the match array. */
549
+ function countMatches(text: string, re: RegExp): number {
550
+ let count = 0;
551
+ re.lastIndex = 0;
552
+ while (re.exec(text) !== null) count++;
553
+ return count;
554
+ }
555
+
556
+ // A sentence needs 2+ uppercase runs ("WHAT THE HELL"), or a single elongated
557
+ // run with a tripled letter ("CMOOON"), before its caps ratio can count as
558
+ // yelling. A lone acronym / identifier / env var ("Follow AGENTS", "use
559
+ // JSON", "HOME=/tmp") never does.
560
+ const UPPER_RUN_RE = /\p{Lu}{2,}/gu;
561
+ const TRIPLED_LETTER_RE = /(\p{Lu})\1\1/u;
562
+
563
+ function isShoutedSentence(sentence: string): boolean {
564
+ const runs = sentence.match(UPPER_RUN_RE);
565
+ if (!runs) return false;
566
+ if (runs.length >= 2) return true;
567
+ return runs[0].length >= YELLING_MIN_LETTERS && TRIPLED_LETTER_RE.test(runs[0]);
568
+ }
569
+
570
+ /**
571
+ * Count sentences where the share of uppercase letters exceeds
572
+ * {@link YELLING_THRESHOLD}. Sentences shorter than
573
+ * {@link YELLING_MIN_LETTERS} alphabetic characters are ignored so that
574
+ * short acronyms ("OK", "WIP", "TODO") don't register as yelling, and the
575
+ * caps must span multiple words (or one elongated shout) - see
576
+ * {@link isShoutedSentence}.
577
+ */
578
+ function countYellingSentences(text: string): number {
579
+ let count = 0;
580
+ SENTENCE_RE.lastIndex = 0;
581
+ let match: RegExpExecArray | null = SENTENCE_RE.exec(text);
582
+ while (match !== null) {
583
+ const sentence = match[0];
584
+ const letters = countMatches(sentence, LETTER_RE);
585
+ if (letters >= YELLING_MIN_LETTERS) {
586
+ const upper = countMatches(sentence, UPPER_LETTER_RE);
587
+ if (upper / letters > YELLING_THRESHOLD && isShoutedSentence(sentence)) count++;
588
+ }
589
+ match = SENTENCE_RE.exec(text);
590
+ }
591
+ return count;
592
+ }
593
+
594
+ /**
595
+ * Strip structured content so that pasted code, harness wrappers, file
596
+ * mentions and quoted blocks don't dilute or fake behavior signals.
597
+ * Each strip is replaced with a newline so subsequent line counting
598
+ * reflects what was removed instead of merging neighbors.
599
+ */
600
+ function stripStructuredContent(text: string): string {
601
+ return text
602
+ .replace(FENCED_CODE_RE, "\n")
603
+ .replace(XML_TAG_PAIR_RE, "\n")
604
+ .replace(XML_TAG_BARE_RE, " ")
605
+ .replace(INLINE_CODE_RE, " ")
606
+ .replace(URL_RE, " ")
607
+ .replace(FILE_MENTION_RE, "$1 ")
608
+ .replace(DOTTED_TOKEN_RE, " ")
609
+ .replace(QUOTE_LINE_RE, "")
610
+ .replace(IMAGE_MARKER_RE, " ")
611
+ .replace(ANSI_ESCAPE_RE, "");
612
+ }
613
+
614
+ function countNonEmptyLines(text: string): number {
615
+ let count = 0;
616
+ for (const line of text.split("\n")) {
617
+ if (line.trim().length > 0) count++;
618
+ }
619
+ return count;
620
+ }
621
+
622
+ /**
623
+ * Compute behavioral metrics for a user message.
624
+ *
625
+ * `text` may be empty or whitespace; in that case every metric is 0.
626
+ */
627
+ export function computeUserMessageMetrics(text: string): UserMessageMetrics {
628
+ const trimmed = text.trim();
629
+ if (!trimmed) {
630
+ return {
631
+ chars: 0,
632
+ words: 0,
633
+ yelling: 0,
634
+ profanity: 0,
635
+ anguish: 0,
636
+ negation: 0,
637
+ repetition: 0,
638
+ blame: 0,
639
+ };
640
+ }
641
+
642
+ const chars = trimmed.length;
643
+ const words = countMatches(trimmed, WORD_RE);
644
+
645
+ // Behavior signals are computed on a stripped prose body; long /
646
+ // well-formatted messages score zero because they are deliberate, not
647
+ // emotional outbursts.
648
+ const prose = stripStructuredContent(trimmed).trim();
649
+ if (!prose || countNonEmptyLines(prose) >= MAX_PROSE_LINES) {
650
+ return {
651
+ chars,
652
+ words,
653
+ yelling: 0,
654
+ profanity: 0,
655
+ anguish: 0,
656
+ negation: 0,
657
+ repetition: 0,
658
+ blame: 0,
659
+ };
660
+ }
661
+
662
+ const anguish =
663
+ countMatches(prose, DRAMA_RE) +
664
+ countMatches(prose, ANGUISH_RE) +
665
+ countMatches(prose, DUDE_RE) +
666
+ countMatches(prose, SAD_EMOTICON_RE);
667
+
668
+ const negation = countMatches(prose, NEGATION_LEAD_RE) + countMatches(prose, NEGATION_PHRASE_RE);
669
+ const repetition = countMatches(prose, REPETITION_RECALL_RE) + countMatches(prose, REPETITION_STILL_RE);
670
+ const blame =
671
+ countMatches(prose, BLAME_YOU_RE) + countMatches(prose, BLAME_WHY_RE) + countMatches(prose, BLAME_STOP_RE);
672
+
673
+ return {
674
+ chars,
675
+ words,
676
+ yelling: countYellingSentences(prose),
677
+ profanity: countMatches(prose, PROFANITY_RE),
678
+ anguish,
679
+ negation,
680
+ repetition,
681
+ blame,
682
+ };
683
+ }
684
+
685
+ /** Empty metrics constant for callers that need a default. */
686
+ export const EMPTY_USER_METRICS: UserMessageMetrics = Object.freeze({
687
+ chars: 0,
688
+ words: 0,
689
+ yelling: 0,
690
+ profanity: 0,
691
+ anguish: 0,
692
+ negation: 0,
693
+ repetition: 0,
694
+ blame: 0,
695
+ });