phaedo-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -323,10 +323,43 @@ function buildLayerConfidence(fingerprint) {
323
323
  });
324
324
  }
325
325
 
326
+ // Mutually-exclusive behavioral signal pairs. When both fire, injecting both
327
+ // produces a self-contradicting instruction (e.g. "Act without checking" AND
328
+ // "Confirm before acting" under Autonomy). Resolve to the dominant side by
329
+ // observation count; on an exact tie neither is a reliable directive, so drop
330
+ // both rather than inject a contradiction.
331
+ const OPPOSING_SIGNALS = [
332
+ ['fewer_questions', 'wants_confirmation'],
333
+ ['fewer_options', 'wants_options'],
334
+ ['prefers_casual', 'prefers_formal'],
335
+ ['avoid_bullets', 'prefer_bullets'],
336
+ ['no_headers', 'use_headers'],
337
+ ['skip_reasoning', 'show_reasoning'],
338
+ ['conceptual_only', 'wants_implementation'],
339
+ ['too_long', 'too_short'],
340
+ ['no_examples', 'wants_examples'],
341
+ ['stay_scoped', 'wants_proactive'],
342
+ ['skip_meta', 'show_meta'],
343
+ ];
344
+
345
+ function resolveOpposingSignals(flat) {
346
+ const out = { ...flat };
347
+ for (const [a, b] of OPPOSING_SIGNALS) {
348
+ const av = out[a] || 0, bv = out[b] || 0;
349
+ if (av > 0 && bv > 0) {
350
+ if (av > bv) delete out[b];
351
+ else if (bv > av) delete out[a];
352
+ else { delete out[a]; delete out[b]; } // tie → omit both
353
+ }
354
+ }
355
+ return out;
356
+ }
357
+
326
358
  function synthesizeResponseInstructions(behavioralSignals, cognitiveProfile) {
327
- const flat = {};
359
+ const raw = {};
328
360
  for (const layer of Object.values(behavioralSignals))
329
- for (const [k, v] of Object.entries(layer)) flat[k] = (flat[k] || 0) + v;
361
+ for (const [k, v] of Object.entries(layer)) raw[k] = (raw[k] || 0) + v;
362
+ const flat = resolveOpposingSignals(raw);
330
363
 
331
364
  const inst = { format: [], tone: [], depth: [], autonomy: [], decisions: [], pace: [] };
332
365
 
@@ -401,11 +434,106 @@ function synthesizeResponseInstructions(behavioralSignals, cognitiveProfile) {
401
434
  return inst;
402
435
  }
403
436
 
437
+ // ─── Operating rules (Proposal 0007: authored standing_rules → §9 injection) ──
438
+ // Authored, user-confirmed rules render VERBATIM (§5.2 — never summarized) at the
439
+ // TOP of the profile, above inferred style, so a confirmed rule wins when it
440
+ // conflicts with an inferred habit (§4.3 conflict-priority). Sources every
441
+ // `standing_rules` entry carrying `text` (a kind:"instruction", or a
442
+ // kind:"authorization" that also chose to surface its rule). Ordered by §5.2
443
+ // `priority` (lower first), capped so rules never crowd out the rest of the persona.
444
+ // Raised 8 → 16 once free-form authoring shipped: a subject with the interview's
445
+ // decision rules (priorities 10–40) plus a few hand-authored rules exceeds 8, and
446
+ // the highest-priority-number (newest hand-authored) rule was being silently
447
+ // dropped from the injected block even though the vault + popup kept it.
448
+ const OPERATING_RULES_BUDGET = 16;
449
+ // A delta-promotion injects as a hard constraint only when its evidence-weighted
450
+ // priority (set by rule-review.js deltaPriority) is at/below this — i.e. strongly
451
+ // backed. Legacy flat-50 endorsements sit above it and stay out of injection.
452
+ const DELTA_INJECT_THRESHOLD = 45;
453
+ // Known-safe sources for injection (allow-list). Was a deny-list ("anything not
454
+ // delta_promotion"), which let any unknown / future / typo'd source through silently.
455
+ // New sources must be added here deliberately. `undefined` source is also allowed
456
+ // (legacy test fixtures + kind:'authorization' rules that don't stamp source — the
457
+ // existing behavior we don't want to regress); delta_promotion is conditional on the
458
+ // evidence-weighted priority gate above. Keep in sync with the source values
459
+ // producers actually write (see grep -E "source:\s*['\"]\w+['\"]"); adding a new
460
+ // producer source without listing it here = the rule silently won't inject.
461
+ const INJECT_SOURCE_ALLOW = new Set([
462
+ 'interview', // popup.js addOperatingRule + edited questionnaire decision rules
463
+ 'interview_template', // chip-picked-unedited questionnaire rule
464
+ 'conflict_resolution', // conflict-review.js resolveConflict + revertOverride
465
+ 'delegation_promotion', // act-as-me, §9 normative
466
+ ]);
467
+ // Newlines/tabs collapse to a single space — the Operating Rules block is a
468
+ // one-line-per-rule block. Without scrub, a user-typed rule containing "\n" (or a
469
+ // future producer that emits multi-line text) would silently break out of its line
470
+ // and inject as extra bullets / pseudo-headings, derailing the block's structure.
471
+ // Render-time chokepoint catches every writer (popup, conflict-review, mobile,
472
+ // historical data already on disk) without chasing each call site.
473
+ function sanitizeRuleLine(s) {
474
+ return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
475
+ }
476
+ function renderOperatingRules(fingerprint) {
477
+ const rules = Array.isArray(fingerprint && fingerprint.standing_rules) ? fingerprint.standing_rules : [];
478
+ const seen = new Set();
479
+ const chosen = rules
480
+ .filter(r => r && typeof r.text === 'string' && r.text.trim())
481
+ // Curation: the Operating-rules block is for DELIBERATE hard constraints —
482
+ // authored rules + interview-elicited decision rules + act-as-me (delegation) are
483
+ // ALWAYS injected. Auto-promoted style signals (source:'delta_promotion') inject
484
+ // ONLY when strongly evidenced — rule-review.js stamps them an evidence-weighted
485
+ // priority (lower = stronger), so a well-backed endorsement (≤ threshold) earns a
486
+ // slot while thin/legacy flat-50 ones stay out. Excluded promotions remain on the
487
+ // fingerprint and still drive §10 consult; they just don't dilute injection (the
488
+ // binding constraint is instruction-following fidelity, not tokens).
489
+ .filter(r => {
490
+ if (r.source == null) return true; // legacy / no-source — defensive
491
+ if (r.source === 'delta_promotion') { // priority-gated
492
+ return (Number.isFinite(r.priority) ? r.priority : 1e9) <= DELTA_INJECT_THRESHOLD;
493
+ }
494
+ return INJECT_SOURCE_ALLOW.has(r.source); // explicit allow-list
495
+ })
496
+ // Dedup by normalized text — defensive against near-duplicate authored rules.
497
+ .filter(r => { const k = r.text.trim().toLowerCase().replace(/\s+/g, ' '); if (seen.has(k)) return false; seen.add(k); return true; })
498
+ .sort((a, b) => (Number.isFinite(a.priority) ? a.priority : 1e9) - (Number.isFinite(b.priority) ? b.priority : 1e9))
499
+ .slice(0, OPERATING_RULES_BUDGET);
500
+ if (!chosen.length) return [];
501
+ const lines = [
502
+ '\n## Operating rules (user-confirmed)',
503
+ 'Explicit rules this person has endorsed. Treat them as hard constraints on decisions',
504
+ 'and recommendations, higher priority than inferred style. Apply them silently; do not',
505
+ 'restate them or open replies with "as someone who...".',
506
+ ];
507
+ for (const r of chosen) {
508
+ const exc = typeof r.exception === 'string' && r.exception.trim() ? ` (Exception: ${sanitizeRuleLine(r.exception)})` : '';
509
+ lines.push(`- ${sanitizeRuleLine(r.text)}${exc}`);
510
+ }
511
+ return lines;
512
+ }
513
+
404
514
  // ─── Context block assembly ───────────────────────────────────────────────────
405
515
 
406
516
  function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfile = {}, cognitiveProfile = null, sessionMetrics = {}, mentalModels = null) {
407
517
  const lines = ['<phaedo_user_profile>'];
408
518
 
519
+ // ── Framing (2026-07-04): declares the block as background about the subject,
520
+ // not conversational history. Closes the confabulation defect surfaced by the
521
+ // persona-eval cross-judge (see docs/persona-extraction/persona-effectiveness-eval.md
522
+ // §7.4 Category A) where the model was writing "you mentioned…" attributing
523
+ // fingerprint content to prior turns the user never spoke.
524
+ lines.push(
525
+ 'This is a persona model of the subject — background about how they',
526
+ 'communicate and decide, learned across prior sessions. It is NOT part of',
527
+ 'this conversation; the subject has NOT said any of what follows in this',
528
+ 'session. Use it to calibrate tone, depth, format, and how decisions are',
529
+ 'framed. Do NOT attribute anything below to the subject as if they said it',
530
+ 'here (never "you mentioned…", "as you noted…", or "given your focus on X"',
531
+ 'citing content from this profile) — those attributions would be fabrications.',
532
+ );
533
+
534
+ // ── 0. Operating rules (authored, user-confirmed — highest priority) ─────────
535
+ lines.push(...renderOperatingRules(fingerprint));
536
+
409
537
  // ── 1. Response instructions (derived from observed behavioral corrections) ──
410
538
  const inst = synthesizeResponseInstructions(behavioralSignals, cognitiveProfile);
411
539
  const hasInst = Object.values(inst).some(a => a.length > 0);
@@ -468,6 +596,23 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
468
596
  const answered = (layer.responses || []).filter(r => r.answer.trim());
469
597
  if (!answered.length) continue;
470
598
  lines.push(`\n### ${layer.label}`);
599
+ // A2 residual fix (2026-07-05): content-side softening of the
600
+ // Domain-and-expertise section. The persona-eval §7.4 residual
601
+ // showed the block front-loading a persona domain (e.g. immigration)
602
+ // on prompts that didn't name or invite it. A previous framing-
603
+ // directive iteration at the block-top level (A2, PR #115) net-
604
+ // regressed by making the model over-hedgy on unrelated prompts.
605
+ // This localized directive is scoped ONLY to the Domain-and-expertise
606
+ // section — the specific place where domain content actually gets
607
+ // read as topical steering. Concrete: for Randy this section says
608
+ // "Primary Expertise: Building operational frameworks... business
609
+ // immigration matters...", which the model was reading as "front-
610
+ // load immigration on any HR-adjacent prompt." The framing tells
611
+ // it to treat those answers as BACKGROUND about the subject rather
612
+ // than topics to steer generic prompts toward.
613
+ if (layerId === 'domain_and_expertise') {
614
+ lines.push('*Use as background context about who the subject is — do not steer general prompts toward these topics unless the prompt names them.*');
615
+ }
471
616
  for (const { label, question, answer } of answered)
472
617
  lines.push(`${label || question}: ${answer.trim()}`);
473
618
  const layerSigs = behavioralSignals[layerId];
@@ -494,8 +639,11 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
494
639
  const learnedLines = [];
495
640
  for (const [layerId, layer] of Object.entries(fingerprint.layers)) {
496
641
  if (layer && typeof layer.summary === 'string' && layer.summary.trim()) {
497
- learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`);
498
- learnedLines.push(layer.summary.trim());
642
+ const clean = sanitizeSummary(layer.summary); // P0 hygiene: drop episodic/opaque/dup lines
643
+ if (clean) {
644
+ learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`);
645
+ learnedLines.push(clean);
646
+ }
499
647
  }
