phaedo-mcp 0.2.2 → 0.3.1

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 (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
@@ -0,0 +1,67 @@
1
+ // Phaedo capture — the tier-agnostic spine: exchange_batch → signal delta.
2
+ //
3
+ // Every capture surface (the Claude Code JSONL hook, the Track B a11y helper, a future
4
+ // OCR tier) converges here once it has an exchange_batch (capture/transcript.js or
5
+ // capture/exchanges.js). This module runs the shared tail — deterministic Path-A
6
+ // detectors (+ optional local-model enrichment) → per-session state fold (resume-safe
7
+ // REPLACE) → cumulative delta lift — and returns the source='delta' signals. It does NOT
8
+ // touch the network: building + depositing the envelope is the transport layer's job
9
+ // (mcp/capture-sync.js), which keeps capture/ deposit-free and lets each surface own its
10
+ // pairing resolution. So a new front-end is a batch producer, not a new pipeline.
11
+ //
12
+ // Pure except the injected state file I/O (capture/state.js), exactly like the hook.
13
+
14
+ import { accumulateBatchCounts } from './detectors.js';
15
+ import { captureModelOpts, enrichCountsWithModel } from './model-detect.js';
16
+ import { loadState, saveState, applySession, cumulativeCounts, defaultStatePath } from './state.js';
17
+ import { liftDeltaSignals } from '../extraction/delta-lift.js';
18
+
19
+ // Fold one batch into this surface's cumulative signal delta.
20
+ // batch — an exchange_batch ({ exchanges, user_prompts, session_local_id }).
21
+ // opts:
22
+ // sourceId — the per-machine+surface source_device_id (capture/source-id.js).
23
+ // surface — surface tag echoed into the result (e.g. 'a11y-macos').
24
+ // statePath — durable per-surface state file (default: state.js defaultStatePath).
25
+ // sessionLocalId — STABLE per-conversation id → per-session REPLACE (idempotent re-reads).
26
+ // now — ISO timestamp (fixed for deterministic tests).
27
+ // captureModel — option (b) model opts, or undefined to auto-resolve from env; falsy = off.
28
+ // env — process.env (for captureModelOpts auto-resolution).
29
+ // Returns { signals, counts, modelAdded, sourceId, surface } — never throws on a bad
30
+ // batch (empty → zero signals); state I/O failures are swallowed (counters just restart).
31
+ export async function foldBatchToSignals(batch, opts = {}) {
32
+ const statePath = opts.statePath || defaultStatePath();
33
+ const nowIso = opts.now || new Date().toISOString();
34
+ const sourceId = opts.sourceId || null;
35
+ const sessionLocalId = opts.sessionLocalId || (batch && batch.session_local_id) || null;
36
+
37
+ const exchanges = (batch && batch.exchanges) || [];
38
+ const prompts = (batch && Array.isArray(batch.user_prompts)) ? batch.user_prompts : exchanges;
39
+ if (!prompts.length) return { signals: [], counts: null, modelAdded: 0, sourceId, surface: opts.surface || null };
40
+
41
+ let counts = accumulateBatchCounts(batch);
42
+
43
+ // Option (b): fold local-model classifications of the regex-MISS user messages into
44
+ // this session's counts BEFORE the state apply, so re-reads stay resume-safe and the
45
+ // deposit stays pure delta. Opt-in + fail-safe (any error leaves regex counts intact).
46
+ let modelAdded = 0;
47
+ const modelOpts = opts.captureModel !== undefined ? opts.captureModel : captureModelOpts(opts.env);
48
+ if (modelOpts && modelOpts.model) {
49
+ try {
50
+ const enriched = await enrichCountsWithModel(batch, counts, modelOpts);
51
+ counts = enriched.counts;
52
+ modelAdded = enriched.added;
53
+ } catch { /* model down/slow/garbage → regex-only, unchanged */ }
54
+ }
55
+
56
+ let state;
57
+ try {
58
+ state = applySession(loadState(statePath), sourceId, sessionLocalId, counts, { now: nowIso });
59
+ try { saveState(statePath, state); } catch { /* state loss just restarts counters — never fatal */ }
60
+ } catch {
61
+ // A corrupt state file must never break capture: fold against an empty baseline.
62
+ state = applySession(null, sourceId, sessionLocalId, counts, { now: nowIso });
63
+ }
64
+
65
+ const signals = liftDeltaSignals(cumulativeCounts(state, sourceId).behavioral_signals, nowIso);
66
+ return { signals, counts, modelAdded, sourceId, surface: opts.surface || null };
67
+ }
@@ -0,0 +1,287 @@
1
+ // Phaedo capture — Path A detectors (shared mirror of the extension's tables).
2
+ //
3
+ // Track A option (a): the Claude Code / Cowork capture hook produces REAL signal by
4
+ // running the SAME deterministic Path-A detectors the Chrome extension runs in
5
+ // content.js — the correction patterns (whose counters delta-lift turns into §5.1
6
+ // signals), the positive-reinforcement patterns, and the mental-model markers.
7
+ //
8
+ // MIRROR, not import: content.js is a plain (non-module) content script, so it
9
+ // cannot be imported here and this module cannot be loaded there without an MV3
10
+ // module-loading change. Following the repo's vendored-copy precedent
11
+ // (context-block.js), the three tables below are a byte-exact mirror of
12
+ // content.js, and drift is impossible in practice because
13
+ // scripts/test_capture_detectors.mjs extracts the tables from content.js source
14
+ // and compares every pattern (source + flags + layer/signal/framework) 1:1 —
15
+ // any edit to either side without the other fails CI.
16
+ //
17
+ // The detect functions are PURE ports (no globals, no accumulation): the
18
+ // extension's detectCorrection mutates memSignals; here the caller accumulates.
19
+ // Matching semantics are identical: corrections are FIRST-match-only per message,
20
+ // mental models at most once per framework per message.
21
+
22
+ // ─── Correction patterns (all 8 layers) — mirror of content.js ───────────────
23
+
24
+ export const CORRECTION_PATTERNS = [
25
+ // Surface — depth
26
+ { pattern: /\b(too long|too verbose|be (more )?concise|shorter|brief(er)?|summarize that|less words|too wordy|cut it down)\b/i, layer: 'surface', signal: 'too_long' },
27
+ { pattern: /\b(too short|more detail|elaborate|expand on|be more thorough|not enough|flesh it out|missing context)\b/i, layer: 'surface', signal: 'too_short' },
28
+ // Surface — formatting
29
+ { pattern: /\bdon'?t use bullets?\b|\bno bullets?\b|\buse (prose|paragraphs?)\b|\bno lists?\b/i, layer: 'surface', signal: 'avoid_bullets' },
30
+ { pattern: /\buse (bullet|numbered) (points?|lists?)\b|\blist (it|them|that) out\b|\bbullet (format|points?)\b/i, layer: 'surface', signal: 'prefer_bullets' },
31
+ { pattern: /\b(no headers?|no sections?|no headings?|don'?t section|remove (the )?headers?)\b/i, layer: 'surface', signal: 'no_headers' },
32
+ { pattern: /\b(add headers?|use headers?|section it|organize with|with headings?)\b/i, layer: 'surface', signal: 'use_headers' },
33
+ { pattern: /\b(table format|in a table|tabular|use a table|as a table)\b/i, layer: 'surface', signal: 'use_table' },
34
+ { pattern: /\b(code block|in code|formatted code|wrap (that|it) in code)\b/i, layer: 'surface', signal: 'use_code_block' },
35
+ // Surface — tone
36
+ { pattern: /\b(too formal|less formal|more casual|relax the tone|conversational(ly)?|informal)\b/i, layer: 'surface', signal: 'prefers_casual' },
37
+ { pattern: /\b(too casual|more formal|professional(ly)?|keep it formal)\b/i, layer: 'surface', signal: 'prefers_formal' },
38
+ { pattern: /\b(too roundabout|get to the point|be (more )?direct|skip the preamble|lead with|bottom.?line (up )?front)\b/i, layer: 'surface', signal: 'more_direct' },
39
+ { pattern: /\b(no (intro|preamble|opener)|skip the intro|don'?t start with|cut the opener|dive right in)\b/i, layer: 'surface', signal: 'no_preamble' },
40
+ { pattern: /\b(no examples?|skip the examples?|I don'?t need examples?|without examples?)\b/i, layer: 'surface', signal: 'no_examples' },
41
+ { pattern: /\b(give me an example|show me an example|illustrate|for example|can you show|concrete example)\b/i, layer: 'surface', signal: 'wants_examples' },
42
+ { pattern: /\b(simpler words?|plain(er)? (language|english)|avoid jargon|less jargon|plain terms?)\b/i, layer: 'surface', signal: 'plainer_language' },
43
+
44
+ // Behavioral — autonomy and initiative
45
+ { pattern: /\b(just do it|stop asking|don'?t (ask|check)|fewer questions?|just (go ahead|start|write it|build it|do it)|no need to ask)\b/i, layer: 'behavioral', signal: 'fewer_questions' },
46
+ { pattern: /\b(ask (me )?first|confirm before|check (with me|before)|get my (input|approval)|don'?t assume)\b/i, layer: 'behavioral', signal: 'wants_confirmation' },
47
+ { pattern: /\b(show your (work|reasoning)|explain your reasoning|walk me through|how (did you|do you) (get|arrive))\b/i, layer: 'behavioral', signal: 'show_reasoning' },
48
+ { pattern: /\b(just (give me|the) (answer|result)|skip the explanation|don'?t explain (how|why)|bottom line only)\b/i, layer: 'behavioral', signal: 'skip_reasoning' },
49
+ { pattern: /\b(just pick (one|it)|make (a|the) decision|too many options?|don'?t give me options?|choose for me)\b/i, layer: 'behavioral', signal: 'fewer_options' },
50
+ { pattern: /\b(give me (options?|alternatives?|choices?)|what are my (options?|choices?)|several approaches?|show alternatives?)\b/i, layer: 'behavioral', signal: 'wants_options' },
51
+ { pattern: /\b(what else (should I|am I missing)|be proactive|anticipate|what haven'?t I|what am I missing)\b/i, layer: 'behavioral', signal: 'wants_proactive' },
52
+ { pattern: /\b(just (what I asked|the question)|don'?t add (extra|more|additional)|stick to (the question|what I said)|only what I asked)\b/i, layer: 'behavioral', signal: 'stay_scoped' },
53
+ { pattern: /\b(let'?s (refine|iterate|keep going)|more (passes?|iterations?)|continue (from|where)|iterate on this)\b/i, layer: 'behavioral', signal: 'iterate_more' },
54
+
55
+ // Domain — expertise calibration
56
+ { pattern: /\b(I (already )?know (that|this)|skip the basics?|assume I know|don'?t explain (that|this)|over.?explaining)\b/i, layer: 'domain_and_expertise', signal: 'overexplaining' },
57
+ { pattern: /\b(too technical|simpler( terms?| language)?|(plain|plain.?english)|explain like (I'?m|a)|non.?technical)\b/i, layer: 'domain_and_expertise', signal: 'too_technical' },
58
+ { pattern: /\b(go deeper|more technical|implementation detail|under the hood|technical depth?|deeper dive|specifics?)\b/i, layer: 'domain_and_expertise', signal: 'wants_depth' },
59
+ { pattern: /\b(cite (your )?sources?|where (does this|did you)|source for that|reference for this|where did you get)\b/i, layer: 'domain_and_expertise', signal: 'wants_sources' },
60
+ { pattern: /\b(just the concept|high.?level( only)?|conceptual (understanding|overview)|the idea not the code)\b/i, layer: 'domain_and_expertise', signal: 'conceptual_only' },
61
+ { pattern: /\b(show (me )?(the )?code|implementation|how (to implement|do I build)|concrete (example|code|steps?))\b/i, layer: 'domain_and_expertise', signal: 'wants_implementation' },
62
+ { pattern: /\b(too broad|narrow it( down)?|focus on .{1,40}? specifically|more focused|scope (it )?down)\b/i, layer: 'domain_and_expertise', signal: 'too_broad' },
63
+
64
+ // Temporal — urgency and project phase
65
+ { pattern: /\b(quick (version|answer|summary)|TL;?DR|key points? only|brief (version|summary)|in a hurry|short on time|fast (answer|version))\b/i, layer: 'temporal_and_context', signal: 'urgent' },
66
+ { pattern: /\b(thorough(ly)?( is fine)?|take (the )?space|I have time|no hurry|comprehensive (is fine|please))\b/i, layer: 'temporal_and_context', signal: 'no_rush' },
67
+ { pattern: /\b(just exploring|early (stage|days)|high.?level for now|don'?t go too deep (yet)?|preliminary)\b/i, layer: 'temporal_and_context', signal: 'exploring_phase' },
68
+ { pattern: /\b(ready to build|implementation mode|let'?s get specific|concrete now|production.?ready|ship it)\b/i, layer: 'temporal_and_context', signal: 'build_mode' },
69
+ { pattern: /\b(big (picture|view)|strategy (level|only)|not the details|30[,.]?000.?foot|overview first|macro)\b/i, layer: 'temporal_and_context', signal: 'big_picture' },
70
+ { pattern: /\b(get (granular|specific|detailed)|deep in the details|implementation specifics?|micro.?level)\b/i, layer: 'temporal_and_context', signal: 'detail_mode' },
71
+
72
+ // Collaboration — audience and voice
73
+ { pattern: /\b(write for (a )?non.?technical|simplify for (my )?audience|they don'?t know|lay(man'?s?) terms?|accessible)\b/i, layer: 'collaboration_and_relationship', signal: 'non_technical_audience' },
74
+ { pattern: /\b(this is for (engineers?|developers?|technical)|technical audience|assume (they know|technical knowledge))\b/i, layer: 'collaboration_and_relationship', signal: 'technical_audience' },
75
+ { pattern: /\b(write in my (voice|style)|sound like me|match my (voice|tone|style)|in my (words|style))\b/i, layer: 'collaboration_and_relationship', signal: 'match_voice' },
76
+ { pattern: /\b(for a client|external (audience|use|doc)|this is (public|external|client.?facing)|publishing this)\b/i, layer: 'collaboration_and_relationship', signal: 'external_audience' },
77
+ { pattern: /\b(too jargon.?y for them|they won'?t understand|simpler for (this|my) audience|dumb (it )?down for)\b/i, layer: 'collaboration_and_relationship', signal: 'too_jargony_for_audience' },
78
+
79
+ // Decision — risk and certainty
80
+ { pattern: /\b(just (decide|pick|choose)|make (a|the) call|stop hedging|give me a recommendation|be decisive|commit to)\b/i, layer: 'decision_and_risk', signal: 'wants_decisiveness' },
81
+ { pattern: /\b(show (me )?(my )?options?|I want to choose|don'?t decide for me|lay out (the )?choices?|what are (my )?options?)\b/i, layer: 'decision_and_risk', signal: 'wants_options_decision' },
82
+ { pattern: /\b(what are the (trade.?offs?|risks?)|pros and cons|caveats?|what could go wrong|downsides?|what'?s the catch)\b/i, layer: 'decision_and_risk', signal: 'wants_nuance' },
83
+ { pattern: /\b(be (bold|more decisive|stronger)|strong(er)? recommendation|which is better|don'?t hedge|be definitive)\b/i, layer: 'decision_and_risk', signal: 'wants_bold_call' },
84
+ { pattern: /\b(conservative (approach|option|choice)|safer (option|path|choice)|low.?risk|cautious)\b/i, layer: 'decision_and_risk', signal: 'wants_conservative' },
85
+ { pattern: /\b(I need certainty|nail it down|definitive (answer|yes|no)|how confident|is this certain)\b/i, layer: 'decision_and_risk', signal: 'needs_certainty' },
86
+ { pattern: /\b(ballpark (is fine|estimate|answer)|rough estimate|in the ballpark|order of magnitude|approximate(ly)?( is fine)?)\b/i, layer: 'decision_and_risk', signal: 'rough_estimate_ok' },
87
+
88
+ // Creative — style and originality
89
+ { pattern: /\b(too conventional|be (more )?creative|unexpected (approach|angle|idea)|think (differently|outside))\b/i, layer: 'creative', signal: 'wants_creative' },
90
+ { pattern: /\b(simpler|more elegant|too complex|less is more|strip (it )?down|over.?engineered|over.?complicated)\b/i, layer: 'creative', signal: 'wants_elegance' },
91
+ { pattern: /\b(standard (approach|solution)|conventional|don'?t overthink|keep it (simple|straightforward|standard))\b/i, layer: 'creative', signal: 'wants_conventional' },
92
+ { pattern: /\b(more original|surprise me|not the obvious|unconventional|different (angle|approach|way)|unexpected)\b/i, layer: 'creative', signal: 'wants_originality' },
93
+
94
+ // Meta — transparency and self-awareness
95
+ { pattern: /\b(don'?t explain what you'?re doing|just do it|skip the (meta|commentary|preamble)|no meta)\b/i, layer: 'big_picture', signal: 'skip_meta' },
96
+ { pattern: /\b(show your reasoning|think out loud|walk me through your (logic|thinking|approach)|what'?s your (plan|approach|thinking))\b/i, layer: 'big_picture', signal: 'show_meta' },
97
+ { pattern: /\b(what (assumptions?|are you assuming)|be transparent|tell me your reasoning|what'?s your (basis|rationale))\b/i, layer: 'big_picture', signal: 'wants_transparency' },
98
+ { pattern: /\b(commit to (it|one|a decision|an answer)|stop second.?guessing|be confident|don'?t hedge|pick and go|just (pick|commit|decide))\b/i, layer: 'big_picture', signal: 'less_second_guessing' },
99
+ ];
100
+
101
+ // Positive reinforcement — mirror of content.js
102
+ export const POSITIVE_PATTERNS = [
103
+ /\b(perfect(ly)?( (done|answer|response|format|length|tone))?)\b/i,
104
+ /\b(exactly (right|what I (wanted|needed|meant|was looking for)))\b/i,
105
+ /\byes[,!]?\s+(that'?s?|exactly|perfect|right|nailed|spot on)\b/i,
106
+ /\b(love (this|it|that|the (way|approach|style|format|tone)))\b/i,
107
+ /\b(great (call|choice|approach|idea|response|answer|format|depth))\b/i,
108
+ /\bnailed (it|the (tone|format|depth|length))\b/i,
109
+ /\b(keep (doing|this|that) (up|like this|way))\b/i,
110
+ /\bspot on\b/i,
111
+ /\b(this is (great|perfect|exactly (right|what I needed)))\b/i,
112
+ /\b(much better( now)?)\b/i,
113
+ /\b(that (works|worked) (perfectly|great|well))\b/i,
114
+ /\b(good (call|instinct|judgment|read|approach))\b/i,
115
+ /\bright (level|tone|depth|format|approach|amount|length)\b/i,
116
+ /\b(sounds like me|matched my (voice|style|tone))\b/i,
117
+ /\b(exactly the (right|level|depth|amount|tone) I (needed|wanted))\b/i,
118
+ ];
119
+
120
+ // Mental-model markers — mirror of content.js
121
+ export const MENTAL_MODEL_PATTERNS = [
122
+ // First-principles reasoning — reasons up from fundamentals
123
+ { pattern: /\bfirst principles?\b/i, framework: 'first_principles' },
124
+ { pattern: /\b(root cause|underlying (reason|cause|mechanism|principle))\b/i, framework: 'first_principles' },
125
+ { pattern: /\bfrom (the ground up|scratch)\b/i, framework: 'first_principles' },
126
+ { pattern: /\b(core|base|underlying) assumptions?\b/i, framework: 'first_principles' },
127
+ { pattern: /\bwhy does (that|this|it) (actually |even |really )?work\b/i, framework: 'first_principles' },
128
+ { pattern: /\bbreak (it|this|that) down to (the )?(basics|fundamentals)\b/i, framework: 'first_principles' },
129
+ { pattern: /\bfundamental(ly)? (question|issue|problem|reason|why)\b/i, framework: 'first_principles' },
130
+ { pattern: /\bwhat'?s really (going on|happening|behind)\b/i, framework: 'first_principles' },
131
+
132
+ // Systems thinking — feedback loops, interconnection, the whole system
133
+ { pattern: /\bfeedback loops?\b/i, framework: 'systems_thinking' },
134
+ { pattern: /\bthe (whole|entire|broader|bigger) (system|picture)\b|\bsystem.?(wide|level)\b/i, framework: 'systems_thinking' },
135
+ { pattern: /\binterdependen|interconnect|interrelat/i, framework: 'systems_thinking' },
136
+ { pattern: /\bemergent\b/i, framework: 'systems_thinking' },
137
+ { pattern: /\bholistic(ally)?\b/i, framework: 'systems_thinking' },
138
+ { pattern: /\bmoving parts\b/i, framework: 'systems_thinking' },
139
+ { pattern: /\b(vicious|virtuous) (cycle|circle)\b/i, framework: 'systems_thinking' },
140
+ { pattern: /\bcause and effect\b/i, framework: 'systems_thinking' },
141
+ { pattern: /\bsystem(ic|s) (dynamics?|behaviou?r)\b/i, framework: 'systems_thinking' },
142
+
143
+ // Theory of constraints — bottleneck / limiting-factor framing
144
+ { pattern: /\bbottleneck/i, framework: 'theory_of_constraints' },
145
+ { pattern: /\b(the|a|key|main|real) constraint\b/i, framework: 'theory_of_constraints' },
146
+ { pattern: /\blimiting factor\b/i, framework: 'theory_of_constraints' },
147
+ { pattern: /\bwhat'?s (blocking|slowing|the blocker)\b|\bholding .{1,20} back\b/i, framework: 'theory_of_constraints' },
148
+ { pattern: /\bcritical path\b/i, framework: 'theory_of_constraints' },
149
+ { pattern: /\brate.?limiting\b/i, framework: 'theory_of_constraints' },
150
+ { pattern: /\bchoke ?point\b/i, framework: 'theory_of_constraints' },
151
+ { pattern: /\bgating (factor|item|step|issue)\b/i, framework: 'theory_of_constraints' },
152
+ { pattern: /\bweakest link\b/i, framework: 'theory_of_constraints' },
153
+
154
+ // Second-order effects — consequences of consequences
155
+ { pattern: /\bsecond.?order\b/i, framework: 'second_order_effects' },
156
+ { pattern: /\bdownstream (effects?|consequences?|impacts?)\b/i, framework: 'second_order_effects' },
157
+ { pattern: /\bknock.?on (effects?|consequences?|impacts?)\b/i, framework: 'second_order_effects' },
158
+ { pattern: /\bripple (effects?|through|across)\b/i, framework: 'second_order_effects' },
159
+ { pattern: /\bunintended consequences?\b/i, framework: 'second_order_effects' },
160
+ { pattern: /\b(and )?then what happens\b/i, framework: 'second_order_effects' },
161
+ { pattern: /\bcascad(e|es)\b/i, framework: 'second_order_effects' },
162
+ { pattern: /\bdown the (line|road)\b/i, framework: 'second_order_effects' },
163
+ { pattern: /\b(long|longer).?term (effects?|consequences?|impacts?)\b/i, framework: 'second_order_effects' },
164
+ { pattern: /\bwhat (does|will) (that|this|it) lead to\b/i, framework: 'second_order_effects' },
165
+
166
+ // Trade-off reasoning — explicit cost/benefit weighing
167
+ { pattern: /\btrade.?offs?\b/i, framework: 'tradeoff_reasoning' },
168
+ { pattern: /\bat the (cost|expense) of\b/i, framework: 'tradeoff_reasoning' },
169
+ { pattern: /\bin exchange for\b/i, framework: 'tradeoff_reasoning' },
170
+ { pattern: /\bpros and cons\b/i, framework: 'tradeoff_reasoning' },
171
+ { pattern: /\bwhat (do|would) (we|i) (lose|give up|sacrifice)\b/i, framework: 'tradeoff_reasoning' },
172
+ { pattern: /\bcost.?benefit\b|\bnet (gain|benefit|positive|negative)\b/i, framework: 'tradeoff_reasoning' },
173
+ { pattern: /\bupsides? and downsides?\b/i, framework: 'tradeoff_reasoning' },
174
+ { pattern: /\bworth the (cost|trade.?off|effort|risk)\b/i, framework: 'tradeoff_reasoning' },
175
+ { pattern: /\bzero.?sum\b/i, framework: 'tradeoff_reasoning' },
176
+
177
+ // Analogical reasoning — reasoning from similar cases
178
+ { pattern: /\b(similar to|analogous to)\b/i, framework: 'analogical_reasoning' },
179
+ { pattern: /\breminds me of\b/i, framework: 'analogical_reasoning' },
180
+ { pattern: /\bsame (idea|pattern|principle|concept) as\b/i, framework: 'analogical_reasoning' },
181
+ { pattern: /\b(by analogy|analog(y|ous))\b/i, framework: 'analogical_reasoning' },
182
+ { pattern: /\bthe .{1,30} equivalent of\b/i, framework: 'analogical_reasoning' },
183
+ { pattern: /\bthink of (it|this) (like|as)\b/i, framework: 'analogical_reasoning' },
184
+ { pattern: /\bkind of like\b/i, framework: 'analogical_reasoning' },
185
+ { pattern: /\bparallels? (to|with|between)\b/i, framework: 'analogical_reasoning' },
186
+
187
+ // Decomposition — break a problem into parts
188
+ { pattern: /\bbreak (it|this|that|the problem|things?) (down|up|apart|into)\b/i, framework: 'decomposition' },
189
+ { pattern: /\bstep.?by.?step\b/i, framework: 'decomposition' },
190
+ { pattern: /\bsmaller (parts|pieces|chunks|problems?|steps?|tasks?)\b/i, framework: 'decomposition' },
191
+ { pattern: /\bdecompos/i, framework: 'decomposition' },
192
+ { pattern: /\bsub.?(problems?|tasks?|components?)\b/i, framework: 'decomposition' },
193
+ { pattern: /\b(one|each) (piece|part|step|chunk|thing) at a time\b/i, framework: 'decomposition' },
194
+ { pattern: /\b(piece by piece|bit by bit)\b/i, framework: 'decomposition' },
195
+ { pattern: /\bdivide and conquer\b/i, framework: 'decomposition' },
196
+ { pattern: /\bchunk (it|this|that) (up|out|down)\b/i, framework: 'decomposition' },
197
+
198
+ // Scenario planning — contingencies, worst/best case
199
+ { pattern: /\bworst.?case\b/i, framework: 'scenario_planning' },
200
+ { pattern: /\bbest.?case\b/i, framework: 'scenario_planning' },
201
+ { pattern: /\bcontingenc/i, framework: 'scenario_planning' },
202
+ { pattern: /\b(edge|corner) cases?\b/i, framework: 'scenario_planning' },
203
+ { pattern: /\bplan for (the )?(failure|downside|worst)\b/i, framework: 'scenario_planning' },
204
+ { pattern: /\bif .{1,30} (fails?|breaks?|goes wrong)\b/i, framework: 'scenario_planning' },
205
+ { pattern: /\bwhat'?s the worst that\b/i, framework: 'scenario_planning' },
206
+ { pattern: /\b(fall.?back plan|plan b)\b/i, framework: 'scenario_planning' },
207
+ { pattern: /\bdownside (risk|scenario|case)\b/i, framework: 'scenario_planning' },
208
+ ];
209
+
210
+ // ─── Pure detect functions (extension semantics, caller accumulates) ─────────
211
+
212
+ // A correction/positive/framework cue is a short phrase; nobody types a 200 KB
213
+ // correction. Detect over only the first slice of a message — this both bounds
214
+ // worst-case regex cost (defense-in-depth against a pathological paste on top of
215
+ // the bounded patterns) and keeps semantics identical for real prompts. 8 KB is
216
+ // far longer than any genuine steering message.
217
+ const MAX_DETECT_CHARS = 8192;
218
+ const clip = (text) => { const s = String(text || ''); return s.length > MAX_DETECT_CHARS ? s.slice(0, MAX_DETECT_CHARS) : s; };
219
+
220
+ // First-match-only per message, same as content.js detectCorrection.
221
+ export function detectCorrection(text) {
222
+ const s = clip(text);
223
+ for (const { pattern, layer, signal } of CORRECTION_PATTERNS) {
224
+ if (pattern.test(s)) return { layer, signal };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ export function detectPositive(text) {
230
+ const s = clip(text);
231
+ return POSITIVE_PATTERNS.some((pattern) => pattern.test(s));
232
+ }
233
+
234
+ // At most once per framework per message, same as content.js detectMentalModels.
235
+ export function detectMentalModels(text) {
236
+ const s = clip(text);
237
+ const seen = new Set();
238
+ for (const { pattern, framework } of MENTAL_MODEL_PATTERNS) {
239
+ if (seen.has(framework)) continue;
240
+ if (pattern.test(s)) seen.add(framework);
241
+ }
242
+ return [...seen];
243
+ }
244
+
245
+ // Run all three detectors over a batch's user messages (transcript.js
246
+ // recordsToBatch output) and return the SESSION's counts in the shapes the rest
247
+ // of the pipeline expects. Also fills each exchange's delta_annotations in place
248
+ // (the Loop-2 per-exchange annotation shape), so a future model backend (option
249
+ // (b)) consumes the identical batch the web path produces.
250
+ // → { behavioral_signals: { layer: { signal: n } }, // phaedo_behavioral_signals shape
251
+ // positive_count, mental_models: { framework: n }, message_count }
252
+ export function accumulateBatchCounts(batch) {
253
+ const exchanges = (batch && batch.exchanges) || [];
254
+ // Path-A counts come from EVERY sent user prompt (the web path counts at send time),
255
+ // so an interrupted / unanswered correction still counts. Fall back to the exchanges'
256
+ // user_msg when user_prompts is absent (hand-built batches in older tests).
257
+ const prompts = (batch && Array.isArray(batch.user_prompts))
258
+ ? batch.user_prompts
259
+ : exchanges.map((e) => e.user_msg);
260
+ const behavioral = {};
261
+ const mental = {};
262
+ let positive = 0;
263
+ for (const text of prompts) {
264
+ const corr = detectCorrection(text);
265
+ if (corr) {
266
+ if (!behavioral[corr.layer]) behavioral[corr.layer] = {};
267
+ behavioral[corr.layer][corr.signal] = (behavioral[corr.layer][corr.signal] || 0) + 1;
268
+ }
269
+ if (detectPositive(text)) positive++;
270
+ for (const fw of detectMentalModels(text)) mental[fw] = (mental[fw] || 0) + 1;
271
+ }
272
+ // Per-exchange annotations (for the future model/Loop-2 backend) stay keyed to complete exchanges.
273
+ for (const ex of exchanges) {
274
+ const corr = detectCorrection(ex.user_msg);
275
+ ex.delta_annotations = {
276
+ corrections: corr ? [corr] : [],
277
+ positive: detectPositive(ex.user_msg),
278
+ mental_models: detectMentalModels(ex.user_msg),
279
+ };
280
+ }
281
+ return {
282
+ behavioral_signals: behavioral,
283
+ positive_count: positive,
284
+ mental_models: mental,
285
+ message_count: prompts.length,
286
+ };
287
+ }
@@ -0,0 +1,77 @@
1
+ // Phaedo capture — reconstructed {role, text} turns → exchange_batch.
2
+ //
3
+ // The tier-agnostic front door for capture surfaces that read a RENDERED transcript
4
+ // (the Track B OS-accessibility helper: Claude Desktop's chat tab, later an OCR tier)
5
+ // rather than Claude Code's JSONL. It emits the SAME exchange_batch shape transcript.js
6
+ // produces (recordsToBatch), so everything downstream — detectors, state, delta-lift,
7
+ // deposit — consumes it unchanged. The only thing that differs per surface is how the
8
+ // turns are obtained; this module is where a11y/OCR/JSONL converge onto one pipeline.
9
+ //
10
+ // Pure: no model, no network, no persistence. The turns' raw text lives here only as the
11
+ // returned in-memory batch; the caller runs detectors and discards it (the discard-raw
12
+ // contract, architecture/persona-vs-conversation-memory.md).
13
+ //
14
+ // Turn shape (what the native front-end emits, one object per rendered turn IN ORDER):
15
+ // { role: 'user' | 'assistant', text: string, id?: string, timestamp?: string }
16
+ // Anything else (role 'system'/'tool', empty text, a leading assistant turn with no
17
+ // preceding user prompt) is ignored, mirroring transcript.js's isUserPrompt/isAssistant
18
+ // filtering — so a UI label or a streamed-but-partial turn can't fabricate an exchange.
19
+
20
+ const PLATFORM = 'a11y';
21
+
22
+ // Turns → exchange_batch. Each user turn is paired with the assistant text that follows
23
+ // it (concatenated across consecutive assistant turns) up to the next user turn. A
24
+ // trailing user turn with no reply yet is dropped (incomplete exchange), matching both
25
+ // the web path and transcript.js. `user_prompts` collects EVERY genuine user turn (Path-A
26
+ // counts at send time, so an unanswered correction still counts).
27
+ // turns — array of { role, text, id?, timestamp? }.
28
+ // opts.sessionId — a STABLE per-conversation id (the front-end derives it, e.g. from the
29
+ // first user message, so re-reading the same chat REPLACES that session's counts in
30
+ // state.js instead of double-counting — the same idempotency the JSONL hook relies on).
31
+ // opts.platform — surface tag for the exchange records (default 'a11y').
32
+ export function turnsToBatch(turns, opts = {}) {
33
+ const list = Array.isArray(turns) ? turns : [];
34
+ const sessionId = opts.sessionId || null;
35
+ const platform = opts.platform || PLATFORM;
36
+
37
+ const exchanges = [];
38
+ const user_prompts = [];
39
+ let pending = null;
40
+ let replyParts = [];
41
+ let seq = 0;
42
+
43
+ const flush = () => {
44
+ if (pending && replyParts.length) {
45
+ const ai = replyParts.join('\n').trim();
46
+ if (ai) {
47
+ exchanges.push({
48
+ exchange_id: pending.id,
49
+ session_id: sessionId,
50
+ platform,
51
+ timestamp: pending.timestamp,
52
+ user_msg: pending.text,
53
+ ai_response: ai,
54
+ delta_annotations: {}, // filled by the caller's detector pass (accumulateBatchCounts)
55
+ });
56
+ }
57
+ }
58
+ pending = null;
59
+ replyParts = [];
60
+ };
61
+
62
+ for (const t of list) {
63
+ if (!t || typeof t.text !== 'string') continue;
64
+ const text = t.text.trim();
65
+ if (!text) continue;
66
+ if (t.role === 'user') {
67
+ flush(); // close any previous exchange first
68
+ user_prompts.push(text); // count Path-A per sent prompt
69
+ pending = { id: (typeof t.id === 'string' && t.id) || `ex-${seq++}`, text, timestamp: t.timestamp || null };
70
+ } else if (t.role === 'assistant' && pending) {
71
+ replyParts.push(text); // a leading assistant turn (no pending) is ignored
72
+ }
73
+ }
74
+ flush(); // close the final exchange
75
+
76
+ return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts };
77
+ }
@@ -0,0 +1,195 @@
1
+ // Phaedo capture — option (b): the model-backed correction detector.
2
+ //
3
+ // Claude Code's terse register under-triggers the chat-tuned Path-A regexes
4
+ // ("tldr pls", "why r u explaining this", "wall of text again"), so a local
5
+ // model backend enriches capture — but as a RICHER DETECTOR, not a second
6
+ // extraction pipeline. The model classifies ONLY the user messages the regexes
7
+ // MISSED against the SAME correction taxonomy (CORRECTION_PATTERNS' layer/signal
8
+ // pairs), and its hits increment the SAME per-session counters. Everything
9
+ // downstream — per-session REPLACE state (resume-safe), cumulative delta-lift,
10
+ // per-source snapshot deposit, owner-side combine — applies unchanged, and the
11
+ // owner's "source:'delta' only" deposit guard stays exactly right: a model hit
12
+ // is just a counter the regex would have incremented if it were smarter.
13
+ //
14
+ // Why not deposit source:'extraction' signals: reconcile merges extraction
15
+ // signals ADDITIVELY (mergeSignal sums counts), so a SessionEnd re-fire (clear /
16
+ // resume re-processes the full transcript) or a double-drain would inflate them.
17
+ // Counters + per-session replace already solve that; the model feeds them.
18
+ //
19
+ // Privacy: regex-missed user messages are sent ONLY to the configured LOCAL
20
+ // endpoint (Ollama / OpenAI-compatible — same trust boundary as the WebLLM
21
+ // extraction and the consult model judge), and nothing derived from them but
22
+ // signal names + integers is persisted or deposited (the discard-raw contract).
23
+ //
24
+ // Transport mirrors mcp/consult.js modelJudge (the consult Ollama plumbing):
25
+ // POST {url}/v1/chat/completions, strict-JSON reply, hard timeout. Opt-in via
26
+ // PHAEDO_CAPTURE_MODEL; everything here fails toward "regex counts only".
27
+
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+ import { CORRECTION_PATTERNS, detectCorrection } from './detectors.js';
32
+
33
+ // The classification taxonomy: unique (layer, signal) pairs from the mirrored
34
+ // correction table, so the model can only ever emit counters the deterministic
35
+ // path could also produce (and the parity test already locks against content.js).
36
+ export function correctionTaxonomy() {
37
+ const seen = new Map();
38
+ for (const { layer, signal } of CORRECTION_PATTERNS) {
39
+ if (!seen.has(signal)) seen.set(signal, { layer, signal });
40
+ }
41
+ return [...seen.values()];
42
+ }
43
+
44
+ const MAX_MODEL_PROMPTS = 40; // most recent regex-miss messages sent per session
45
+ const CLIP_CHARS = 1500; // per-message clip (corrections live in the first words)
46
+
47
+ // The regex-miss user messages of a batch (the only ones the model sees), most
48
+ // recent MAX_MODEL_PROMPTS. Same prompt-source fallback as accumulateBatchCounts.
49
+ export function regexMissPrompts(batch) {
50
+ const exchanges = (batch && batch.exchanges) || [];
51
+ const prompts = (batch && Array.isArray(batch.user_prompts))
52
+ ? batch.user_prompts
53
+ : exchanges.map((e) => e.user_msg);
54
+ return prompts
55
+ .filter((t) => typeof t === 'string' && t.trim() && !detectCorrection(t))
56
+ .slice(-MAX_MODEL_PROMPTS)
57
+ .map((t) => t.slice(0, CLIP_CHARS));
58
+ }
59
+
60
+ export function buildDetectPrompt(prompts) {
61
+ const names = correctionTaxonomy().map((t) => t.signal).join(', ');
62
+ const system =
63
+ 'You classify a user\'s messages to an AI assistant, looking for CORRECTIONS — moments where ' +
64
+ 'the user pushes back on HOW the assistant responded (length, format, tone, depth, autonomy, ' +
65
+ 'risk posture), not on facts. Signals: ' + names + '. ' +
66
+ 'The names are literal: too_long = the reply was too long / wants it shorter; wants_confirmation = ' +
67
+ 'asks the assistant to check before acting; and so on. Reply with STRICT JSON only:\n' +
68
+ '{"hits": [{"m": <message number>, "signal": "<signal name>"}]}\n' +
69
+ 'At most ONE signal per message — the dominant correction. Messages with no correction are ' +
70
+ 'simply omitted. When unsure, omit (precision over recall). Output ONLY the JSON object.';
71
+ const user = prompts.map((t, i) => `[${i + 1}] ${t}`).join('\n\n');
72
+ return { system, user };
73
+ }
74
+
75
+ // Parse + validate the model's reply. Returns { <1-based m>: signal } for valid,
76
+ // in-range, first-per-message hits with known signal names; {} on any junk.
77
+ export function parseDetectResponse(text, promptCount) {
78
+ let obj;
79
+ try {
80
+ const m = String(text).match(/\{[\s\S]*\}/);
81
+ if (!m) return {};
82
+ obj = JSON.parse(m[0]);
83
+ } catch { return {}; }
84
+ const valid = new Map(correctionTaxonomy().map((t) => [t.signal, t.layer]));
85
+ const out = {};
86
+ for (const hit of Array.isArray(obj?.hits) ? obj.hits : []) {
87
+ const m = hit && hit.m;
88
+ if (!Number.isInteger(m) || m < 1 || m > promptCount) continue;
89
+ if (out[m]) continue; // first hit per message wins (regex parity)
90
+ if (!valid.has(hit.signal)) continue; // unknown name → dropped, never invented
91
+ out[m] = hit.signal;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // Fold validated model hits into a session-counts object (accumulateBatchCounts
97
+ // shape). Pure — returns a new counts object; the input is not mutated. Only
98
+ // behavioral_signals move: positives/mental-models stay regex-only for now.
99
+ export function mergeModelCounts(counts, hits) {
100
+ const layerOf = new Map(correctionTaxonomy().map((t) => [t.signal, t.layer]));
101
+ const behavioral = {};
102
+ for (const [layer, sigs] of Object.entries((counts && counts.behavioral_signals) || {})) {
103
+ behavioral[layer] = { ...sigs };
104
+ }
105
+ let added = 0;
106
+ for (const signal of Object.values(hits || {})) {
107
+ const layer = layerOf.get(signal);
108
+ if (!layer) continue;
109
+ if (!behavioral[layer]) behavioral[layer] = {};
110
+ behavioral[layer][signal] = (behavioral[layer][signal] || 0) + 1;
111
+ added++;
112
+ }
113
+ return { counts: { ...counts, behavioral_signals: behavioral }, added };
114
+ }
115
+
116
+ // Opt-in config, mirroring consultOptsFromEnv's conventions. `model` unset →
117
+ // the whole backend is off and the hook never touches the network for it.
118
+ export function captureModelOptsFromEnv(env = process.env) {
119
+ return {
120
+ model: env.PHAEDO_CAPTURE_MODEL || null,
121
+ modelUrl: env.PHAEDO_CAPTURE_MODEL_URL || 'http://localhost:11434',
122
+ apiKey: env.PHAEDO_CAPTURE_API_KEY || null,
123
+ timeoutMs: Number(env.PHAEDO_CAPTURE_MODEL_TIMEOUT_MS) || 15000,
124
+ };
125
+ }
126
+
127
+ // The durable on-switch: <state dir>/capture-model.json, written by
128
+ // `node install.mjs --capture-model <name>` (removed with `... off`). A shell
129
+ // `export` only reaches TERMINAL-launched sessions — Claude Desktop / Cowork
130
+ // launched from the Dock never read the shell profile, so their hooks would
131
+ // silently stay regex-only. The config file lives beside capture-state.json,
132
+ // which the hook reads anyway, so it reaches every launch path.
133
+ // Shape: { "model": "llama3.1:8b", "url"?, "api_key"?, "timeout_ms"? }.
134
+ export function captureModelConfigPath(env = process.env) {
135
+ const dir = env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
136
+ return join(dir, 'capture-model.json');
137
+ }
138
+
139
+ // Resolved capture-model config: env vars win (per-session override, and the
140
+ // original interface), else the config file, else off. A missing or malformed
141
+ // file is simply "off" — the hook must never fail on config.
142
+ export function captureModelOpts(env = process.env) {
143
+ const fromEnv = captureModelOptsFromEnv(env);
144
+ if (fromEnv.model) return fromEnv;
145
+ try {
146
+ const cfg = JSON.parse(readFileSync(captureModelConfigPath(env), 'utf8'));
147
+ if (cfg && typeof cfg.model === 'string' && cfg.model) {
148
+ return {
149
+ model: cfg.model,
150
+ modelUrl: (typeof cfg.url === 'string' && cfg.url) || fromEnv.modelUrl,
151
+ apiKey: (typeof cfg.api_key === 'string' && cfg.api_key) || null,
152
+ timeoutMs: Number(cfg.timeout_ms) || fromEnv.timeoutMs,
153
+ };
154
+ }
155
+ } catch { /* no/broken file → off */ }
156
+ return fromEnv;
157
+ }
158
+
159
+ // One chat call against an Ollama / OpenAI-compatible endpoint (the consult
160
+ // modelJudge transport). Throws on any transport/HTTP failure; the caller
161
+ // treats every throw as "no enrichment".
162
+ export async function callChatModel(system, user, opts) {
163
+ const res = await fetch(`${String(opts.modelUrl).replace(/\/$/, '')}/v1/chat/completions`, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json', ...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}) },
166
+ body: JSON.stringify({
167
+ model: opts.model,
168
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
169
+ temperature: 0,
170
+ stream: false,
171
+ }),
172
+ signal: AbortSignal.timeout(opts.timeoutMs || 15000),
173
+ });
174
+ if (!res.ok) throw new Error(`capture model endpoint ${res.status}`);
175
+ const data = await res.json();
176
+ return data?.choices?.[0]?.message?.content || '';
177
+ }
178
+
179
+ // The enrichment pass: classify the regex-miss messages and fold the hits into
180
+ // the session counts. NEVER throws and never degrades the deterministic result —
181
+ // any failure (endpoint down, timeout, junk output) returns the input counts.
182
+ // opts — captureModelOptsFromEnv shape; opts.call is injectable for tests.
183
+ export async function enrichCountsWithModel(batch, counts, opts = {}) {
184
+ if (!opts.model) return { counts, added: 0 };
185
+ const prompts = regexMissPrompts(batch);
186
+ if (!prompts.length) return { counts, added: 0 };
187
+ try {
188
+ const { system, user } = buildDetectPrompt(prompts);
189
+ const call = opts.call || callChatModel;
190
+ const hits = parseDetectResponse(await call(system, user, opts), prompts.length);
191
+ return mergeModelCounts(counts, hits);
192
+ } catch {
193
+ return { counts, added: 0 }; // enrichment only — regex counts always land
194
+ }
195
+ }