phaedo-mcp 0.1.1 → 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.
- package/README.md +4 -1
- package/calibration.js +107 -0
- package/consult-core.js +363 -36
- package/consult-policy.js +61 -12
- package/consult.js +4 -2
- package/fingerprint-source.js +61 -3
- package/hooks/session-start.mjs +9 -2
- package/install.mjs +6 -0
- package/package.json +11 -4
- package/pairing.js +13 -1
- package/receipts.js +12 -1
- package/sample-fingerprint.json +3 -0
- package/server.js +366 -8
- package/server.json +3 -3
- package/vendor/context-block.js +179 -4
- package/vendor/envelope.js +4 -1
- package/vendor/kdf.js +16 -1
- package/vendor/protocol.js +4 -0
package/vendor/context-block.js
CHANGED
|
@@ -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
|
|
359
|
+
const raw = {};
|
|
328
360
|
for (const layer of Object.values(behavioralSignals))
|
|
329
|
-
for (const [k, v] of Object.entries(layer))
|
|
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
|
-
|
|
498
|
-
|
|
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) {
|
package/vendor/envelope.js
CHANGED
|
@@ -126,5 +126,8 @@
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
globalThis.Phaedo = globalThis.Phaedo || {}
|
|
129
|
-
|
|
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
|
})()
|
package/vendor/protocol.js
CHANGED
|
@@ -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. */
|