500
648
  }
501
649
  if (learnedLines.length) {
@@ -569,6 +717,33 @@ function validateVaultData(vd) {
569
717
  return { ok: errors.length === 0, empty, errors };
570
718
  }
571
719
 
720
+ // Projection hygiene (P0, 2026-06-16): defense-in-depth on the injected summary.
721
+ // The producer's renderLayerSummary guards at generation, but a stale or
722
+ // third-party producer could store a violating summary; drop episodic + opaque
723
+ // value lines and dedupe repeated dimensions so the AI never sees them. Regexes
724
+ // MUST stay in sync with checkSummary() in spec/validate-fingerprint.mjs.
725
+ var SUMMARY_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;
726
+ var SUMMARY_DIM_RE = /^[-*\s]*([^:]{1,60}):\s*(.+)$/;
727
+ var SUMMARY_OPAQUE_RE = /^[a-z]\d(?:[ _][a-z0-9]+)*$/i;
728
+ function sanitizeSummary(text) {
729
+ const out = [];
730
+ const seen = new Set();
731
+ for (const raw of String(text).split('\n')) {
732
+ const t = raw.trim();
733
+ if (!t) { out.push(raw); continue; }
734
+ if (SUMMARY_EPISODIC_RE.test(t)) continue; // drop episodic (§4.6)
735
+ const m = t.match(SUMMARY_DIM_RE);
736
+ if (m) {
737
+ const dim = m[1].trim().toLowerCase();
738
+ if (seen.has(dim)) continue; // drop duplicate dimension
739
+ if (SUMMARY_OPAQUE_RE.test(m[2].trim())) continue; // drop opaque value
740
+ seen.add(dim);
741
+ }
742
+ out.push(raw);
743
+ }
744
+ return out.join('\n').trim();
745
+ }
746
+
572
747
  function renderContextBlock(vd, opts) {
573
748
  const v = validateVaultData(vd);
574
749
  if (!v.ok) {
@@ -126,5 +126,8 @@
126
126
  }
127
127
 
128
128
  globalThis.Phaedo = globalThis.Phaedo || {}
129
- globalThis.Phaedo.Envelope = { wrap, unwrap }
129
+ // canonicalStringify is exposed for the conformance test vectors (spec §6 /
130
+ // spec/test-vectors): it is the only canonicalization v0.1 actually performs
131
+ // (the metadata AAD binding). See its doc-comment for the RFC 8785 subset note.
132
+ globalThis.Phaedo.Envelope = { wrap, unwrap, canonicalStringify }
130
133
  })()
