@oh-my-pi/omp-stats 16.3.8 → 16.3.9

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.
@@ -99,13 +99,13 @@ export interface UserMessageStats {
99
99
  yelling: number;
100
100
  /** Profanity hits */
101
101
  profanity: number;
102
- /** Catch-all upset signal: drama runs + `noooo`/`ughh`/... + `dude` + `..` */
102
+ /** Catch-all upset signal: drama runs + `noooo`/`ughh`/... + `dude` + `:(` */
103
103
  anguish: number;
104
104
  /** Corrective negation ("no", "nope", "thats not what i meant") */
105
105
  negation: number;
106
106
  /** User repeating themselves ("i meant", "still doesnt work", "like i said") */
107
107
  repetition: number;
108
- /** Second-person reproach ("you didnt", "you broke", "stop X-ing") */
108
+ /** Second-person reproach ("you didnt", "why did you", "stop X-ing") */
109
109
  blame: number;
110
110
  }
111
111
  /**
@@ -13,6 +13,9 @@ export interface UserMessageMetrics {
13
13
  * Number of "yelling" sentences: sentences where more than half of the
14
14
  * alphabetic characters are uppercase (and there are enough letters to
15
15
  * make the ratio meaningful - short acronyms like "OK" don't count).
16
+ * A sentence also needs either two uppercase runs ("WHAT THE HELL") or
17
+ * one elongated run ("CMOOON") so a lone acronym/env var ("use JSON",
18
+ * "HOME=/tmp") doesn't register.
16
19
  */
17
20
  yelling: number;
18
21
  /** Profanity hits (word-boundary, case-insensitive). */
