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.
- package/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- 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
|
+
}
|