package/vendor/kdf.js CHANGED
@@ -44,6 +44,21 @@
44
44
  )
45
45
  }
46
46
 
47
+ /**
48
+ * Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of pair_key,
49
+ * so the store-and-forward fingerprint envelope is decryptable by either device
50
+ * with NO live session or biometric — and never reuses pair_key's HMAC role or
51
+ * seed_key's. Re-derivable from the stored pair_key, so neither side holds state.
52
+ */
53
+ async function deriveFpSyncKey(pairKey) {
54
+ return hkdfBytes(
55
+ pairKey,
56
+ new Uint8Array(0),
57
+ utf8(P.HKDF_INFO.FP_SYNC_KEY),
58
+ P.SIZES.AES_KEY,
59
+ )
60
+ }
61
+
47
62
  async function deriveSessionKey(ephemeralShared, sessionToken) {
48
63
  return hkdfBytes(
49
64
  ephemeralShared,
@@ -108,7 +123,7 @@
108
123
 
109
124
  globalThis.Phaedo = globalThis.Phaedo || {}
110
125
  globalThis.Phaedo.Kdf = {
111
- derivePairKey, deriveSeedKey, deriveSessionKey, deriveSAS, formatSAS,
126
+ derivePairKey, deriveSeedKey, deriveFpSyncKey, deriveSessionKey, deriveSAS, formatSAS,
112
127
  authEphemeral, verifyAuthEphemeral,
113
128
  }
114
129
  })()
@@ -29,6 +29,10 @@
29
29
  // Seed-transfer key for QR-free pairing (spec §8.4). A sub-key of
30
30
  // pair_key so the GCM seed envelope never reuses pair_key's HMAC role.
31
31
  SEED_KEY: 'phaedo-seed-v0.1',
32
+ // Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of
33
+ // pair_key so the store-and-forward fingerprint envelope is decryptable by
34
+ // either device with no live session — and never reuses any other role's key.
35
+ FP_SYNC_KEY: 'phaedo-fp-sync-v0.1',
32
36
  },
33
37
 
34
38
  /** Sizes in bytes — fixed by spec §7.2. */