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,232 @@
|
|
|
1
|
+
// Phaedo Loop 2 extraction — field catalog (Stage-2 source of truth).
|
|
2
|
+
//
|
|
3
|
+
// Promoted verbatim from the proven prototype (scripts/local-model-eval/lib/
|
|
4
|
+
// mapper.mjs + embed-mapper.mjs). The architecture decision
|
|
5
|
+
// (docs/roadmap/extraction-engine-decision.md §6) explicitly DEPRIORITIZES
|
|
6
|
+
// further hand-tuning of this catalog — the real gains are in the v2 fine-tuned
|
|
7
|
+
// model, not more vocabulary. So this is a faithful copy, not a rewrite: the
|
|
8
|
+
// cross-persona F1 the eval established (~48% avg, ~70% peak) rides on these
|
|
9
|
+
// exact triggers/anchors. Touch only with a re-run of the eval harness.
|
|
10
|
+
//
|
|
11
|
+
// Maps to protocol §4.2 layers / extraction-task-spec §5.4 fields. Each catalog
|
|
12
|
+
// entry names the (layer, field) it produces and how to resolve its value +
|
|
13
|
+
// polarity once the field is matched (lexically here, or via embedding in
|
|
14
|
+
// mapper.js — both share resolveValueAndPolarity once the field is chosen).
|
|
15
|
+
|
|
16
|
+
export const NEG_CUES = [
|
|
17
|
+
'dislike', 'hate', 'avoid', "doesn't", 'does not', "don't want", 'do not want',
|
|
18
|
+
'stop ', 'no ', 'without', 'annoy', 'frustrat', 'pet peeve', 'bothered', 'against',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Each entry: field, layer, default polarity, trigger phrases, and optional
|
|
22
|
+
// value resolver (cues → enum value, or a fixed scalar/array).
|
|
23
|
+
//
|
|
24
|
+
// `detectNeg: true` opts a field into polarity flipping on negation cues. Only
|
|
25
|
+
// fields whose negative form is a real, distinct signal (formatting "no
|
|
26
|
+
// bullets") use it. Enum/scalar fields encode direction in their VALUE, so a
|
|
27
|
+
// phrase like "don't dumb it down" (→ full_technical) must stay positive.
|
|
28
|
+
export const CATALOG = [
|
|
29
|
+
// ── surface ──
|
|
30
|
+
{ field: 'response_length_pref', layer: 'surface', triggers: ['length', 'how long', 'thorough', 'concise', 'brief', 'short answer', 'detailed answer', 'verbose', 'quick version', 'short on time', 'too much detail', 'the short answer', 'full picture', 'complete picture', 'even if it', 'keep it short', 'long answer'],
|
|
31
|
+
values: { short: ['short', 'brief', 'concise', 'tight', 'quick version', 'the short answer', 'too much detail', 'short on time', 'bottom line'], thorough: ['thorough', 'detailed', 'long', 'deep', 'comprehensive', 'full picture', 'complete picture', 'even if it', 'full pathway'], medium: ['medium', 'moderate'] } },
|
|
32
|
+
{ field: 'tone_register', layer: 'surface', triggers: ['tone', 'casual', 'formal', 'informal', 'conversational', 'professional', 'friendly', 'punchy', 'like a human', 'like a friend', 'corporate'],
|
|
33
|
+
values: { casual: ['casual', 'informal', 'conversational', 'relaxed', 'friendly', 'punchy', 'like a human', 'like a friend', 'texting'], formal: ['formal', 'professional', 'corporate'], neutral: ['neutral'] } },
|
|
34
|
+
{ field: 'formatting_prefs', layer: 'surface', detectNeg: true, triggers: ['bullet', 'prose', 'paragraph', 'numbered', 'table', 'header', 'heading', 'code block', 'code', 'list', 'inline example', 'example', 'in order', 'steps', 'scan'],
|
|
35
|
+
values: { prose: ['prose', 'paragraph'], bullets: ['bullet', 'list', 'scan'], numbered: ['numbered', 'in order', 'step'], tables: ['table'], headers: ['header', 'heading'], code_blocks: ['code block', 'code', 'just the code', 'give me the code'], inline_examples: ['inline example', 'example', 'use an example'] }, arrayValue: true },
|
|
36
|
+
{ field: 'jargon_tolerance', layer: 'surface', triggers: ['jargon', 'technical term', 'define', 'plain language', 'plain english', 'assume i know', 'not techy', 'not technical', 'lose the thread', 'what it means', 'cut the jargon', 'no jargon', 'simple', 'simpler'],
|
|
37
|
+
scalar: t => /assume|already know|no need to define|skip the .*primer|skip the basics/.test(t) ? 0.85 : /define|explain.*term|plain|not techy|not technical|cut the jargon|no jargon|what it means|simpl/.test(t) ? 0.15 : 0.5 },
|
|
38
|
+
{ field: 'directness', layer: 'surface', triggers: ['get to the point', 'direct', 'bottom line', 'no preamble', 'skip the intro', 'cut to', 'cut to the chase', 'blunt', 'straight', 'no fluff', 'give it to me straight', 'just tell me'],
|
|
39
|
+
scalar: () => 0.85 },
|
|
40
|
+
{ field: 'pet_peeves', layer: 'surface', forcePolarity: 'negative', triggers: ['dislike', 'annoy', 'pet peeve', 'bother', 'opener', 'great question', "don't like", 'frustrat', 'hate when', 'gets on'],
|
|
41
|
+
arrayValueFromText: true },
|
|
42
|
+
|
|
43
|
+
// ── behavioral ──
|
|
44
|
+
{ field: 'iteration_style', layer: 'behavioral', triggers: ['iterate', 'refine', 'keep going', 'revise', 'rework', 'passes', 'one shot', 'single pass', 'keep refining', 'until it', 'improve later'],
|
|
45
|
+
values: { iterative_refine: ['iterate', 'refine', 'keep going', 'revise', 'keep refining', 'until it'], deep_rework: ['rework', 'rewrite', 'overhaul'], single_pass: ['one shot', 'single pass', 'one pass'] } },
|
|
46
|
+
{ field: 'ambiguity_response', layer: 'behavioral', triggers: ['ask first', 'clarify', 'clarifying question', 'ask before', 'state assumption', 'offer options'],
|
|
47
|
+
values: { ask_first: ['ask first', 'clarify', 'clarifying', 'ask before'], assume_state_proceed: ['assume and proceed', 'state assumption'], offer_options: ['offer options'] } },
|
|
48
|
+
{ field: 'detail_vs_principle', layer: 'behavioral', triggers: ['principle', 'step by step', 'steps', 'big idea first', 'detail', 'in order', 'what do i actually do', 'understand why', 'why it works', 'concept first'],
|
|
49
|
+
values: { principle_first: ['principle first', 'big idea first', 'understand why', 'why it works', 'concept first', 'principle.*then'], principle: ['principle'], steps: ['step by step', 'steps', 'in order', 'what do i actually do', 'what do i do'] } },
|
|
50
|
+
{ field: 'autonomy_pref', layer: 'behavioral', triggers: ['just do it', 'stop asking', 'confirm', 'check with me', 'act without', 'fewer questions', 'just go', 'without asking'],
|
|
51
|
+
scalar: t => /just do|stop asking|fewer question|without asking|just go/.test(t) ? 0.85 : /confirm|check with|ask me/.test(t) ? 0.2 : 0.5 },
|
|
52
|
+
{ field: 'reasoning_visibility', layer: 'behavioral', triggers: ['show your reasoning', 'show your work', 'walk me through', 'skip the explanation', 'just the answer', 'no explanation', "i'll read it", 'just the code', 'without explanation', 'no need to explain'],
|
|
53
|
+
values: { show: ['show your reasoning', 'show your work', 'walk me through'], skip: ['skip the explanation', 'just the answer', 'no explanation', "i'll read", 'just the code', 'without explanation', 'no need to explain'] } },
|
|
54
|
+
{ field: 'scope_discipline', layer: 'behavioral', triggers: ['stay scoped', 'just what i asked', 'be proactive', 'anticipate', "what i'm missing", 'what else'],
|
|
55
|
+
values: { stay_scoped: ['stay scoped', 'just what i asked', 'only what'], wants_proactive: ['proactive', 'anticipate', 'what else', 'missing'] } },
|
|
56
|
+
|
|
57
|
+
// ── creative ──
|
|
58
|
+
{ field: 'quality_word', layer: 'creative', triggers: ['elegant', 'rigorous', 'clear', 'surprising', 'useful', 'honest', 'beautiful', 'original', 'unexpected', 'practical', 'works', 'stands out', 'clean', 'simple'],
|
|
59
|
+
values: { elegant: ['elegant', 'clean', 'simple', 'strip', 'less is more'], rigorous: ['rigorous', 'precise', 'exact'], clear: ['clear', 'clarity'], surprising: ['surprising', 'unexpected', 'original', 'different angle', 'stands out', 'nobody else', 'angle nobody'], useful: ['useful', 'practical', 'works', 'gets it done', 'fancy', 'something that works'], honest: ['honest'], beautiful: ['beautiful'] } },
|
|
60
|
+
{ field: 'problem_solving_move', layer: 'creative', triggers: ['decompose', 'break it down', 'analogy', 'analogous', 'map the system', 'systems', 'sketch', 'sit with', 'bottleneck', 'constraint', 'where the time goes', 'think of it as', 'hang together'],
|
|
61
|
+
values: { decompose: ['decompose', 'break it down'], analogize: ['analogy', 'analogous', 'like a', 'think of it as'], map_system: ['map the system', 'systems', 'bottleneck', 'constraint', 'where the noise', 'where the time', 'hang together', 'as a system'], discuss: ['talk it through', 'discuss'], sketch: ['sketch', 'draw'], sit_with: ['sit with', 'let it sit'] } },
|
|
62
|
+
{ field: 'mental_model_inventory', layer: 'creative', triggers: ['first principles', 'systems thinking', 'theory of constraints', 'second order', 'second-order', 'tradeoff', 'trade-off', 'decomposition', 'scenario', 'analogical', 'downstream', 'cascade', 'ripple'],
|
|
63
|
+
values: { first_principles: ['first principles'], systems_thinking: ['systems thinking', 'as a system', 'hang together'], theory_of_constraints: ['theory of constraints', 'constraint', 'bottleneck'], second_order_effects: ['second order', 'second-order', 'downstream', 'cascade', 'ripple', 'knock-on'], tradeoff_reasoning: ['tradeoff', 'trade-off'], analogical_reasoning: ['analogical', 'analogy'], decomposition: ['decomposition', 'decompose'], scenario_planning: ['scenario'] }, arrayValue: true },
|
|
64
|
+
|
|
65
|
+
// ── domain_and_expertise ──
|
|
66
|
+
{ field: 'primary_expertise', layer: 'domain_and_expertise', triggers: ['expert in', 'expertise', 'works in', 'background in', 'professional', 'specializes', 'hr', 'immigration', 'engineer', 'designer', 'researcher', 'lawyer', 'distributed systems', 'molecular biology', 'marketing', 'design', 'business owner', 'i work in', 'i run a', 'years of'],
|
|
67
|
+
stringFromText: true },
|
|
68
|
+
{ field: 'domain_depth_pref', layer: 'domain_and_expertise', triggers: ['go deep', 'technical depth', 'high level', 'surface level', 'match my question', "don't dumb", 'full picture', 'complete picture', "don't oversimplify", 'full pathway', 'no basics', 'just the concept', 'under the hood'],
|
|
69
|
+
values: { full_technical: ['go deep', 'technical depth', 'under the hood', "don't dumb", 'no basics', 'full picture', 'complete picture', "don't oversimplify", 'full pathway', 'the full'], surface: ['high level', 'surface level', 'just the concept', 'too much detail'], match_my_question: ['match my question', 'follow my lead'] } },
|
|
70
|
+
{ field: 'active_learning_areas', layer: 'domain_and_expertise', triggers: ['learning', 'interested in', 'wants to learn', 'curious about'],
|
|
71
|
+
arrayValueFromText: true },
|
|
72
|
+
{ field: 'domain_vocabulary', layer: 'domain_and_expertise', triggers: ['uses term', 'specialized vocab', 'jargon like', 'terms like', 'h-1b', 'lca', 'cap-exempt', 'raft', 'postgres', 'cassandra', 'kinase', 'mapk', 'technical vocabulary'],
|
|
73
|
+
arrayValueFromText: true },
|
|
74
|
+
{ field: 'expertise_level', layer: 'domain_and_expertise', triggers: ['expert', 'power user', 'experienced', 'years of', 'years', 'novice', 'beginner', 'general user', 'not techy', 'not technical', "don't know enough", 'i work in this field', 'run this', "i've been", 'i run a'],
|
|
75
|
+
values: { power: ['expert', 'power user', 'years', 'experienced', 'run this', 'work in this field', "i've been", 'i run a', 'seasoned'], technical: ['technical', 'some knowledge', 'comfortable with'], general: ['novice', 'beginner', 'general', 'not techy', 'not technical', "don't know enough", "don't know much"] } },
|
|
76
|
+
|
|
77
|
+
// ── temporal_and_context ──
|
|
78
|
+
{ field: 'early_project_mode', layer: 'temporal_and_context', triggers: ['explore', 'wander', 'early stage', 'early on', 'not ready to commit', 'brainstorm', 'frame first', 'commit fast', 'just exploring', 'angles'],
|
|
79
|
+
values: { explore_wide: ['explore', 'wander', 'brainstorm', 'angles', 'widely', 'just exploring', 'not ready to commit'], frame_then_explore: ['frame first', 'narrow then'], commit_fast: ['commit fast', 'decide quickly'] } },
|
|
80
|
+
{ field: 'urgency_baseline', layer: 'temporal_and_context', triggers: ['in a hurry', 'urgent', 'quick', 'no rush', 'take your time', 'asap', 'today', "don't have time", 'between job', 'short on time', 'time-pressured', 'need it now'],
|
|
81
|
+
scalar: t => /hurry|urgent|asap|today|don.t have time|between job|short on time|need it now|need it today/.test(t) ? 0.85 : /no rush|take your time|have time|no hurry/.test(t) ? 0.15 : 0.5 },
|
|
82
|
+
{ field: 'working_mode', layer: 'temporal_and_context', triggers: ['exploring', 'build mode', 'ready to build', 'implementation', 'just thinking', 'still figuring'],
|
|
83
|
+
values: { exploring: ['exploring', 'just thinking', 'early', 'still figuring'], build_mode: ['build mode', 'ready to build', 'implementation', 'ship'] } },
|
|
84
|
+
|
|
85
|
+
// ── collaboration_and_relationship ──
|
|
86
|
+
{ field: 'feedback_pref', layer: 'collaboration_and_relationship', triggers: ['blunt', 'direct feedback', "don't soften", 'tell me what is wrong', 'diplomatic', 'gentle', 'be direct', "don't sugarcoat", 'honest but kind', 'real problems', 'tell me if', "don't be harsh", 'be honest'],
|
|
87
|
+
values: { blunt: ['blunt', "don't soften", "don't sugarcoat", 'tell me what is wrong', 'be direct', 'real problems', 'tell me if', 'be honest', "don't hold back"], direct_balanced: ['direct but', 'balanced'], diplomatic: ['diplomatic', 'gentle', 'honest but kind', "don't be harsh", 'kind'] } },
|
|
88
|
+
{ field: 'disagreement_style', layer: 'collaboration_and_relationship', triggers: ['push back', 'disagree', 'challenge', 'ask questions to understand', 'revisit later', 'disagree and commit', 'challenged'],
|
|
89
|
+
values: { push_back_now: ['push back', 'challenge', 'say it now', 'challenged'], ask_first: ['ask questions to understand', 'understand their reasoning'], revisit_later: ['revisit later'], disagree_commit: ['disagree and commit'] } },
|
|
90
|
+
{ field: 'audience_adaptation', layer: 'collaboration_and_relationship', triggers: ['for my audience', 'simplify for', 'non-technical audience', 'same level', 'pull up', 'going to', 'cut the jargon for', 'they would say', 'not marketers', 'small-business owners', 'the way they'],
|
|
91
|
+
values: { simplify_aggressive: ['simplify for', 'dumb down', 'lay terms', 'cut the jargon', 'they would say', 'not marketers', 'small-business', 'the way they'], accuracy_slow: ['accurate', "don't oversimplify"], same_level: ['same level'], pull_up: ['pull up', 'level up'] } },
|
|
92
|
+
|
|
93
|
+
// ── decision_and_risk ──
|
|
94
|
+
{ field: 'options_vs_recommendation', layer: 'decision_and_risk', triggers: ['recommend', 'which one', 'give me options', 'lay out choices', 'just decide', 'make the call', 'one recommendation', 'just pick', 'pick one', 'pick for me', 'what would you', 'three or four', 'several approaches', 'compare', 'different approaches', "don't just give me one", 'alternatives'],
|
|
95
|
+
values: { single_recommendation: ['recommend', 'which one', 'make the call', 'just decide', 'one recommendation', 'just pick', 'pick one', 'pick for me', 'what would you go', 'go with'], option_set: ['give me options', 'lay out choices', 'show me options', 'alternatives', 'three or four', 'several approaches', 'different approaches', 'compare', "don't just give me one", "don't just give me"] } },
|
|
96
|
+
{ field: 'ambiguity_posture', layer: 'decision_and_risk', triggers: ['decisive', "don't hedge", 'stop hedging', 'be definitive', 'commit to a position', 'wrong and decisive', 'be confident'],
|
|
97
|
+
values: { default: ['decisive', "don't hedge", 'stop hedging', 'definitive', 'wrong and decisive', 'commit', 'be confident'], ask: ['ask before deciding'], delay: ['wait', 'delay'] } },
|
|
98
|
+
{ field: 'speed_vs_quality', layer: 'decision_and_risk', triggers: ['speed over', 'quality over', 'accuracy over', 'thorough over', 'fast over', 'good enough', "don't need it perfect", "don't overthink", 'ship', 'speed matters', 'perfect on the first', 'a/b test'],
|
|
99
|
+
scalar: t => /quality over|accuracy over|thorough over|cover the risk/.test(t) ? 0.8 : /speed over|fast over|quick over|good enough|don.t need it perfect|don.t overthink|ship|speed matters|improve later/.test(t) ? 0.15 : 0.5 },
|
|
100
|
+
{ field: 'evidence_threshold', layer: 'decision_and_risk', triggers: ['cover the risks', 'evidence', 'certainty', 'nail it down', 'need proof', 'ballpark is fine', 'cite', 'source', 'trace back', 'strength of the evidence', "don't state that as settled", 'contested', 'be careful', 'replicated'],
|
|
101
|
+
scalar: t => /cover the risk|evidence|certainty|nail it down|proof|high bar|cite|source|trace back|strength of the evidence|don.t state|contested|be careful|replicated/.test(t) ? 0.8 : /ballpark|rough|good enough/.test(t) ? 0.2 : 0.5 },
|
|
102
|
+
|
|
103
|
+
// ── big_picture ──
|
|
104
|
+
{ field: 'stable_principles', layer: 'big_picture', triggers: ['believes', 'principle', 'values', 'open to changing', 'pragmatic', 'idealist', 'always'],
|
|
105
|
+
arrayValueFromText: true },
|
|
106
|
+
{ field: 'process_narration_pref', layer: 'big_picture', triggers: ['skip the meta', 'no commentary', 'show your thinking', 'think out loud', "don't narrate"],
|
|
107
|
+
values: { skip_meta: ['skip the meta', 'no commentary', "don't narrate", 'no meta'], show_meta: ['show your thinking', 'think out loud', 'show your plan'] } },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
// Human-readable anchor per field: a short natural sentence describing what the
|
|
111
|
+
// field captures. Embedded once and cached by the embedding mapper. Falls back
|
|
112
|
+
// to joining the field's triggers if no explicit anchor is given.
|
|
113
|
+
export const FIELD_ANCHORS = {
|
|
114
|
+
response_length_pref: 'how long or short the user wants answers to be',
|
|
115
|
+
tone_register: 'how casual or formal the user wants the tone',
|
|
116
|
+
formatting_prefs: 'whether the user prefers prose, bullets, tables, headers, or code blocks',
|
|
117
|
+
jargon_tolerance: 'whether to define technical terms or assume the user knows them',
|
|
118
|
+
directness: 'the user wants the answer to get to the point with no preamble',
|
|
119
|
+
pet_peeves: 'things the AI does that annoy or bother the user',
|
|
120
|
+
iteration_style: 'whether the user iterates and refines or wants one final answer',
|
|
121
|
+
ambiguity_response: 'whether the user wants clarifying questions or wants the AI to proceed',
|
|
122
|
+
detail_vs_principle: 'whether the user wants step-by-step detail or the principle first',
|
|
123
|
+
autonomy_pref: 'whether the user wants the AI to just act or to confirm before acting',
|
|
124
|
+
reasoning_visibility: 'whether the user wants to see the reasoning or just the answer',
|
|
125
|
+
scope_discipline: 'whether the user wants the AI to stay scoped or be proactive',
|
|
126
|
+
quality_word: 'the quality the user values most, like elegant, useful, or surprising',
|
|
127
|
+
problem_solving_move: 'how the user approaches problems, like decomposing or mapping the system',
|
|
128
|
+
mental_model_inventory: 'mental models the user reasons with, like first principles or theory of constraints',
|
|
129
|
+
primary_expertise: 'the field the user is an expert in or works in professionally',
|
|
130
|
+
domain_depth_pref: 'whether the user wants deep technical depth or a high-level overview',
|
|
131
|
+
active_learning_areas: 'topics the user is currently learning or curious about',
|
|
132
|
+
domain_vocabulary: 'specialized terms or jargon the user uses',
|
|
133
|
+
expertise_level: 'whether the user is an expert, technical, or general user',
|
|
134
|
+
early_project_mode: 'whether the user explores widely or commits fast early in a project',
|
|
135
|
+
urgency_baseline: 'whether the user is in a hurry or has no rush',
|
|
136
|
+
working_mode: 'whether the user is exploring or in build mode',
|
|
137
|
+
feedback_pref: 'whether the user wants blunt or diplomatic feedback',
|
|
138
|
+
disagreement_style: 'how the user handles disagreement, like pushing back or asking questions first',
|
|
139
|
+
audience_adaptation: 'how the user adapts communication for different audiences',
|
|
140
|
+
options_vs_recommendation: 'whether the user wants a single recommendation or a set of options',
|
|
141
|
+
ambiguity_posture: 'whether the user wants the AI to be decisive or to hedge',
|
|
142
|
+
speed_vs_quality: 'whether the user values speed or quality more',
|
|
143
|
+
evidence_threshold: 'how much evidence the user needs before committing',
|
|
144
|
+
stable_principles: 'enduring principles or values the user holds',
|
|
145
|
+
process_narration_pref: 'whether the user wants the AI to narrate its process or skip the meta',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function anchorFor(entry) {
|
|
149
|
+
return FIELD_ANCHORS[entry.field] || entry.triggers.join(', ');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function hasNeg(text) {
|
|
153
|
+
const t = text.toLowerCase();
|
|
154
|
+
return NEG_CUES.some(c => t.includes(c));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Cues that are short, common substrings of unrelated words
|
|
158
|
+
// (table↔comfortable, code↔decode, list↔realistic, scan↔scandal,
|
|
159
|
+
// prose↔prosecutor). Match THESE at word boundaries; everything else stays
|
|
160
|
+
// substring, to preserve intended stem matches ("frustrat"→frustrated,
|
|
161
|
+
// "annoy"→annoying). Used for both trigger and value-cue matching.
|
|
162
|
+
const WHOLE_WORD_CUES = new Set(['table', 'tables', 'code', 'list', 'scan', 'prose']);
|
|
163
|
+
export function cueHit(text, cue) {
|
|
164
|
+
if (WHOLE_WORD_CUES.has(cue)) return new RegExp(`\\b${cue}\\b`).test(text);
|
|
165
|
+
return text.includes(cue);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Decide a single value's polarity by PROXIMITY to a negation cue: a value is
|
|
169
|
+
// negative only when a negation cue ("no", "stop", "don't", "avoid", "without",
|
|
170
|
+
// …) sits just before one of its cue words. So "stop using bullets, just write
|
|
171
|
+
// prose" splits into bullets→negative + prose→positive, instead of the old
|
|
172
|
+
// blanket flip that wrongly marked prose negative too. Window-based, so a
|
|
173
|
+
// negation distributed across a clause ("avoid bullets and headers") can miss
|
|
174
|
+
// the second item — the embedding mapper is the fuller fix.
|
|
175
|
+
const NEG_WINDOW = 16;
|
|
176
|
+
function valuePolarity(t, cues) {
|
|
177
|
+
for (const c of cues) {
|
|
178
|
+
let idx = t.indexOf(c);
|
|
179
|
+
while (idx !== -1) {
|
|
180
|
+
const before = t.slice(Math.max(0, idx - NEG_WINDOW), idx);
|
|
181
|
+
if (NEG_CUES.some(n => before.includes(n.trim()))) return 'negative';
|
|
182
|
+
idx = t.indexOf(c, idx + c.length);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return 'positive';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Field-level base polarity for non-split fields: forced (pet_peeves) >
|
|
189
|
+
// whole-observation negation for opted-in fields > positive. Enum/scalar fields
|
|
190
|
+
// encode direction in their VALUE, so "don't dumb it down" stays positive.
|
|
191
|
+
function basePolarity(entry, t) {
|
|
192
|
+
if (entry.forcePolarity) return entry.forcePolarity;
|
|
193
|
+
if (entry.detectNeg && hasNeg(t)) return 'negative';
|
|
194
|
+
return 'positive';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Resolve a matched catalog entry to ZERO OR MORE {value, polarity} signal
|
|
198
|
+
// groups. Most fields yield one group; a detectNeg ranked_list
|
|
199
|
+
// (formatting_prefs) can yield two — a positive subset and a negative subset —
|
|
200
|
+
// when one observation expresses both a wanted and an unwanted format.
|
|
201
|
+
// Shared by the lexical and embedding mappers once the FIELD is chosen.
|
|
202
|
+
export function resolveSignalGroups(entry, obs) {
|
|
203
|
+
const t = obs.toLowerCase().replace(/^[-*•]\s*/, '').trim();
|
|
204
|
+
|
|
205
|
+
if (entry.values && entry.arrayValue) {
|
|
206
|
+
const pos = [], neg = [];
|
|
207
|
+
for (const [val, cues] of Object.entries(entry.values)) {
|
|
208
|
+
if (!cues.some(c => cueHit(t, c))) continue;
|
|
209
|
+
const pol = entry.detectNeg ? valuePolarity(t, cues) : 'positive';
|
|
210
|
+
(pol === 'negative' ? neg : pos).push(val);
|
|
211
|
+
}
|
|
212
|
+
const out = [];
|
|
213
|
+
if (pos.length) out.push({ value: pos, polarity: 'positive' });
|
|
214
|
+
if (neg.length) out.push({ value: neg, polarity: 'negative' });
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
if (entry.values) {
|
|
218
|
+
for (const [val, cues] of Object.entries(entry.values))
|
|
219
|
+
if (cues.some(c => cueHit(t, c))) return [{ value: val, polarity: basePolarity(entry, t) }];
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
if (entry.scalar) return [{ value: entry.scalar(t), polarity: basePolarity(entry, t) }];
|
|
223
|
+
if (entry.arrayValueFromText) return [{ value: [obs.replace(/^[-*•]\s*/, '').trim()], polarity: basePolarity(entry, t) }];
|
|
224
|
+
if (entry.stringFromText) return [{ value: obs.replace(/^[-*•]\s*/, '').trim(), polarity: basePolarity(entry, t) }];
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Back-compat shim: first group only (or a null value when nothing matched).
|
|
229
|
+
export function resolveValueAndPolarity(entry, obs) {
|
|
230
|
+
const groups = resolveSignalGroups(entry, obs);
|
|
231
|
+
return groups.length ? groups[0] : { value: null, polarity: 'positive' };
|
|
232
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Phaedo Loop 2 extraction — Path A signal lift (ported from the parallel impl).
|
|
2
|
+
//
|
|
3
|
+
// Turns the long-running phaedo_behavioral_signals counter map (Path A's
|
|
4
|
+
// regex-correction hits, accumulated for months) into §5.1 signals tagged
|
|
5
|
+
// source='delta'. Path A produces the bulk of Phaedo's real signal, but the
|
|
6
|
+
// projection layer otherwise only sees questionnaire + extraction signals; this
|
|
7
|
+
// lift surfaces "things the user actively pushed back on" into the same §4.2
|
|
8
|
+
// fingerprint, with provenance that lets the receiving AI weight them highly.
|
|
9
|
+
//
|
|
10
|
+
// Pure: counters in → signals out. The orchestrator concats these with the
|
|
11
|
+
// extraction signals so they reconcile in one merge. Emits CANONICAL layer ids
|
|
12
|
+
// (projection.LAYER_ID_MAP maps them to the questionnaire's ids, same as
|
|
13
|
+
// extraction signals).
|
|
14
|
+
//
|
|
15
|
+
// IDEMPOTENCY: a delta signal's observation_count is Path A's CUMULATIVE total,
|
|
16
|
+
// not new evidence — so reconcile REPLACES a delta signal on key match rather
|
|
17
|
+
// than summing (reconcile.js treats source==='delta' as a snapshot). Re-lifting
|
|
18
|
+
// the same counters every pass is therefore a no-op, not inflation.
|
|
19
|
+
|
|
20
|
+
// Path A signal name → (canonical layer, field, polarity, value). Values match
|
|
21
|
+
// the enums/scalars projection renders, so its PHRASING table picks them up.
|
|
22
|
+
// Unmapped Path A signals are simply skipped.
|
|
23
|
+
export const SIGNAL_MAP = {
|
|
24
|
+
// ── surface ──
|
|
25
|
+
too_long: { layer: 'surface', field: 'response_length_pref', polarity: 'positive', value: 'short' },
|
|
26
|
+
too_short: { layer: 'surface', field: 'response_length_pref', polarity: 'positive', value: 'thorough' },
|
|
27
|
+
avoid_bullets: { layer: 'surface', field: 'formatting_prefs', polarity: 'negative', value: ['bullets'] },
|
|
28
|
+
prefer_bullets: { layer: 'surface', field: 'formatting_prefs', polarity: 'positive', value: ['bullets'] },
|
|
29
|
+
no_headers: { layer: 'surface', field: 'formatting_prefs', polarity: 'negative', value: ['headers'] },
|
|
30
|
+
use_headers: { layer: 'surface', field: 'formatting_prefs', polarity: 'positive', value: ['headers'] },
|
|
31
|
+
use_table: { layer: 'surface', field: 'formatting_prefs', polarity: 'positive', value: ['tables'] },
|
|
32
|
+
use_code_block: { layer: 'surface', field: 'formatting_prefs', polarity: 'positive', value: ['code_blocks'] },
|
|
33
|
+
no_examples: { layer: 'surface', field: 'formatting_prefs', polarity: 'negative', value: ['inline_examples'] },
|
|
34
|
+
wants_examples: { layer: 'surface', field: 'formatting_prefs', polarity: 'positive', value: ['inline_examples'] },
|
|
35
|
+
prefers_casual: { layer: 'surface', field: 'tone_register', polarity: 'positive', value: 'casual' },
|
|
36
|
+
prefers_formal: { layer: 'surface', field: 'tone_register', polarity: 'positive', value: 'formal' },
|
|
37
|
+
more_direct: { layer: 'surface', field: 'directness', polarity: 'positive', value: 0.85 },
|
|
38
|
+
no_preamble: { layer: 'surface', field: 'directness', polarity: 'positive', value: 0.85 },
|
|
39
|
+
plainer_language: { layer: 'surface', field: 'jargon_tolerance', polarity: 'positive', value: 0.15 },
|
|
40
|
+
|
|
41
|
+
// ── behavioral ──
|
|
42
|
+
fewer_questions: { layer: 'behavioral', field: 'autonomy_pref', polarity: 'positive', value: 0.85 },
|
|
43
|
+
wants_confirmation: { layer: 'behavioral', field: 'autonomy_pref', polarity: 'positive', value: 0.15 },
|
|
44
|
+
show_reasoning: { layer: 'behavioral', field: 'reasoning_visibility', polarity: 'positive', value: 'show' },
|
|
45
|
+
skip_reasoning: { layer: 'behavioral', field: 'reasoning_visibility', polarity: 'positive', value: 'skip' },
|
|
46
|
+
iterate_more: { layer: 'behavioral', field: 'iteration_style', polarity: 'positive', value: 'iterative_refine' },
|
|
47
|
+
|
|
48
|
+
// ── domain_and_expertise (legacy Path A layer 'domain') ──
|
|
49
|
+
overexplaining: { layer: 'domain_and_expertise', field: 'expertise_level', polarity: 'positive', value: 'power' },
|
|
50
|
+
too_technical: { layer: 'domain_and_expertise', field: 'expertise_level', polarity: 'positive', value: 'general' },
|
|
51
|
+
wants_depth: { layer: 'domain_and_expertise', field: 'domain_depth_pref', polarity: 'positive', value: 'full_technical' },
|
|
52
|
+
conceptual_only: { layer: 'domain_and_expertise', field: 'domain_depth_pref', polarity: 'positive', value: 'surface' },
|
|
53
|
+
|
|
54
|
+
// ── temporal_and_context ──
|
|
55
|
+
urgent: { layer: 'temporal_and_context', field: 'urgency_baseline', polarity: 'positive', value: 0.85 },
|
|
56
|
+
no_rush: { layer: 'temporal_and_context', field: 'urgency_baseline', polarity: 'positive', value: 0.15 },
|
|
57
|
+
exploring_phase: { layer: 'temporal_and_context', field: 'early_project_mode', polarity: 'positive', value: 'explore_wide' },
|
|
58
|
+
build_mode: { layer: 'temporal_and_context', field: 'working_mode', polarity: 'positive', value: 'build_mode' },
|
|
59
|
+
|
|
60
|
+
// ── decision_and_risk ──
|
|
61
|
+
wants_decisiveness: { layer: 'decision_and_risk', field: 'ambiguity_posture', polarity: 'positive', value: 'default' },
|
|
62
|
+
wants_bold_call: { layer: 'decision_and_risk', field: 'ambiguity_posture', polarity: 'positive', value: 'default' },
|
|
63
|
+
fewer_options: { layer: 'decision_and_risk', field: 'options_vs_recommendation', polarity: 'positive', value: 'single_recommendation' },
|
|
64
|
+
wants_options: { layer: 'decision_and_risk', field: 'options_vs_recommendation', polarity: 'positive', value: 'option_set' },
|
|
65
|
+
wants_options_decision: { layer: 'decision_and_risk', field: 'options_vs_recommendation', polarity: 'positive', value: 'option_set' },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Confidence as a stepped function of corrective hit count: one hit is
|
|
69
|
+
// suggestive (0.4); five is solid (0.75); ten+ is strong (0.85).
|
|
70
|
+
export function countConfidence(n) {
|
|
71
|
+
if (!n || n <= 0) return 0;
|
|
72
|
+
if (n >= 10) return 0.85;
|
|
73
|
+
if (n >= 5) return 0.75;
|
|
74
|
+
if (n >= 3) return 0.65;
|
|
75
|
+
if (n >= 2) return 0.55;
|
|
76
|
+
return 0.4;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// liftDeltaSignals(behavioralSignals, now?) → mapper-shaped signals tagged
|
|
80
|
+
// source='delta'. behavioralSignals is the { layer: { signalName: count } } map
|
|
81
|
+
// (phaedo_behavioral_signals); the outer layer key is ignored — SIGNAL_MAP
|
|
82
|
+
// determines the canonical layer/field from the signal name. `now` is injected
|
|
83
|
+
// for deterministic timestamps. Empty/missing input → [].
|
|
84
|
+
export function liftDeltaSignals(behavioralSignals, now) {
|
|
85
|
+
if (!behavioralSignals || typeof behavioralSignals !== 'object') return [];
|
|
86
|
+
const stamp = now || new Date().toISOString();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const signalCounts of Object.values(behavioralSignals)) {
|
|
89
|
+
if (!signalCounts || typeof signalCounts !== 'object') continue;
|
|
90
|
+
for (const [signalName, count] of Object.entries(signalCounts)) {
|
|
91
|
+
const mapping = SIGNAL_MAP[signalName];
|
|
92
|
+
if (!mapping) continue;
|
|
93
|
+
const confidence = countConfidence(count);
|
|
94
|
+
if (confidence <= 0) continue;
|
|
95
|
+
out.push({
|
|
96
|
+
layer: mapping.layer,
|
|
97
|
+
field: mapping.field,
|
|
98
|
+
polarity: mapping.polarity,
|
|
99
|
+
value: mapping.value,
|
|
100
|
+
confidence,
|
|
101
|
+
source: 'delta',
|
|
102
|
+
observation_count: count,
|
|
103
|
+
session_refs: [],
|
|
104
|
+
last_observed: stamp,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Phaedo Loop 2 extraction — fingerprint-update builder (signals → §4.2 layer docs).
|
|
2
|
+
//
|
|
3
|
+
// NOTE: distinct from the INJECTION projection (context-block.js renderContextBlock
|
|
4
|
+
// / mcp/projection.js), which renders the user-facing profile block. THIS step turns
|
|
5
|
+
// the mapper's flat signal list into the persistent fingerprint's §4.2 layer
|
|
6
|
+
// documents. (Renamed from extraction/projection.js to disambiguate "projection".)
|
|
7
|
+
//
|
|
8
|
+
// The extraction model emits signals[] + confidence ONLY; the human-readable
|
|
9
|
+
// `summary` is rendered downstream by this deterministic step (task-spec §4.2). It
|
|
10
|
+
// takes the flat signal list from the mapper and produces a partial fingerprint
|
|
11
|
+
// update: one protocol §4.2 layer document per layer that has signal —
|
|
12
|
+
// { layer_version, last_updated, confidence, signals[], summary }
|
|
13
|
+
// where each signal is enriched to the full §5.1 object.
|
|
14
|
+
//
|
|
15
|
+
// No raw transcript text ever enters a layer doc — only derived structured
|
|
16
|
+
// values + provenance counts (§7 privacy constraint).
|
|
17
|
+
|
|
18
|
+
import { CATALOG } from './catalog.js';
|
|
19
|
+
import { resolveDimension, valKey } from './resolve.js'; // §4.7 deterministic resolution (Proposal 0006)
|
|
20
|
+
|
|
21
|
+
const LAYER_VERSION = '0.1';
|
|
22
|
+
|
|
23
|
+
// The §4.7 canonical layer-key rename has landed (migration.js canonicalizes on
|
|
24
|
+
// vault read; Path A + the questionnaire schema now use canonical ids), so
|
|
25
|
+
// extraction's canonical layer ids match the stored fingerprint directly — no
|
|
26
|
+
// down-mapping needed.
|
|
27
|
+
|
|
28
|
+
function uuid() {
|
|
29
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
30
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
31
|
+
const r = Math.random() * 16 | 0;
|
|
32
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Readable field labels for the rendered summary. Missing fields fall back to a
|
|
37
|
+
// humanized version of the field id.
|
|
38
|
+
const FIELD_LABELS = {
|
|
39
|
+
response_length_pref: 'Response length', tone_register: 'Tone',
|
|
40
|
+
formatting_prefs: 'Formatting', jargon_tolerance: 'Jargon', directness: 'Directness',
|
|
41
|
+
pet_peeves: 'Pet peeves', iteration_style: 'Iteration', ambiguity_response: 'On ambiguity',
|
|
42
|
+
detail_vs_principle: 'Detail vs principle', autonomy_pref: 'Autonomy',
|
|
43
|
+
reasoning_visibility: 'Reasoning', scope_discipline: 'Scope', quality_word: 'Values quality',
|
|
44
|
+
problem_solving_move: 'Problem-solving', mental_model_inventory: 'Mental models',
|
|
45
|
+
primary_expertise: 'Expertise', domain_depth_pref: 'Depth', active_learning_areas: 'Learning',
|
|
46
|
+
domain_vocabulary: 'Vocabulary', expertise_level: 'Level', early_project_mode: 'Early-project mode',
|
|
47
|
+
urgency_baseline: 'Urgency', working_mode: 'Working mode', feedback_pref: 'Feedback',
|
|
48
|
+
disagreement_style: 'Disagreement', audience_adaptation: 'Audience',
|
|
49
|
+
options_vs_recommendation: 'Options vs recommendation', ambiguity_posture: 'Decisiveness',
|
|
50
|
+
speed_vs_quality: 'Speed vs quality', evidence_threshold: 'Evidence bar',
|
|
51
|
+
stable_principles: 'Principles', process_narration_pref: 'Process narration',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Allowed enum values per field, derived from the catalog (the same source of
|
|
55
|
+
// truth the mapper resolves values against). Only fields that resolve to a fixed
|
|
56
|
+
// value set appear here; scalar and free-text fields (no `.values`) admit
|
|
57
|
+
// anything. Used to keep opaque/garbage values — e.g. a model emitting "s2_d"
|
|
58
|
+
// for tone_register — OUT of the rendered summary. The signal is retained in
|
|
59
|
+
// signals[] for provenance; it is simply never surfaced in the injected
|
|
60
|
+
// projection. Ingestion-time enum validation is the stricter v0.2 fix (schema
|
|
61
|
+
// tightening); this is the projection-layer guard.
|
|
62
|
+
const ALLOWED_VALUES = (() => {
|
|
63
|
+
const m = {};
|
|
64
|
+
for (const entry of CATALOG) {
|
|
65
|
+
if (entry && entry.values && typeof entry.values === 'object')
|
|
66
|
+
m[entry.field] = new Set(Object.keys(entry.values));
|
|
67
|
+
}
|
|
68
|
+
return m;
|
|
69
|
+
})();
|
|
70
|
+
|
|
71
|
+
// A value conforms if the field has no fixed set (scalar/free-text), or every
|
|
72
|
+
// token is in the set. Array fields are checked token-by-token.
|
|
73
|
+
function valueConforms(field, value) {
|
|
74
|
+
const allowed = ALLOWED_VALUES[field];
|
|
75
|
+
if (!allowed) return true;
|
|
76
|
+
const tokens = Array.isArray(value) ? value : [value];
|
|
77
|
+
return tokens.length > 0 && tokens.every(v => allowed.has(v));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Drop non-conforming members from an enum array value; scalars/free-text pass
|
|
81
|
+
// through untouched (their conformance is decided by valueConforms).
|
|
82
|
+
function sanitizeValue(field, value) {
|
|
83
|
+
const allowed = ALLOWED_VALUES[field];
|
|
84
|
+
if (!allowed || !Array.isArray(value)) return value;
|
|
85
|
+
return value.filter(v => allowed.has(v));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// §4.6 layer admission: behavioral layers carry persona patterns, never episodic
|
|
89
|
+
// content. Drop a signal whose value reads as an episodic fact (URL / email /
|
|
90
|
+
// domain — e.g. an extracted "principle" of "values getphaedo.com"). High-
|
|
91
|
+
// precision markers only. MUST match the conformance check EPISODIC_RE in
|
|
92
|
+
// spec/validate-fingerprint.mjs.
|
|
93
|
+
const EPISODIC_RE = /(https?:\/\/|\bwww\.)|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|\b[a-z0-9][a-z0-9-]*\.(com|org|net|io|so|co|ai|app|xyz|info)\b/i;
|
|
94
|
+
function isEpisodic(value) {
|
|
95
|
+
const text = Array.isArray(value) ? value.join(' ') : (value == null ? '' : String(value));
|
|
96
|
+
return EPISODIC_RE.test(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function humanizeValue(value) {
|
|
100
|
+
if (Array.isArray(value)) return value.map(humanizeValue).join(', ');
|
|
101
|
+
if (typeof value === 'number') {
|
|
102
|
+
if (value >= 0.7) return 'high';
|
|
103
|
+
if (value <= 0.3) return 'low';
|
|
104
|
+
return 'medium';
|
|
105
|
+
}
|
|
106
|
+
return String(value).replace(/_/g, ' ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One readable line per signal for the layer summary.
|
|
110
|
+
export function describeSignal(sig) {
|
|
111
|
+
const label = FIELD_LABELS[sig.field] || sig.field.replace(/_/g, ' ');
|
|
112
|
+
const val = humanizeValue(sig.value);
|
|
113
|
+
return sig.polarity === 'negative' ? `${label}: avoids ${val}` : `${label}: ${val}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Enrich a mapper signal to the full §5.1 object. sessionId/now provide
|
|
117
|
+
// provenance (session_refs, last_observed); raw text (`_from`/`_sim`) is dropped
|
|
118
|
+
// so no transcript leaks into the stored signal.
|
|
119
|
+
// Evidence basis of a signal, derived from its `source` (proposal 0002 vocabulary):
|
|
120
|
+
// how the signal was learned. Lets a consumer (e.g. the consultation resolver) weigh
|
|
121
|
+
// observed/corroborated signals above self-reported ones without re-deriving it.
|
|
122
|
+
function evidenceBasis(source) {
|
|
123
|
+
const s = String(source || '').toLowerCase();
|
|
124
|
+
if (s.includes('corrobor') || s.includes('reconcil')) return 'corroborated';
|
|
125
|
+
if (s.includes('question') || s.includes('self') || s.includes('interview')) return 'self_reported';
|
|
126
|
+
return 'observed'; // extraction/delta-sourced
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toSignalObject(sig, sessionId, now) {
|
|
130
|
+
const source = sig.source || 'extraction';
|
|
131
|
+
return {
|
|
132
|
+
signal_id: uuid(),
|
|
133
|
+
field: sig.field,
|
|
134
|
+
dimension: sig.field, // proposal 0002: the decision/style axis (== field)
|
|
135
|
+
// §4.2.7 optional domain (PROTOTYPE: set by extraction/domain-inference.js for
|
|
136
|
+
// Decision & Risk signals when a session's domain is unambiguous; else absent).
|
|
137
|
+
...(typeof sig.domain === 'string' ? { domain: sig.domain } : {}),
|
|
138
|
+
polarity: sig.polarity,
|
|
139
|
+
value: sig.value,
|
|
140
|
+
confidence: sig.confidence ?? 0.6,
|
|
141
|
+
source,
|
|
142
|
+
evidence_basis: evidenceBasis(source), // proposal 0002: observed | corroborated | self_reported
|
|
143
|
+
observation_count: sig.observation_count ?? 1,
|
|
144
|
+
// Preserve provenance the producer set (delta signals carry [] + their own
|
|
145
|
+
// timestamp); fall back to this pass's session/time for extraction signals.
|
|
146
|
+
session_refs: sig.session_refs ?? (sessionId ? [sessionId] : []),
|
|
147
|
+
last_observed: sig.last_observed ?? now,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mean(nums) {
|
|
152
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Group mapper signals by layer into a partial fingerprint update.
|
|
156
|
+
// signals — flat array from mapObservations / mapObservationsEmbed
|
|
157
|
+
// opts.sessionId — session id for provenance (session_refs)
|
|
158
|
+
// opts.now — RFC 3339 timestamp (injected so the fn is pure/testable)
|
|
159
|
+
// Returns { [layerId]: { layer_version, last_updated, confidence, signals, summary } }.
|
|
160
|
+
export function buildFingerprintUpdate(signals, opts = {}) {
|
|
161
|
+
const now = opts.now || new Date().toISOString();
|
|
162
|
+
const byLayer = {};
|
|
163
|
+
for (const sig of signals) {
|
|
164
|
+
(byLayer[sig.layer] ??= []).push(toSignalObject(sig, opts.sessionId, now));
|
|
165
|
+
}
|
|
166
|
+
const update = {};
|
|
167
|
+
for (const [layerId, layerSignals] of Object.entries(byLayer)) {
|
|
168
|
+
update[layerId] = {
|
|
169
|
+
layer_version: LAYER_VERSION,
|
|
170
|
+
last_updated: now,
|
|
171
|
+
confidence: Number(mean(layerSignals.map(s => s.confidence)).toFixed(3)),
|
|
172
|
+
signals: layerSignals,
|
|
173
|
+
summary: renderLayerSummary(layerSignals),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return update;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Render a layer's signals into a short readable summary block.
|
|
180
|
+
//
|
|
181
|
+
// Four projection-quality guards (none mutate signals[] — provenance is kept):
|
|
182
|
+
// 1. Opaque-value drop — a signal whose enum value is not in the catalog's
|
|
183
|
+
// value set for its field is skipped (e.g. a model emitting "s2_d" for
|
|
184
|
+
// tone_register). Array values keep only their conforming members.
|
|
185
|
+
// 2. Same-dimension resolution — collapse to one line per (field, polarity,
|
|
186
|
+
// domain) using the §4.7 / Proposal 0006 resolver (resolveDimension):
|
|
187
|
+
// evidence-tier precedence → observation-count → recency — the SAME
|
|
188
|
+
// deterministic rule every consumer applies, not raw confidence. So a
|
|
189
|
+
// contradiction that survives upstream merge resolves to the authoritative
|
|
190
|
+
// value, identically across implementations.
|
|
191
|
+
// 3. Unresolved-drop — when the resolver returns status:"unresolved" (a thin
|
|
192
|
+
// inferred value contradicting a self-report, below the evidence floor) we
|
|
193
|
+
// DON'T assert it: an injected summary stating a confident-but-contradicted
|
|
194
|
+
// value is exactly the autonomy-class defect 0006 fixes. Omit the line.
|
|
195
|
+
// 4. Duplicate-line drop — an untagged and a domain-tagged signal that
|
|
196
|
+
// humanize to the same text must not appear twice.
|
|
197
|
+
export function renderLayerSummary(signals, suppress) {
|
|
198
|
+
const sup = suppress instanceof Set ? suppress : null;
|
|
199
|
+
const groups = new Map();
|
|
200
|
+
for (const s of signals || []) {
|
|
201
|
+
// §4.7 injection-suppression: a dimension with an OPEN conflict (a contested
|
|
202
|
+
// observed-vs-self-report value, below the evidence floor) is WITHHELD from the
|
|
203
|
+
// projection until the subject settles it — the popup conflict dot gates what the
|
|
204
|
+
// AI sees, rather than asserting a value we are not yet confident in.
|
|
205
|
+
if (sup && sup.has(s.field + (typeof s.domain === 'string' ? `::${s.domain}` : ''))) continue;
|
|
206
|
+
const value = sanitizeValue(s.field, s.value); // array fields: strip bad members
|
|
207
|
+
if (!valueConforms(s.field, value)) continue; // (1) drop opaque/garbage (or now-empty array)
|
|
208
|
+
if (isEpisodic(value)) continue; // §4.6: drop episodic content (URL/email/domain)
|
|
209
|
+
const key = `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
|
|
210
|
+
if (!groups.has(key)) groups.set(key, []); // group the dimension; resolve below
|
|
211
|
+
groups.get(key).push({ ...s, value });
|
|
212
|
+
}
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const lines = [];
|
|
215
|
+
for (const sigs of groups.values()) {
|
|
216
|
+
const r = resolveDimension(sigs); // (2) §4.7 deterministic winner
|
|
217
|
+
if (!r || r.status === 'unresolved') continue; // (3) don't assert a contested value
|
|
218
|
+
const winner = sigs.find(s => valKey(s.value) === valKey(r.value)) || sigs[0];
|
|
219
|
+
const line = `- ${describeSignal(winner)}`;
|
|
220
|
+
if (seen.has(line)) continue; // (4) dedupe identical display lines
|
|
221
|
+
seen.add(line);
|
|
222
|
+
lines.push(line);
|
|
223
|
+
}
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { uuid, LAYER_VERSION, FIELD_LABELS };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Declares extraction/*.js as ES modules so Node (tests, the eval harness in scripts/local-model-eval) can import them directly. The extension itself loads these unbundled as ESM via manifest.json (service_worker type:module); browsers ignore package.json, so this is node-only and has no effect on the shipped extension.",
|
|
3
|
+
"type": "module"
|
|
4
|
+
}
|