@@ -25,11 +28,11 @@ export interface UserMessageMetrics {
25
28
  *
26
29
  * Sum of:
27
30
  * - drama runs: 3+ `!` / `?` (with `1`-mishit fallout)
28
- * - elongated interjections: `noooo`, `ahhhh`, `ughhh`, `argh`, `stooop`,
29
- * `whyyy`, `fuuu(ck)`, `shiiit`, `wtfff`, `omggg`, `yessss`, `helpp`,
30
- * `goddd`, `dammm`, `bruhh`
31
+ * - interjections, elongated where the short form is ambiguous:
32
+ * `noooo`, `ahhh`, `ugh(h)`, `argh`, `grr`, `stooop`, `whyyy`,
33
+ * `fuuu(ck)`, `wtfff`, `omggg`, `yesss`, `goddd`, `bruhh`
31
34
  * - standalone `dude`
32
- * - dot runs: `..`, `...`, `....+`
35
+ * - sad emoticons: `:(`, `;(`, `:-(((`
33
36
  */
34
37
  anguish: number;
35
38
  /**
@@ -37,9 +40,14 @@ export interface UserMessageMetrics {
37
40
  *
38
41
  * Counted on the same prose-only body as {@link anguish}.
39
42
  *
40
- * - line-leading `no` / `nope` / `nah` / `nvm` / `wrong` / `incorrect`
43
+ * - line-leading `nope` / `nah` / `nvm` / `wrong` / `incorrect`
41
44
  * (word-bounded, so `now`, `nobody`, `north` don't match)
45
+ * - line-leading `no` when used as an interjection - followed by
46
+ * punctuation, end-of-text, or a discourse word (`no i meant`,
47
+ * `no, wait`). Determiner `no` (`no extensions to the page`,
48
+ * `no auto start`) doesn't count.
42
49
  * - `that(?:'s)? not (what|right|it)` and `not what i (meant|asked|said|wanted)`
50
+ * - `makes (no|zero) sense`
43
51
  */
44
52
  negation: number;
45
53
  /**
@@ -58,6 +66,7 @@ export interface UserMessageMetrics {
58
66
  * Direct second-person reproach pinned on the agent:
59
67
  *
60
68
  * - `you (didn't|did not|broke|missed|forgot|keep|always|never|still|ignored)`
69
+ * - `why (would|did) (you|u)`
61
70
  * - sentence-leading `stop <verb>ing` imperatives
62
71
  */
63
72
  blame: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "16.3.8",
4
+ "version": "16.3.9",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "16.3.8",
43
- "@oh-my-pi/pi-catalog": "16.3.8",
44
- "@oh-my-pi/pi-utils": "16.3.8",
42
+ "@oh-my-pi/pi-ai": "16.3.9",
43
+ "@oh-my-pi/pi-catalog": "16.3.9",
44
+ "@oh-my-pi/pi-utils": "16.3.9",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
@@ -132,14 +132,14 @@ function BehaviorSummaryPanel({
132
132
  const METRIC_OPTIONS = [
133
133
  { value: "yelling", label: "CAPS", title: "Yelling (CAPS)" },
134
134
  { value: "profanity", label: "Profanity", title: "Profanity" },
135
- { value: "anguish", label: "Anguish", title: "Anguish (!!!, nooo, dude, ..)" },
136
- { value: "negation", label: "Negation", title: "Negation (no/nope/wrong)" },
135
+ { value: "anguish", label: "Anguish", title: "Anguish (!!!, nooo, ugh, dude, ':(')" },
136
+ { value: "negation", label: "Negation", title: "Negation (no/nope/wrong, makes no sense)" },
137
137
  {
138
138
  value: "repetition",
139
139
  label: "Repetition",
140
140
  title: "Repetition (i meant, still doesnt)",
141
141
  },
142
- { value: "blame", label: "Blame", title: "Blame (you didnt, stop X-ing)" },
142
+ { value: "blame", label: "Blame", title: "Blame (you didnt, why did you, stop X-ing)" },
143
143
  {
144
144
  value: "frustration",
145
145
  label: "Frustration",
package/src/db.ts CHANGED
@@ -46,7 +46,7 @@ let db: Database | null = null;
46
46
 
47
47
  const BACKFILL_COMPLETE = "complete";
48
48
  const BACKFILL_PENDING = "pending";
49
- const USER_MESSAGES_BACKFILL_KEY = "user_messages_v6";
49
+ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v8";
50
50
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
51
51
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
52
52
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
@@ -206,6 +206,13 @@ export async function initDb(): Promise<Database> {
206
206
  // plus profanity dictionary expansion + word-boundary fix.
207
207
  // v4 -> v5: column `yelling_sentences` renamed to `yelling` to match
208
208
  // the other single-word signal columns.
209
+ // v5 -> v6: dropped `git` from the profanity word list.
210
+ // v6 -> v7: dropped dot runs from `anguish`, technical-collision and
211
+ // opinion words from the profanity list; gated yelling on
212
+ // multi-word caps and bare `no` on interjection use.
213
+ // v7 -> v8: `no-op` compounds no longer count as negation; recovered
214
+ // measured false negatives: `:(` emoticons -> anguish,
215
+ // `why (would|did) you` -> blame, `makes no sense` -> negation.
209
216
  const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
210
217
  name: string;
211
218
  }[];
@@ -893,6 +900,21 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
893
900
  * single-word signal columns (profanity, anguish, negation, ...).
894
901
  * - v6: dropped `git` from the profanity word list - it collided with the
895
902
  * version-control tool name, so existing rows over-counted profanity.
903
+ * - v7: false-positive trim measured against the real corpus: dot runs
904
+ * (`..`/`...`) no longer count as anguish; profanity list dropped
905
+ * technical-collision words (`dummy`, `blast`, `knob`, `trash`, `crud`,
906
+ * `garbage`, ...), opinion/dislike words (`useless`, `awful`, `hate`,
907
+ * `meh`, ...) and moved `ugh`/`argh`/`grr` interjections to anguish;
908
+ * yelling now requires multi-word caps (filenames like `AGENTS.md` no
909
+ * longer fragment into all-caps sentences); bare leading `no` only
910
+ * counts as negation when used as an interjection, not a determiner.
911
+ * - v8: `no-op`-style compounds no longer count as corrective negation
912
+ * (hyphen after bare `no` only counts as a separator when it isn't
913
+ * gluing a compound word), and three measured false-negative clusters
914
+ * were recovered: sad emoticons (`:(`) score anguish, `why (would|did)
915
+ * you` scores blame, `makes (no|zero) sense` scores negation. v7
916
+ * shipped briefly without these, so any database that completed the v7
917
+ * backfill needs one more re-derive.
896
918
  *
897
919
  * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
898
920
  */
package/src/types.ts CHANGED
@@ -103,13 +103,13 @@ export interface UserMessageStats {
103
103
  yelling: number;
104
104
  /** Profanity hits */
105
105
  profanity: number;
106
- /** Catch-all upset signal: drama runs + `noooo`/`ughh`/... + `dude` + `..` */
106
+ /** Catch-all upset signal: drama runs + `noooo`/`ughh`/... + `dude` + `:(` */
107
107
  anguish: number;
108
108
  /** Corrective negation ("no", "nope", "thats not what i meant") */
109
109
  negation: number;
110
110
  /** User repeating themselves ("i meant", "still doesnt work", "like i said") */
111
111
  repetition: number;
112
- /** Second-person reproach ("you didnt", "you broke", "stop X-ing") */
112
+ /** Second-person reproach ("you didnt", "why did you", "stop X-ing") */
113
113
  blame: number;
114
114
  }
115
115
 
@@ -14,6 +14,9 @@ export interface UserMessageMetrics {
14
14
  * Number of "yelling" sentences: sentences where more than half of the
15
15
  * alphabetic characters are uppercase (and there are enough letters to
16
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.
17
20
  */
18
21
  yelling: number;
19
22
  /** Profanity hits (word-boundary, case-insensitive). */
@@ -26,11 +29,11 @@ export interface UserMessageMetrics {
26
29
  *
27
30
  * Sum of:
28
31
  * - drama runs: 3+ `!` / `?` (with `1`-mishit fallout)
29
- * - elongated interjections: `noooo`, `ahhhh`, `ughhh`, `argh`, `stooop`,
30
- * `whyyy`, `fuuu(ck)`, `shiiit`, `wtfff`, `omggg`, `yessss`, `helpp`,
31
- * `goddd`, `dammm`, `bruhh`
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`
32
35
  * - standalone `dude`
33
- * - dot runs: `..`, `...`, `....+`
36
+ * - sad emoticons: `:(`, `;(`, `:-(((`
34
37
  */
35
38
  anguish: number;
36
39
  /**
@@ -38,9 +41,14 @@ export interface UserMessageMetrics {
38
41
  *
39
42
  * Counted on the same prose-only body as {@link anguish}.
40
43
  *
41
- * - line-leading `no` / `nope` / `nah` / `nvm` / `wrong` / `incorrect`
44
+ * - line-leading `nope` / `nah` / `nvm` / `wrong` / `incorrect`
42
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.
43
50
  * - `that(?:'s)? not (what|right|it)` and `not what i (meant|asked|said|wanted)`
51
+ * - `makes (no|zero) sense`
44
52
  */
45
53
  negation: number;
46
54
  /**
@@ -59,6 +67,7 @@ export interface UserMessageMetrics {
59
67
  * Direct second-person reproach pinned on the agent:
60
68
  *
61
69
  * - `you (didn't|did not|broke|missed|forgot|keep|always|never|still|ignored)`
70
+ * - `why (would|did) (you|u)`
62
71
  * - sentence-leading `stop <verb>ing` imperatives
63
72
  */
64
73
  blame: number;
@@ -69,9 +78,13 @@ export interface UserMessageMetrics {
69
78
  *
70
79
  * Broad English coverage: f-/s-word families and their censored variants,
71
80
  * mild swears, intelligence-based insults, body-part insults, British/
72
- * Australian/Irish slang, religious exclamations, chat acronyms, and
73
- * frustration interjections. Curated to exclude racial, homophobic, and
74
- * other identity slurs.
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.
75
88
  */
76
89
  const PROFANITY: readonly string[] = [
77
90
  // f-word family
@@ -183,8 +196,6 @@ const PROFANITY: readonly string[] = [
183
196
  "hecks",
184
197
  "heckin",
185
198
  "gosh",
186
- "blast",
187
- "blasted",
188
199
  "bloody",
189
200
  "bollocks",
190
201
  "bollox",
@@ -197,7 +208,6 @@ const PROFANITY: readonly string[] = [
197
208
  "crapped",
198
209
  "crapping",
199
210
  "crapload",
200
- "crapshoot",
201
211
  "crapola",
202
212
  // piss family
203
213
  "piss",
@@ -281,7 +291,6 @@ const PROFANITY: readonly string[] = [
281
291
  "cockblock",
282
292
  "cocksucker",
283
293
  "cocksuckers",
284
- "knob",
285
294
  "knobhead",
286
295
  "knobheads",
287
296
  "knobend",
@@ -327,8 +336,6 @@ const PROFANITY: readonly string[] = [
327
336
  "dumber",
328
337
  "dumbest",
329
338
  "dumbo",
330
- "dummy",
331
- "dummies",
332
339
  "fool",
333
340
  "fools",
334
341
  "foolish",
@@ -387,31 +394,13 @@ const PROFANITY: readonly string[] = [
387
394
  "bugger",
388
395
  "buggered",
389
396
  // generic aggression / dismissal
390
- "hate",
391
- "hated",
392
- "hates",
393
- "hating",
394
- "hateful",
395
397
  "suck",
396
398
  "sucks",
397
399
  "sucked",
398
400
  "sucking",
399
401
  "sucky",
400
402
  "suckage",
401
- "trash",
402
403
  "trashy",
403
- "trashed",
404
- "garbage",
405
- "crud",
406
- "crudded",
407
- // quality-dismissal ("this is garbage / pointless")
408
- "useless",
409
- "pointless",
410
- "horrible",
411
- "awful",
412
- "worthless",
413
- "ridiculous",
414
- "nonsense",
415
404
  // religious exclamations
416
405
  "jesus",
417
406
  "christ",
@@ -439,26 +428,6 @@ const PROFANITY: readonly string[] = [
439
428
  "lmfao",
440
429
  "fubar",
441
430
  "snafu",
442
- // frustration interjections
443
- "ugh",
444
- "ughh",
445
- "ughhh",
446
- "urgh",
447
- "argh",
448
- "arghh",
449
- "arghhh",
450
- "arrgh",
451
- "blah",
452
- "bleh",
453
- "meh",
454
- "yikes",
455
- "yeesh",
456
- "oof",
457
- "gah",
458
- "gahh",
459
- "grr",
460
- "grrr",
461
- "grrrr",
462
431
  ];
463
432
 
464
433
  const PROFANITY_RE = new RegExp(String.raw`\b(?:${PROFANITY.join("|")})\b`, "gi");
@@ -473,16 +442,18 @@ const YELLING_THRESHOLD = 0.5;
473
442
  const DRAMA_RE = /[!?][!?1]{2,}/g;
474
443
  const WORD_RE = /\S+/g;
475
444
 
476
- // Elongated anguish/exasperation interjections. Each alternative is a
477
- // case-insensitive word-bounded pattern that requires *real* elongation
478
- // (so plain "no" / "argh" / "ahh" / "god" don't fire). Picked to avoid
479
- // hex / base64 contamination via the surrounding `\b` plus letter-only
480
- // alternatives.
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.
481
451
  const ANGUISH_PATTERNS: readonly string[] = [
482
452
  "no{3,}", // nooo, noooooo
483
453
  "a+h{2,}", // ahh, aaaahhh
484
- "u+g+h{2,}", // ughh, uuugh
454
+ "u+r?g+h+", // ugh, ughh, urgh, uuugh
485
455
  "a+r+g+h+", // argh, aaargh, arrgghhh
456
+ "g+r{2,}", // grr, grrrr
486
457
  "st+o{3,}p+", // stooop, sttooopp
487
458
  "w+h+y{3,}", // whyyy, whyyyyy
488
459
  "f+u{3,}c*k*", // fuuu, fuuuck
@@ -494,9 +465,13 @@ const ANGUISH_PATTERNS: readonly string[] = [
494
465
  ];
495
466
  const ANGUISH_RE = new RegExp(String.raw`\b(?:${ANGUISH_PATTERNS.join("|")})\b`, "gi");
496
467
  const DUDE_RE = /\bdude\b/gi;
497
- // Runs of 2+ dots. Captures `..` (lazy trail-off), `...` (tentative
498
- // ellipsis), and `....+` (exasperation) in a single signal.
499
- const ELLIPSIS_RE = /\.{2,}/g;
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.
500
475
 
501
476
  // --- Frustration signals ----------------------------------------------------
502
477
  // Each set of patterns below is tuned against ~42k real user prompts so the
@@ -506,10 +481,18 @@ const ELLIPSIS_RE = /\.{2,}/g;
506
481
  // trimmed prose body (no `m` flag) - in practice mid-message lines that
507
482
  // start with `no`/`Wrong`/`No JSDoc warning` are list items, pasted error
508
483
  // text or descriptive statements, not actual corrections. Real frustration
509
- // negation overwhelmingly opens the message.
510
- const NEGATION_LEAD_RE = /^[ \t]*(?:no|nope|nah|nvm|wrong|incorrect)\b/gi;
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;
511
494
  const NEGATION_PHRASE_RE =
512
- /\b(?:that['\u2019]?s\s+not\s+(?:what|right|it)|not\s+what\s+i\s+(?:meant|asked|said|wanted))\b/gi;
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;
513
496
 
514
497
  // User repeating themselves. The recall pattern accepts an optional
515
498
  // `like ` / `as ` prefix so "like i said" doesn't double-count with bare
@@ -526,6 +509,10 @@ const REPETITION_STILL_RE =
526
509
  // Direct second-person reproach. `you` alone is too generic (>7k hits in
527
510
  // short prose), so we anchor it to a small set of accusatory verbs.
528
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;
529
516
  // `stop <verb>ing` is only frustration when it's an imperative - require it
530
517
  // to start a sentence (line start or after a sentence-terminating punctuator).
531
518
  const BLAME_STOP_RE = /(?:^|(?<=[.!?\n]))\s*stop\s+\w+ing\b/gim;
@@ -540,6 +527,11 @@ const XML_TAG_BARE_RE = /<\/?[A-Za-z][\w-]*\b[^>]*\/?>/g;
540
527
  const INLINE_CODE_RE = /`[^`\n]*`/g;
541
528
  const URL_RE = /\bhttps?:\/\/\S+/gi;
542
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;
543
535
  const QUOTE_LINE_RE = /^[ \t]*>.*$/gm;
544
536
  // Harness placeholders the TUI substitutes for binary/non-text user input.
545
537
  // Strip them so real frustration signals on later lines aren't masked off
@@ -561,11 +553,27 @@ function countMatches(text: string, re: RegExp): number {
561
553
  return count;
562
554
  }
563
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
+
564
570
  /**
565
571
  * Count sentences where the share of uppercase letters exceeds
566
572
  * {@link YELLING_THRESHOLD}. Sentences shorter than
567
573
  * {@link YELLING_MIN_LETTERS} alphabetic characters are ignored so that
568
- * short acronyms ("OK", "WIP", "TODO") don't register as yelling.
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}.
569
577
  */
570
578
  function countYellingSentences(text: string): number {
571
579
  let count = 0;
@@ -576,7 +584,7 @@ function countYellingSentences(text: string): number {
576
584
  const letters = countMatches(sentence, LETTER_RE);
577
585
  if (letters >= YELLING_MIN_LETTERS) {
578
586
  const upper = countMatches(sentence, UPPER_LETTER_RE);
579
- if (upper / letters > YELLING_THRESHOLD) count++;
587
+ if (upper / letters > YELLING_THRESHOLD && isShoutedSentence(sentence)) count++;
580
588
  }
581
589
  match = SENTENCE_RE.exec(text);
582
590
  }
@@ -597,6 +605,7 @@ function stripStructuredContent(text: string): string {
597
605
  .replace(INLINE_CODE_RE, " ")
598
606
  .replace(URL_RE, " ")
599
607
  .replace(FILE_MENTION_RE, "$1 ")
608
+ .replace(DOTTED_TOKEN_RE, " ")
600
609
  .replace(QUOTE_LINE_RE, "")
601
610
  .replace(IMAGE_MARKER_RE, " ")
602
611
  .replace(ANSI_ESCAPE_RE, "");
@@ -654,11 +663,12 @@ export function computeUserMessageMetrics(text: string): UserMessageMetrics {
654
663
  countMatches(prose, DRAMA_RE) +
655
664
  countMatches(prose, ANGUISH_RE) +
656
665
  countMatches(prose, DUDE_RE) +
657
- countMatches(prose, ELLIPSIS_RE);
666
+ countMatches(prose, SAD_EMOTICON_RE);
658
667
 
659
668
  const negation = countMatches(prose, NEGATION_LEAD_RE) + countMatches(prose, NEGATION_PHRASE_RE);
660
669
  const repetition = countMatches(prose, REPETITION_RECALL_RE) + countMatches(prose, REPETITION_STILL_RE);
661
- const blame = countMatches(prose, BLAME_YOU_RE) + countMatches(prose, BLAME_STOP_RE);
670
+ const blame =
671
+ countMatches(prose, BLAME_YOU_RE) + countMatches(prose, BLAME_WHY_RE) + countMatches(prose, BLAME_STOP_RE);
662
672
 
663
673
  return {
664
674
  chars,