+107
| // Phaedo — calibration reliability curve (build-now tier, slice 2). | ||
| // | ||
| // agreement.js answers "how often was the oracle right, per domain." This answers a | ||
| // DIFFERENT and (per the design thread) more important question: is the oracle's | ||
| // CONFIDENCE honest? When it says 0.8, does that decision actually hold ~80% of the | ||
| // time? A well-calibrated 0.6 is worth more to an agent deciding act-vs-escalate than | ||
| // an overconfident 0.9. So we bucket decisive consult predictions by confidence band | ||
| // and compare the band's MEAN PREDICTED confidence to its REALIZED agreement rate. | ||
| // | ||
| // PURE: (receipts, opts) -> report. No I/O, no globals — same discipline as | ||
| // agreement.js / consult-core.js. Reuses agreement.js's scoring contract (DECISIVE | ||
| // signals + agrees()) so "right" means exactly what it means there. | ||
| // | ||
| // DELIBERATELY NOT WIRED to anything. This is a READOUT (`npm run calibration`). | ||
| // Recalibrating confidence or auto-adjusting deference from this curve is a product | ||
| // decision for a later session (closing the loop), not an engineering default. | ||
| // | ||
| // Metric: for each scored receipt (decisive signal + known user_action, in window), | ||
| // bucket by response.confidence into equal-width bins. Per bin: | ||
| // predicted_mean = mean(confidence), realized_rate = agreements / scored, | ||
| // gap = predicted_mean - realized_rate (positive = OVERconfident). | ||
| // Expected Calibration Error (ECE) = sum over bins of (bin_scored/total_scored)·|gap|. | ||
| // A low ECE means the numbers can be trusted as probabilities. | ||
| import { DECISIVE, DEFAULT_WINDOW_DAYS } from './agreement.js'; | ||
| function agrees(signal, userAction) { | ||
| if (userAction === 'approved') return signal === 'proceed' || signal === 'proceed_with_note'; | ||
| if (userAction === 'rejected') return signal === 'decline'; | ||
| return false; // 'modified' — not accepted as-judged | ||
| } | ||
| const clamp01 = (x) => Math.max(0, Math.min(1, x)); | ||
| // (receipts, { windowDays, bins, now }) -> { | ||
| // window: { days, since, now, total_receipts, in_window }, | ||
| // scored, ece, | ||
| // bins: [ { lo, hi, scored, agreements, predicted_mean, realized_rate, gap } ], | ||
| // } | ||
| // ece / a bin's realized_rate are null when nothing was scored (no data ≠ 0%). | ||
| export function computeCalibration(receipts, { windowDays = DEFAULT_WINDOW_DAYS, bins = 5, now = Date.now() } = {}) { | ||
| const nBins = Math.max(1, Math.floor(bins)); | ||
| const since = now - windowDays * 24 * 60 * 60 * 1000; | ||
| const slot = (conf) => Math.min(nBins - 1, Math.floor(clamp01(conf) * nBins)); // 1.0 → last bin | ||
| const table = Array.from({ length: nBins }, (_, i) => ({ | ||
| lo: Math.round((i / nBins) * 100) / 100, | ||
| hi: Math.round(((i + 1) / nBins) * 100) / 100, | ||
| scored: 0, agreements: 0, _confSum: 0, predicted_mean: null, realized_rate: null, gap: null, | ||
| })); | ||
| let inWindow = 0, totalScored = 0; | ||
| for (const r of Array.isArray(receipts) ? receipts : []) { | ||
| const t = Date.parse(r?.created_at); | ||
| if (!Number.isFinite(t) || t < since || t > now) continue; | ||
| inWindow++; | ||
| const signal = r.response?.signal; | ||
| const action = r.user_action; | ||
| const conf = r.response?.confidence; | ||
| if (!DECISIVE.includes(signal)) continue; // non-directional: no prediction to calibrate | ||
| if (action == null || action === 'unknown') continue; // not yet scorable | ||
| if (typeof conf !== 'number' || !Number.isFinite(conf)) continue; | ||
| const b = table[slot(conf)]; | ||
| b.scored++; b._confSum += clamp01(conf); | ||
| if (agrees(signal, action)) b.agreements++; | ||
| totalScored++; | ||
| } | ||
| let eceSum = 0; | ||
| for (const b of table) { | ||
| if (b.scored > 0) { | ||
| b.predicted_mean = Math.round((b._confSum / b.scored) * 1000) / 1000; | ||
| b.realized_rate = Math.round((b.agreements / b.scored) * 1000) / 1000; | ||
| b.gap = Math.round((b.predicted_mean - b.realized_rate) * 1000) / 1000; | ||
| eceSum += (b.scored / totalScored) * Math.abs(b.predicted_mean - b.realized_rate); | ||
| } | ||
| delete b._confSum; | ||
| } | ||
| return { | ||
| window: { days: windowDays, since: new Date(since).toISOString(), now: new Date(now).toISOString(), total_receipts: Array.isArray(receipts) ? receipts.length : 0, in_window: inWindow }, | ||
| scored: totalScored, | ||
| ece: totalScored > 0 ? Math.round(eceSum * 1000) / 1000 : null, | ||
| bins: table, | ||
| }; | ||
| } | ||
| // ── Dev CLI: `node calibration.js [windowDays] [bins]` / `npm run calibration` ── | ||
| // Local readout only — decrypts the local receipt store with the local key; nothing | ||
| // leaves the device. NOT product UX, NOT fed back into confidence/deference. | ||
| if (process.argv[1] && process.argv[1].endsWith('calibration.js')) { | ||
| const days = Number(process.argv[2]) || DEFAULT_WINDOW_DAYS; | ||
| const nBins = Number(process.argv[3]) || 5; | ||
| Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => { | ||
| const report = computeCalibration(await readReceipts(defaultStateDir()), { windowDays: days, bins: nBins }); | ||
| const { window: w } = report; | ||
| console.log(`Calibration over the last ${w.days}d — ${report.scored} scored of ${w.in_window}/${w.total_receipts} receipts in window`); | ||
| console.log(`ECE (expected calibration error): ${report.ece === null ? ' — ' : report.ece.toFixed(3)} (lower = confidence is honest)\n`); | ||
| console.log('band scored predicted realized gap'); | ||
| for (const b of report.bins) { | ||
| const f = (x) => x === null ? ' — ' : (x * 100).toFixed(0).padStart(4) + '%'; | ||
| const gap = b.gap === null ? ' — ' : (b.gap > 0 ? '+' : '') + (b.gap * 100).toFixed(0) + '%'; | ||
| console.log(`${(b.lo.toFixed(1) + '–' + b.hi.toFixed(1)).padEnd(12)} ${String(b.scored).padStart(5)} ${f(b.predicted_mean)} ${f(b.realized_rate)} ${gap.padStart(6)}`); | ||
| } | ||
| if (report.scored === 0) console.log('\nNo scored decisive receipts yet — set user_action as you accept/reject agent actions.'); | ||
| else console.log('\npositive gap = overconfident (predicted > realized); negative = underconfident.'); | ||
| }).catch((e) => { console.error(`calibration: ${e.message}`); process.exit(1); }); | ||
| } |
+363
-36
@@ -42,2 +42,10 @@ // Phaedo §10 Agent Consultation — the PURE resolver core. | ||
| // §4.7: distinct from "no coverage" — coverage EXISTS but the dimension(s) this action | ||
| // turns on are under an open conflict the subject hasn't settled, so the oracle withholds | ||
| // a signal (the consult-side mirror of injection suppression). Escalate, don't assume. | ||
| const CONTESTED_HINT = | ||
| 'The subject is actively reconciling a contradiction on the decision dimension this ' + | ||
| 'action turns on, so the oracle withholds a signal until they settle it — escalate to ' + | ||
| 'the subject rather than assume a default.'; | ||
| // ── vd contract guard (brief M0.4) ──────────────────────────────────────────── | ||
@@ -98,2 +106,7 @@ // The consultation boundary's mirror of context-block.js validateVaultData (which | ||
| const BASIS_WEIGHT = { observed: 1.0, corroborated: 1.0, self_reported: 0.9, text_scan: 0.75 }; | ||
| // The act-as-me channel: a `delegation`-origin cue (how the subject wants work done FOR | ||
| // them) reads as MORE authoritative for a consult — an agent is about to act on their | ||
| // behalf — so it is boosted above a `self`-origin one. `self` is the neutral 1.0, so | ||
| // today's all-self fingerprints are unchanged; the per-cue product is clamped to 1.0. | ||
| const ORIGIN_WEIGHT = { delegation: 1.15, self: 1.0 }; | ||
@@ -108,3 +121,35 @@ // Does a signal's §4.2.7 domain tag pertain to the action's domain? Same | ||
| // Resolve a relevant cue to { text, confidence, basis }, preferring a structured | ||
| // ── Open-conflict suppression (§4.7, the consult-side mirror of injection) ────── | ||
| // When a dimension has an OPEN conflict_record (the subject's self-report and a fresh | ||
| // observation disagree, not yet settled), the injection projection withholds it from | ||
| // the layer summary (extraction/reconcile.js openConflictFieldKeys → renderLayerSummary | ||
| // suppress). Consult reads the raw signals, not the summary, so it must withhold the | ||
| // SAME contested dimensions itself — otherwise an agent gets a confident answer on a | ||
| // dimension the subject is still deciding. A withheld cue counts as "not found", so if | ||
| // every relevant cue is contested, coverage→0 and the resolver abstains (below). Field | ||
| // keys mirror reconcile.js: `field` (untagged → fingerprint-wide) or `field::domain`. | ||
| function openConflictKeys(vd) { | ||
| const recs = vd?.phaedo_fingerprint?.conflict_records; | ||
| const set = new Set(); | ||
| for (const r of (Array.isArray(recs) ? recs : [])) { | ||
| if (r && r.status === 'open' && r.field) set.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : '')); | ||
| } | ||
| return set; | ||
| } | ||
| // Is `field` contested for this action? An UNTAGGED open conflict suppresses it | ||
| // fingerprint-wide (matching untagged signals' fingerprint-wide reach); a domain-TAGGED | ||
| // conflict suppresses only when the action's domain matches. | ||
| function isFieldContested(suppressed, field, actionDomain) { | ||
| if (!suppressed || !suppressed.size) return false; | ||
| if (suppressed.has(field)) return true; | ||
| if (actionDomain) { | ||
| for (const key of suppressed) { | ||
| const i = key.indexOf('::'); | ||
| if (i > 0 && key.slice(0, i) === field && domainMatches(key.slice(i + 2), actionDomain)) return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| // Resolve a relevant cue to { text, confidence, basis, origin }, preferring a structured | ||
| // §4.2 signal (which carries a real per-signal confidence + provenance) over a | ||
@@ -119,2 +164,9 @@ // dimensioned questionnaire response. Returns undefined when neither is present | ||
| // the action has no domain, tags are ignored (any field-matching signal serves). | ||
| // | ||
| // Origin preference (the act-as-me channel, §4.2 `origin`): a `delegation`-origin | ||
| // signal — learned from the subject CORRECTING an agent acting on their behalf — | ||
| // OUTRANKS a `self`-origin one for the same dimension. A consultation IS an agent | ||
| // about to act on the subject's behalf (§10), so how they want work done FOR them | ||
| // dominates how they do it themselves. Absent `origin` ⇒ `self`, so today's | ||
| // (all-self) fingerprints are unaffected — this only bites once delegation signals exist. | ||
| function signalCue(vd, layerId, fields, actionDomain) { | ||
@@ -128,19 +180,29 @@ const L = layer(vd, layerId); | ||
| const basis = typeof s.evidence_basis === 'string' ? s.evidence_basis : evidenceBasis(s.source); | ||
| return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis }; | ||
| const origin = s.origin === 'delegation' ? 'delegation' : 'self'; | ||
| return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis, origin }; | ||
| }; | ||
| let untagged; | ||
| // Score each matching signal and keep the best. tier = domain relevance (a domain | ||
| // match outranks an untagged fallback, as before); within a tier, `delegation` | ||
| // outranks `self`. Ties keep document order (first wins) — so for today's all-self, | ||
| // mostly-untagged fingerprints the selection is identical to the prior first-match. | ||
| let best; | ||
| for (const s of (L.signals || [])) { | ||
| if (!(s && want.has(s.field) && (s.polarity ?? 'positive') !== 'negative' && s.value != null)) continue; | ||
| let tier; | ||
| if (typeof s.domain === 'string' && actionDomain) { | ||
| if (domainMatches(s.domain, actionDomain)) return asCue(s); // domain match wins outright | ||
| continue; // wrong domain — skip | ||
| if (!domainMatches(s.domain, actionDomain)) continue; // wrong domain — skip | ||
| tier = 2; // domain match | ||
| } else if (typeof s.domain === 'string') { | ||
| tier = 1; // tagged, action domain-free | ||
| } else { | ||
| tier = 0; // untagged fallback (today's norm) | ||
| } | ||
| if (typeof s.domain === 'string' && !actionDomain) return asCue(s); // no action domain → tags ignored | ||
| if (!untagged) untagged = asCue(s); // untagged fallback | ||
| const score = tier * 2 + (s.origin === 'delegation' ? 1 : 0); | ||
| if (!best || score > best.score) best = { score, cue: asCue(s) }; | ||
| } | ||
| if (untagged) return untagged; | ||
| if (best) return best.cue; | ||
| for (const r of (L.responses || L.answers || [])) { | ||
| const dim = r?.dimension || r?.field; | ||
| const v = r?.value ?? r?.answer; | ||
| if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported' }; | ||
| if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported', origin: 'self' }; | ||
| } | ||
@@ -204,3 +266,9 @@ return undefined; | ||
| function readCue(vd, spec, actionDomain) { | ||
| function readCue(vd, spec, actionDomain, suppressed) { | ||
| // §4.7: if any of this cue's fields is under an open conflict, withhold the whole cue | ||
| // (its field-aliases name the same contested dimension). `withheld` lets the resolver | ||
| // tell "abstaining because contested" apart from "abstaining for no coverage". | ||
| if (suppressed && spec.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) { | ||
| return { found: false, lean: 0, confidence: 0, basis: null, withheld: true }; | ||
| } | ||
| let cue; | ||
@@ -213,8 +281,16 @@ for (const lid of spec.layers) { cue = signalCue(vd, lid, spec.fields, actionDomain); if (cue) break; } // a structured §4.2 signal wins | ||
| const lean = (c && !b) ? 1 : (b && !c) ? -1 : 0; // both/neither → present but neutral | ||
| // per-cue confidence + basis: a structured signal carries its own; a keyword hit | ||
| // in free text is the weakest evidence (text_scan). | ||
| return { found, lean, confidence: found ? (cue ? cue.confidence : 0.6) : 0, basis: found ? (cue ? cue.basis : 'text_scan') : null }; | ||
| // per-cue confidence + basis + origin: a structured signal carries its own; a keyword | ||
| // hit in free text is the weakest evidence (text_scan), always self-origin. | ||
| return { | ||
| found, lean, | ||
| confidence: found ? (cue ? cue.confidence : 0.6) : 0, | ||
| basis: found ? (cue ? cue.basis : 'text_scan') : null, | ||
| origin: found ? (cue ? cue.origin : 'self') : null, | ||
| }; | ||
| } | ||
| function readAmbiguity(vd, actionDomain) { | ||
| function readAmbiguity(vd, actionDomain, suppressed) { | ||
| if (suppressed && AMBIGUITY.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) { | ||
| return { found: false, value: null, confidence: 0, basis: null, withheld: true }; | ||
| } | ||
| let cue; | ||
@@ -225,4 +301,5 @@ for (const lid of AMBIGUITY.layers) { cue = signalCue(vd, lid, AMBIGUITY.fields, actionDomain); if (cue) break; } | ||
| const basis = cue ? cue.basis : 'text_scan'; | ||
| if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis }; | ||
| if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis }; | ||
| const origin = cue ? cue.origin : 'self'; | ||
| if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis, origin }; | ||
| if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis, origin }; | ||
| return { found: false, value: null, confidence: 0, basis: null }; | ||
@@ -240,13 +317,16 @@ } | ||
| // the ambiguity verdict. | ||
| function profile(vd, relevantCues, includeAmbiguity, actionDomain) { | ||
| let score = 0.5, found = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0); | ||
| const effConfs = []; // per-cue confidence × evidence-basis weight, for the cues actually used | ||
| function profile(vd, relevantCues, includeAmbiguity, actionDomain, suppressed) { | ||
| let score = 0.5, found = 0, withheld = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0); | ||
| const effConfs = []; // per-cue confidence × evidence-basis × origin weight, for the cues used | ||
| const eff = (r) => Math.min(1, r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75) * (ORIGIN_WEIGHT[r.origin] ?? 1.0)); | ||
| for (const key of relevantCues) { | ||
| const r = readCue(vd, CUES[key], actionDomain); | ||
| if (r.found) { found++; score += r.lean * 0.13; effConfs.push(r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75)); } | ||
| const r = readCue(vd, CUES[key], actionDomain, suppressed); | ||
| if (r.found) { found++; score += r.lean * 0.13; effConfs.push(eff(r)); } | ||
| else if (r.withheld) withheld++; | ||
| } | ||
| let ambiguity = { found: false, value: null }; | ||
| if (includeAmbiguity) { | ||
| ambiguity = readAmbiguity(vd, actionDomain); | ||
| if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(ambiguity.confidence * (BASIS_WEIGHT[ambiguity.basis] ?? 0.75)); } | ||
| ambiguity = readAmbiguity(vd, actionDomain, suppressed); | ||
| if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(eff(ambiguity)); } | ||
| else if (ambiguity.withheld) withheld++; | ||
| } | ||
@@ -257,2 +337,3 @@ return { | ||
| found, | ||
| withheld, | ||
| signalConfidence: effConfs.length ? effConfs.reduce((a, b) => a + b, 0) / effConfs.length : 0, | ||
@@ -310,5 +391,15 @@ ambiguity, | ||
| // The Decision&Risk cues each consultation type weighs (single source of truth — | ||
| // the resolvers AND the provenance reader consultDrivers() read from here, so the | ||
| // recorded "what drove this" can never drift from what actually drove it). | ||
| const RELEVANT_CUES = { | ||
| action_approval: ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], | ||
| domain_risk_check: ['evidenceBar', 'speedQuality', 'decisionThreshold'], | ||
| escalation_default: ['decisionThreshold', 'irreversibleCaution'], | ||
| }; | ||
| const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']); | ||
| // ── Per-type resolvers → { signal, hint, coverage, found } ──────────────────── | ||
| function resolveActionApproval(vd, n) { | ||
| const p = profile(vd, ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], true, n.domain); | ||
| function resolveActionApproval(vd, n, suppressed) { | ||
| const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed); | ||
| const irreversible = n.reversible === false; | ||
@@ -337,7 +428,7 @@ const mag = magnitudeRank(n.magnitude); | ||
| : `On reversible actions${domain}, the subject ${posture}.`; | ||
| return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence }; | ||
| return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence }; | ||
| } | ||
| function resolveDomainRiskCheck(vd, n) { | ||
| const p = profile(vd, ['evidenceBar', 'speedQuality', 'decisionThreshold'], false, n.domain); | ||
| function resolveDomainRiskCheck(vd, n, suppressed) { | ||
| const p = profile(vd, RELEVANT_CUES.domain_risk_check, false, n.domain, suppressed); | ||
| const high = p.score >= 0.55; | ||
@@ -349,7 +440,7 @@ const signal = high ? 'clarify' : p.score <= 0.4 ? 'proceed' : 'proceed_with_note'; | ||
| : 'balances evidence against speed'; | ||
| return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence }; | ||
| return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence }; | ||
| } | ||
| function resolveEscalationDefault(vd, n) { | ||
| const p = profile(vd, ['decisionThreshold', 'irreversibleCaution'], true, n.domain); | ||
| function resolveEscalationDefault(vd, n, suppressed) { | ||
| const p = profile(vd, RELEVANT_CUES.escalation_default, true, n.domain, suppressed); | ||
| let signal; | ||
@@ -364,3 +455,3 @@ if (p.ambiguity.value === 'ask_first') signal = 'clarify'; | ||
| : 'When uncertain on low-stakes calls, the subject defaults to a reasonable assumption, stated openly.'; | ||
| return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence }; | ||
| return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence }; | ||
| } | ||
@@ -379,2 +470,230 @@ | ||
| // ── Provenance (local audit only — NOT the §10.3 response) ──────────────────── | ||
| // The Decision&Risk cues that actually drove a consultation, for the §10.6 receipt: | ||
| // "I returned this because cues A,B,C fired at these confidences/bases." Reuses the | ||
| // resolver's exact relevance lists + readCue, so it can't drift from what drove the | ||
| // answer. Carries lean/value + confidence + evidence basis — NOT the raw layer text, | ||
| // and it lives ONLY in the local encrypted receipt, never in the response the agent | ||
| // sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent). | ||
| // Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes | ||
| // internally so callers pass exactly what they passed to the consult. | ||
| export function consultDrivers(vd, request) { | ||
| let n; | ||
| try { n = normalizeRequest(request || {}); } catch { return []; } | ||
| const keys = RELEVANT_CUES[n.type]; | ||
| if (!keys) return []; // voice_draft / unknown → no inferred basis | ||
| const suppressed = openConflictKeys(vd); // §4.7: withhold contested cues here too, | ||
| const drivers = []; // so the receipt matches what actually drove it | ||
| // A driver records `origin` only when it's `delegation` — the act-as-me case worth | ||
| // flagging in the audit; omitting it for `self` keeps today's receipts byte-identical. | ||
| const withOrigin = (d, origin) => (origin === 'delegation' ? { ...d, origin } : d); | ||
| for (const key of keys) { | ||
| const r = readCue(vd, CUES[key], n.domain, suppressed); | ||
| if (r.found) drivers.push(withOrigin({ cue: key, lean: r.lean, confidence: r.confidence, basis: r.basis }, r.origin)); | ||
| } | ||
| if (USES_AMBIGUITY.has(n.type)) { | ||
| const a = readAmbiguity(vd, n.domain, suppressed); | ||
| if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin)); | ||
| } | ||
| return drivers; | ||
| } | ||
| // ── Delegation producer (the act-as-me SOURCE) ──────────────────────────────── | ||
| // Derive `delegation`-origin signals from the consult OVERRIDE history (§10.6 receipts) | ||
| // — the act-as-me learning half that pairs with origin-aware resolution above. | ||
| // | ||
| // Honesty rail (docs/roadmap/in-claude-outcome-marking.md): ONLY an override teaches — | ||
| // `user_action` `rejected` or `modified`, the subject correcting the agent. `approved` | ||
| // (exactly what a rubber-stamping autonomous agent would self-report) teaches NOTHING, | ||
| // so the channel can't silently collapse into "agent obedience". The corrected DIRECTION | ||
| // is read from the oracle's own signal: overriding a PERMISSIVE signal (proceed*) means | ||
| // the subject wanted MORE caution on delegated work; overriding a CAUTIOUS one | ||
| // (escalate/decline/clarify) means LESS. Each driving cue of the overridden consult is | ||
| // nudged in that direction, aggregated across receipts (net direction wins; a tie is | ||
| // genuinely ambiguous → skipped). Pure; reuses the CUES catalog so the synthesized value | ||
| // reads back through the same keyword path. A delegated correction is scoped to the | ||
| // action's domain when it had one, so a financial override never reshapes writing. | ||
| const PERMISSIVE_SIGNAL = new Set(['proceed', 'proceed_with_note']); | ||
| const CAUTIOUS_SIGNAL = new Set(['escalate', 'decline', 'clarify']); | ||
| const OVERRIDE_WEIGHT = { rejected: 1.0, modified: 0.5 }; | ||
| export function deriveDelegationSignals(receipts, { now = new Date().toISOString(), floor = 1 } = {}) { | ||
| const tally = new Map(); // `${cue}|${domain}` → { cue, domain, sum, n } | ||
| for (const r of (Array.isArray(receipts) ? receipts : [])) { | ||
| const w = OVERRIDE_WEIGHT[r && r.user_action]; | ||
| if (!w) continue; // only rejected/modified teach | ||
| const sig = r?.response?.signal; | ||
| const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0; // +1 cautious / -1 bold | ||
| if (!correct) continue; // abstain/unknown → no direction | ||
| const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : ''; | ||
| for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) { | ||
| if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later) | ||
| const k = `${d.cue}|${domain}`; | ||
| const t = tally.get(k) || { cue: d.cue, domain, sum: 0, n: 0 }; | ||
| t.sum += correct * w; t.n += 1; | ||
| tally.set(k, t); | ||
| } | ||
| } | ||
| const out = []; | ||
| for (const { cue, domain, sum, n } of tally.values()) { | ||
| if (n < 1 || Math.abs(sum) < floor) continue; // need a clear, sufficient net correction | ||
| const dir = sum > 0 ? 'cautious' : 'bold'; | ||
| out.push({ | ||
| layer: CUES[cue].layers[0], | ||
| field: CUES[cue].fields[0], | ||
| value: CUES[cue][dir][0], // a keyword the resolver reads as this direction | ||
| polarity: 'positive', | ||
| origin: 'delegation', | ||
| evidence_basis: 'observed', | ||
| source: 'delegation_override', | ||
| confidence: Math.min(0.9, 0.55 + 0.1 * n), | ||
| observation_count: n, | ||
| last_observed: now, | ||
| ...(domain ? { domain } : {}), | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| // Merge derived delegation signals into a COPY of vd's layers so the origin-aware | ||
| // resolver sees them (delegation outranks self per dimension). Pure; clones only the | ||
| // layers it touches; never mutates the input vd or its arrays. The derived signals live | ||
| // only in this per-consult in-memory vd — never persisted to the fingerprint, never | ||
| // across §10.4 (they shape the SIGNAL, they don't cross as content). | ||
| export function withDelegationSignals(vd, signals) { | ||
| if (!Array.isArray(signals) || !signals.length || !vd || !vd.phaedo_fingerprint) return vd; | ||
| const fp = vd.phaedo_fingerprint; | ||
| const layers = { ...(fp.layers || {}) }; | ||
| for (const s of signals) { | ||
| const lid = s.layer || 'decision_and_risk'; | ||
| const L = layers[lid] ? { ...layers[lid] } : {}; | ||
| const { layer, ...sig } = s; // drop the routing key from the stored signal | ||
| L.signals = [...(Array.isArray(L.signals) ? L.signals : []), sig]; | ||
| layers[lid] = L; | ||
| } | ||
| return { ...vd, phaedo_fingerprint: { ...fp, layers } }; | ||
| } | ||
| // ── Delegation PROMOTION (the reviewable, portable act-as-me step) ──────────── | ||
| // `deriveDelegationSignals` above feeds the live resolver an in-memory overlay from | ||
| // EVERY override. Promotion is the durable, reviewable step: when an override pattern is | ||
| // ESTABLISHED (|net corrections| ≥ floor on a dimension) and not already authored, stage | ||
| // a `suggested_rules` candidate — the SAME shape the §E delta-promotion review uses | ||
| // (Proposal 0007), so it rides the existing popup card + endorse path. Endorsing it | ||
| // authors a portable, delegation-origin `standing_rules` entry (syncs, shapes injection | ||
| // + consult). Pure; honesty rail inherited (only overrides count). No-nag: skip a | ||
| // dimension already authored as a delegation rule, or already staged/dismissed. | ||
| export const DELEGATION_PROMOTION_FLOOR = 3; // matches §4.7 RESOLUTION_FLOOR / §E PROMOTION_FLOOR | ||
| // Each scalar caution cue → its canonical §4.2 Decision&Risk dimension + the catalog | ||
| // value token for each corrective direction, and a first-person *delegation* sentence | ||
| // (act-as-me framing — "when you act on my behalf"). Ambiguity is excluded (its driver | ||
| // carries a value, not a lean — handled when promotion grows past the scalar cues). | ||
| const CUE_DIMENSION = { | ||
| irreversibleCaution: 'reversible_vs_irreversible', | ||
| decisionThreshold: 'evidence_threshold', | ||
| evidenceBar: 'evidence_threshold', | ||
| speedQuality: 'speed_vs_quality', | ||
| }; | ||
| const DIRECTION_VALUE = { | ||
| reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' }, | ||
| evidence_threshold: { cautious: 'high', bold: 'low' }, | ||
| speed_vs_quality: { cautious: 'quality', bold: 'speed' }, | ||
| }; | ||
| const DELEGATION_TEXT = { | ||
| reversible_vs_irreversible: { cautious: 'When you act on my behalf, slow down and check with me on decisions that are hard to undo.', | ||
| bold: "When acting on my behalf, treat hard-to-undo decisions like any other — don't over-escalate them." }, | ||
| evidence_threshold: { cautious: 'When acting on my behalf, make sure the main risks are covered before you commit.', | ||
| bold: "When acting on my behalf, act on the best available signal — don't over-gather evidence." }, | ||
| speed_vs_quality: { cautious: 'When acting on my behalf, favor getting it right over getting it fast.', | ||
| bold: 'When acting on my behalf, favor moving fast over polishing.' }, | ||
| }; | ||
| function delegationText(dimension, dir, domain) { | ||
| const base = (DELEGATION_TEXT[dimension] && DELEGATION_TEXT[dimension][dir]) || `When acting on my behalf, handle ${dimension} as I've corrected you to.`; | ||
| return domain ? base.replace(/\.$/, ` — especially on ${domain} decisions.`) : base; | ||
| } | ||
| export function buildDelegationPromotions(receipts, fp, { now = new Date().toISOString(), floor = DELEGATION_PROMOTION_FLOOR } = {}) { | ||
| // Aggregate overrides per CANONICAL (dimension, domain): signed weight + count. Same | ||
| // honesty rail + direction rule as deriveDelegationSignals (only rejected/modified; | ||
| // direction read from the oracle's own signal). | ||
| const tally = new Map(); // `${dimension}|${domain}` → { dimension, domain, sum, n } | ||
| for (const r of (Array.isArray(receipts) ? receipts : [])) { | ||
| const w = OVERRIDE_WEIGHT[r && r.user_action]; | ||
| if (!w) continue; | ||
| const sig = r?.response?.signal; | ||
| const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0; | ||
| if (!correct) continue; | ||
| const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : ''; | ||
| for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) { | ||
| const dim = CUE_DIMENSION[d && d.cue]; | ||
| if (!dim) continue; | ||
| const k = `${dim}|${domain}`; | ||
| const t = tally.get(k) || { dimension: dim, domain, sum: 0, n: 0 }; | ||
| t.sum += correct * w; t.n += 1; tally.set(k, t); | ||
| } | ||
| } | ||
| const keyOf = (dim, domain) => `${dim}|${domain || ''}`; | ||
| const authored = new Set((Array.isArray(fp?.standing_rules) ? fp.standing_rules : []) | ||
| .filter((r) => r?.decision?.origin === 'delegation' && r?.decision?.dimension) | ||
| .map((r) => keyOf(r.decision.dimension, r.decision.domain))); | ||
| const staged = new Set((Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : []) | ||
| .filter((s) => s?.source === 'delegation_promotion' && s?.decision?.dimension) | ||
| .map((s) => keyOf(s.decision.dimension, s.decision.domain))); | ||
| const out = []; | ||
| for (const { dimension, domain, sum, n } of tally.values()) { | ||
| if (Math.abs(sum) < floor) continue; // not yet established | ||
| if (authored.has(keyOf(dimension, domain)) || staged.has(keyOf(dimension, domain))) continue; // no-nag | ||
| const dir = sum > 0 ? 'cautious' : 'bold'; | ||
| out.push({ | ||
| suggestion_id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `dlg-${Math.random().toString(36).slice(2, 10)}`, | ||
| status: 'suggested', | ||
| source: 'delegation_promotion', | ||
| proposed_text: delegationText(dimension, dir, domain), | ||
| decision: { dimension, polarity: 'positive', origin: 'delegation', value: DIRECTION_VALUE[dimension]?.[dir], ...(domain ? { domain } : {}) }, | ||
| evidence: { observation_count: n, evidence_basis: 'observed' }, | ||
| created: now, | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| // ── Honoring an AUTHORED delegation rule (the portable act-as-me preference) ── | ||
| // Once the subject ENDORSES a delegation suggestion (rule-review.js), it becomes a | ||
| // `standing_rules` instruction with `decision.origin:"delegation"` — portable: it syncs | ||
| // across devices and renders in the §9 injection "Operating rules" block verbatim. To | ||
| // also shape CONSULT, convert each such rule into a `delegation`-origin signal the | ||
| // origin-aware resolver already honors (it then OUTRANKS the subject's self pattern, and | ||
| // — authored — reads at full strength). The catalog `decision.value` token fixes the | ||
| // direction; mapped to the keyword the cue readers recognize. Pure. | ||
| const VALUE_DIRECTION = { | ||
| more_cautious: 'cautious', consistent_style: 'bold', | ||
| high: 'cautious', low: 'bold', | ||
| quality: 'cautious', accuracy: 'cautious', speed: 'bold', | ||
| }; | ||
| const DIMENSION_KEYWORD = { | ||
| reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' }, | ||
| evidence_threshold: { cautious: 'high evidence', bold: 'good enough' }, | ||
| speed_vs_quality: { cautious: 'quality over', bold: 'speed over' }, | ||
| }; | ||
| export function authoredDelegationSignals(fp) { | ||
| const rules = Array.isArray(fp && fp.standing_rules) ? fp.standing_rules : []; | ||
| const out = []; | ||
| for (const r of rules) { | ||
| const d = r && r.decision; | ||
| if (!d || d.origin !== 'delegation' || !d.dimension) continue; | ||
| const dir = VALUE_DIRECTION[d.value]; | ||
| const kw = dir && DIMENSION_KEYWORD[d.dimension] && DIMENSION_KEYWORD[d.dimension][dir]; | ||
| if (!kw) continue; // unmapped value → can't honor in-consult (still injects verbatim) | ||
| out.push({ | ||
| layer: 'decision_and_risk', | ||
| field: d.dimension, value: kw, polarity: 'positive', | ||
| origin: 'delegation', evidence_basis: 'corroborated', source: 'authored_delegation', | ||
| confidence: 0.9, observation_count: 3, | ||
| ...(typeof d.domain === 'string' ? { domain: d.domain } : {}), | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| // §10.3 response shape — the ONLY fields that cross the boundary (§10.4). | ||
@@ -395,7 +714,10 @@ export function shape(type, signal, confidence, rationale_hint, deference_level) { | ||
| export function ruleResponse(vd, n) { | ||
| // §4.7 contested dimensions are withheld from the cue readers (the consult-side mirror | ||
| // of injection suppression). Built once per consult and threaded into every resolver. | ||
| const suppressed = openConflictKeys(vd); | ||
| let resolved; | ||
| switch (n.type) { | ||
| case 'action_approval': resolved = resolveActionApproval(vd, n); break; | ||
| case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n); break; | ||
| case 'escalation_default': resolved = resolveEscalationDefault(vd, n); break; | ||
| case 'action_approval': resolved = resolveActionApproval(vd, n, suppressed); break; | ||
| case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n, suppressed); break; | ||
| case 'escalation_default': resolved = resolveEscalationDefault(vd, n, suppressed); break; | ||
| case 'voice_draft': resolved = resolveVoiceDraft(); break; | ||
@@ -410,3 +732,8 @@ } | ||
| if (n.type !== 'voice_draft' && resolved.found === 0) { | ||
| return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, ABSTAIN_HINT, 'low'); | ||
| // Distinguish "no coverage" from "coverage exists but is contested": if every | ||
| // relevant cue was WITHHELD by an open conflict, tell the agent it is under review | ||
| // (so it escalates to the subject rather than treating the subject as a blank slate). | ||
| const contested = resolved.withheld > 0; | ||
| return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, | ||
| contested ? CONTESTED_HINT : ABSTAIN_HINT, 'low'); | ||
| } | ||
@@ -413,0 +740,0 @@ const cal = n.type === 'voice_draft' |
+61
-12
@@ -52,2 +52,10 @@ // Phaedo §10.5 consultation — standing authorizations / deference policies | ||
| amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null, | ||
| // Conditional + revocable authority (§10.5). `expiresAt` is ENFORCED: a lapsed | ||
| // authorization stops firing (time-bound = revocable, no identity needed; the | ||
| // direction is always authority-NARROWING, so it's fail-safe). `agents` is | ||
| // RESERVED (parsed, not yet enforced): scoping authority to specific agents needs | ||
| // an AUTHENTICATED agent identity — a self-asserted agent_id would be false | ||
| // security — so enforcement waits for that (build-later). | ||
| expiresAt: typeof r.expires_at === 'string' && Number.isFinite(Date.parse(r.expires_at)) ? Date.parse(r.expires_at) : null, | ||
| agents: Array.isArray(r.agents) ? r.agents.map((a) => String(a).toLowerCase()).filter(Boolean) : [], | ||
| effect, | ||
@@ -60,12 +68,46 @@ note: typeof r.note === 'string' ? r.note : null, | ||
| // Merge policy sources, most-portable first: | ||
| // 1. inside the fingerprint (`phaedo_fingerprint.consult_policies`) — rides | ||
| // `inner.data` on the phone pull, so it travels everywhere the fingerprint | ||
| // does (extension ↔ phone ↔ MCP) with NO profile-schema change. Preferred | ||
| // home for authored, synced policies. | ||
| // 2. a top-level vault sibling (`phaedo_consult_policies`) — local/extension use. | ||
| // 3. opts.policies — a loaded local file (server: mcp/consult-policies.json). | ||
| // Project the AUTHORIZATION-kind entries of `standing_rules` (Proposal 0007 — the | ||
| // single authored home) into the raw consult_policies rule shape normalizePolicies | ||
| // consumes. `kind:"instruction"` entries are verbatim injection rules, not gates — | ||
| // the resolver ignores them. `note` falls back to the injectable `text` so the | ||
| // rationale_hint still names the rule. | ||
| function authorizationsFromStandingRules(fp) { | ||
| const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : []; | ||
| return rules | ||
| .filter((r) => r && r.kind === 'authorization' && r.effect) | ||
| .map((r) => ({ id: r.instruction_id || null, match: r.match || {}, effect: r.effect, note: r.note || r.text || null, expires_at: r.expires_at, agents: r.agents })); | ||
| } | ||
| // Project a bound `kind:"instruction"` (Proposal 0007 §D) into a consultation BIAS. | ||
| // An instruction carries prose + a `decision` dimension + polarity, but not a | ||
| // structured enum value, so it cannot resolve a dimension to a value the way an | ||
| // inferred signal does. v0.1 semantics, deliberately conservative: a NEGATIVE | ||
| // (anti-preference / "I won't…") bound rule nudges the matching-domain consultation | ||
| // one step cautious (bias_cautious) — authored rules may make the agent MORE | ||
| // deferential, never less. POSITIVE-polarity rules expand autonomy, the riskier | ||
| // direction, and are NOT projected in v0.1 (they still inject verbatim); that is | ||
| // gated behind structured per-dimension values. An unscoped (no decision.domain) | ||
| // rule biases all actions cautious — global by construction. | ||
| function biasesFromStandingRules(fp) { | ||
| const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : []; | ||
| return rules | ||
| .filter((r) => r && r.kind === 'instruction' && r.decision && r.decision.polarity === 'negative') | ||
| .map((r) => ({ id: r.instruction_id || null, match: { domains: r.decision.domain ? [r.decision.domain] : [] }, effect: 'bias_cautious', note: r.text || null })); | ||
| } | ||
| // Merge policy sources, the authored home first: | ||
| // 1. `phaedo_fingerprint.standing_rules` — the Proposal 0007 authored home: | ||
| // kind:authorization → a deterministic gate; kind:instruction bound negative → | ||
| // a cautious bias (§D). Rides `inner.data` on the phone pull, travels everywhere | ||
| // the fingerprint does (extension ↔ phone ↔ MCP) with NO profile-schema change. | ||
| // 2. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path. | ||
| // 3. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use. | ||
| // 4. opts.policies — a loaded local file (server: mcp/consult-policies.json). | ||
| // Force rules (authorizations) always take precedence over bias rules in applyPolicies, | ||
| // so ordering within the list only affects which same-class rule wins (first match). | ||
| // Defensive — never throws. | ||
| export function loadPolicies(vd, opts = {}) { | ||
| let list = []; | ||
| try { list = list.concat(normalizePolicies(authorizationsFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ } | ||
| try { list = list.concat(normalizePolicies(biasesFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ } | ||
| try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ } | ||
@@ -99,5 +141,12 @@ try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ } | ||
| // paying for a full consultation. voice_draft is exempt (a redirect, not a decision). | ||
| export function matchAuthorization(n, policies) { | ||
| // A rule is ACTIVE only if it has not lapsed (§10.5 revocable authority). `expiresAt` | ||
| // is enforced; `agents` scoping is reserved (see normalizePolicies). Authority-narrowing | ||
| // only — an expired rule simply stops applying. | ||
| function ruleActive(rule, now) { | ||
| return !(rule.expiresAt != null && now >= rule.expiresAt); | ||
| } | ||
| export function matchAuthorization(n, policies, { now = Date.now() } = {}) { | ||
| if (!policies || !policies.length || n.type === 'voice_draft') return null; | ||
| const matched = policies.filter((r) => ruleMatches(r, n)); | ||
| const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n)); | ||
| const force = matched.find((r) => r.effect in FORCE); | ||
@@ -113,6 +162,6 @@ if (!force) return null; | ||
| // never policy-overridden (it's a redirect, not a decision). | ||
| export function applyPolicies(response, n, policies) { | ||
| export function applyPolicies(response, n, policies, { now = Date.now() } = {}) { | ||
| if (!policies || !policies.length || n.type === 'voice_draft') return response; | ||
| const auth = matchAuthorization(n, policies); | ||
| const auth = matchAuthorization(n, policies, { now }); | ||
| if (auth) { | ||
@@ -128,3 +177,3 @@ return { | ||
| const matched = policies.filter((r) => ruleMatches(r, n)); | ||
| const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n)); | ||
| const bias = matched[0]; | ||
@@ -131,0 +180,0 @@ if (!bias) return response; |
+4
-2
@@ -17,3 +17,5 @@ // Phaedo §10 Agent Consultation — MCP-binding wrapper around the pure resolver. | ||
| resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest, | ||
| shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, | ||
| shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers, | ||
| deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, | ||
| authoredDelegationSignals, | ||
| } from './consult-core.js'; | ||
@@ -23,3 +25,3 @@ | ||
| // spec/test-schemas.mjs) keep their `from './consult.js'` paths. | ||
| export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape }; | ||
| export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals }; | ||
@@ -26,0 +28,0 @@ // ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ────────────── |
@@ -16,2 +16,27 @@ // Fingerprint source for the Phaedo MCP server. The server depends only on | ||
| // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16). | ||
| // A stored fingerprint from a producer that predates identity stamping (or whose | ||
| // phone copy was never re-persisted) can arrive missing the REQUIRED §4.1 fields | ||
| // — `npm run health` surfaced exactly this on a live fingerprint. The | ||
| // deterministic ones (phaedo_protocol_version, schema_revision) are safe for any | ||
| // consumer to ensure; subject_id falls back to the existing, stable, non-PII | ||
| // `fingerprint_id` when absent, rather than minting a value that would churn on | ||
| // every load. This makes the reference server serve §4.1-conformant fingerprints | ||
| // regardless of producer staleness. Source cleanup (re-persisting these on the | ||
| // phone) remains the producer's job. SEMANTIC hygiene (episodic/opaque content) | ||
| // is deliberately NOT masked here: health keeps surfacing it as an honest | ||
| // producer signal, and context-block's render guard protects the AI projection. | ||
| const IDENTITY_PROTOCOL_VERSION = '0.1'; | ||
| const IDENTITY_SCHEMA_REVISION = 1; | ||
| function normalizeIdentity(vd) { | ||
| const fp = vd && vd.phaedo_fingerprint; | ||
| if (!fp || typeof fp !== 'object') return vd; | ||
| const patch = {}; | ||
| if (fp.phaedo_protocol_version === undefined) patch.phaedo_protocol_version = IDENTITY_PROTOCOL_VERSION; | ||
| if (fp.schema_revision === undefined) patch.schema_revision = IDENTITY_SCHEMA_REVISION; | ||
| if (fp.subject_id === undefined && typeof fp.fingerprint_id === 'string' && fp.fingerprint_id) patch.subject_id = fp.fingerprint_id; | ||
| if (!Object.keys(patch).length) return vd; | ||
| return { ...vd, phaedo_fingerprint: { ...fp, ...patch } }; | ||
| } | ||
| // Resolution order for the local fixture (1a): | ||
@@ -48,3 +73,3 @@ // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path) | ||
| await writeCache(stateDir, vd); | ||
| return vd; | ||
| return normalizeIdentity(vd); | ||
| } catch (err) { | ||
@@ -61,3 +86,3 @@ // A 4xx means the phone REACHED us and rejected this client (revoked / | ||
| const cached = await readCache(stateDir); | ||
| if (cached) return cached.vault; // phone unreachable → encrypted cache (may be stale) | ||
| if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale) | ||
| throw err; // no phone + no cache → cannot serve | ||
@@ -79,3 +104,36 @@ } | ||
| } | ||
| return vd; | ||
| return normalizeIdentity(vd); | ||
| } | ||
| // Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull. | ||
| // Used by the escalation path so reading the fingerprint (to compute the consult signal) | ||
| // doesn't trigger a SECOND prompt — the fingerprint-read AUTHORIZE — on top of the | ||
| // escalation decision card the subject already answers. One prompt per escalation, not two. | ||
| // | ||
| // The cache is seeded + refreshed by the normal live-pull consults, so it stays current; a | ||
| // consult SIGNAL tolerates a slightly-stale fingerprint (it doesn't change minute to minute). | ||
| // Falls back to a live pull only when no cache exists yet (first run on this machine), which | ||
| // seeds it. Revocation safety is unaffected: a revoked MCP's escalation can't be decrypted by | ||
| // the phone anyway (its client entry is gone), so it never reaches the subject regardless of | ||
| // what the MCP read locally. | ||
| // Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull. | ||
| // Used by the escalation path so reading the fingerprint (to compute the consult signal) | ||
| // NEVER triggers an interactive fingerprint-read AUTHORIZE on top of the escalation | ||
| // decision card. One prompt per escalation — the decision — not two, and critically no | ||
| // authorize modal racing the decision modal on the phone (that double-present crashed iOS). | ||
| // | ||
| // Uses ANY decryptable cache regardless of age. Revocation is enforced PHONE-SIDE for the | ||
| // escalation path: a revoked agent's escalation can't be decrypted by the phone (its client | ||
| // registry entry is gone), so reading a stale cache here yields nothing the agent can | ||
| // actually deliver to the subject. (The normal consult path — loadFingerprint — still does | ||
| // the live pull with its 4xx-purge revocation, so a revoked agent stops getting fresh reads | ||
| // there.) Falls through to a live pull ONLY when no cache exists at all (first run on this | ||
| // machine), which seeds it; run one phaedo_consult after pairing to seed without an | ||
| // escalation in flight. | ||
| export async function loadFingerprintPreferCache(baseDir) { | ||
| if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) { | ||
| const cached = await readCache(defaultStateDir()); | ||
| if (cached) return normalizeIdentity(cached.vault); | ||
| } | ||
| return loadFingerprint(baseDir); | ||
| } |
@@ -23,3 +23,3 @@ #!/usr/bin/env node | ||
| import { dirname, resolve } from 'path'; | ||
| import { loadFingerprint } from '../fingerprint-source.js'; | ||
| import { loadFingerprintPreferCache } from '../fingerprint-source.js'; | ||
| import { buildInjectionResponse } from '../projection.js'; | ||
@@ -41,3 +41,10 @@ | ||
| async function run() { | ||
| const vd = await loadFingerprint(MCP_DIR); | ||
| // PREFER the at-rest encrypted cache over a live phone pull. Claude Code (and Claude | ||
| // Desktop's per-tab MCP spawn) runs SessionStart often — including on every tab bounce | ||
| // to a session that uses this hook — so a LIVE pull here surfaces a stray "authorize" | ||
| // sheet on the phone with no live caller behind it, since the user didn't initiate | ||
| // anything from the subject side. The cache is exactly what session-start should rely | ||
| // on: fast, non-interrupting, and refreshed by any real consult/escalate that follows. | ||
| // Same pattern that #76 applied to escalation (cache-preferred fingerprint read). | ||
| const vd = await loadFingerprintPreferCache(MCP_DIR); | ||
| const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' }); | ||
@@ -44,0 +51,0 @@ if (response && response.projection) emitContext(response.projection); |
+6
-0
@@ -359,2 +359,8 @@ #!/usr/bin/env node | ||
| } | ||
| console.log(' 4. Let Phaedo learn how you delegate — set `phaedo_record_outcome`'); | ||
| console.log(' to "Always allow" in your client\'s tool-permission settings.'); | ||
| console.log(' It records when you override an agent (the act-as-me signal); on'); | ||
| console.log(' "Ask" it prompts every time, so the learning stays cold if skipped.'); | ||
| console.log(' It only writes a local, encrypted audit receipt — nothing leaves the'); | ||
| console.log(' device, and an authored rule still needs your explicit endorsement.'); | ||
| } | ||
@@ -361,0 +367,0 @@ if (opts.pair && !opts.dryRun && touched.length) return runPairing(); |
+11
-4
| { | ||
| "name": "phaedo-mcp", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "private": false, | ||
@@ -9,3 +9,3 @@ "license": "Apache-2.0", | ||
| "mcpName": "io.github.galavoxx/phaedo-mcp", | ||
| "homepage": "https://getphaedo.com", | ||
| "homepage": "https://phaedo.so", | ||
| "repository": { | ||
@@ -37,2 +37,3 @@ "type": "git", | ||
| "agreement.js", | ||
| "calibration.js", | ||
| "phone-source.js", | ||
@@ -59,2 +60,3 @@ "cache.js", | ||
| "prepack": "node build-package.mjs", | ||
| "pretest": "node build-package.mjs", | ||
| "setup": "node install.mjs", | ||
@@ -66,5 +68,8 @@ "setup:pair": "node install.mjs --pair", | ||
| "pull": "node pull.js", | ||
| "test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-receipts.mjs && node test-agreement.mjs", | ||
| "test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs", | ||
| "receipts": "node receipts.js list", | ||
| "agreement": "node agreement.js", | ||
| "calibration": "node calibration.js", | ||
| "health": "node health.js", | ||
| "validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json", | ||
| "smoke": "node test-smoke.mjs", | ||
@@ -78,4 +83,6 @@ "smoke:phone": "node test-phone-boundary.mjs", | ||
| "smoke:policy": "node test-consult-policy.mjs", | ||
| "smoke:escalation": "node test-escalation.mjs", | ||
| "smoke:receipts": "node test-receipts.mjs", | ||
| "smoke:agreement": "node test-agreement.mjs" | ||
| "smoke:agreement": "node test-agreement.mjs", | ||
| "smoke:calibration": "node test-calibration.mjs" | ||
| }, | ||
@@ -82,0 +89,0 @@ "dependencies": { |
+13
-1
@@ -140,2 +140,13 @@ // MCP server self-pairing (P3, plan: mcp-revocable-approval-plan.md). | ||
| // The relay lane id: prefer the one resolved during a relay rendezvous, but a LAN | ||
| // pairing ALSO needs it (the escalation channel + fp-mailbox deposit to the relay by | ||
| // this id). The phone returns its current relay_device_id in the /v1/pair response | ||
| // (localServer.ts), so capture it whichever rendezvous we used — without it, a LAN | ||
| // pairing has no relay lane and phaedo_escalate fails with "no-relay-device-id". | ||
| const relayId = relayDeviceId || body.relay_device_id || null; | ||
| // Per-device bearer (relay/auth.js): the phone shares the secret it minted with paired | ||
| // clients so they can authenticate to the relay AS this device. Without it, | ||
| // escalation/fp-mailbox deposits get 401. | ||
| const relaySecret = body.relay_device_secret || null; | ||
| return { | ||
@@ -148,3 +159,4 @@ endpoint, | ||
| sas, | ||
| ...(relayDeviceId ? { relay_device_id: relayDeviceId } : {}), | ||
| ...(relayId ? { relay_device_id: relayId } : {}), | ||
| ...(relaySecret ? { relay_device_secret: relaySecret } : {}), | ||
| }; | ||
@@ -151,0 +163,0 @@ } |
+4
-1
@@ -17,2 +17,5 @@ # Phaedo MCP server — Phase 1 (push injection) | ||
| | tool | `phaedo_check_authorization` | deterministic-only pre-flight: does a standing authorization (§10.5) already decide this action? (no inference) | | ||
| | tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. | | ||
| | tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). | | ||
| | tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). | | ||
@@ -202,3 +205,3 @@ The projection is rendered by the **same** `context-block.js` the extension | ||
| - `consultation_type`: `action_approval` · `domain_risk_check` · `escalation_default` · `voice_draft`. | ||
| - `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — the fingerprint has no coverage for this action; fall back to your own default). | ||
| - `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — either the fingerprint has no coverage for this action, or the relevant decision dimension is under an open §4.7 conflict the subject is still reconciling and is withheld; fall back to your own default or escalate to the subject). | ||
| - `deference_level` (`high`/`medium`/`low`) = how strongly to weight it; sparse data → `low`. | ||
@@ -205,0 +208,0 @@ |
+12
-1
@@ -90,4 +90,14 @@ // Phaedo §10.6 — consultation RECEIPTS (build brief M4). | ||
| // full consultations and pre-flight matches separately. | ||
| export function buildReceipt({ via, agentId, request, response, now = Date.now() }) { | ||
| export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) { | ||
| const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {}; | ||
| // Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that | ||
| // drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer | ||
| // text; coerced to the known shape; absent for deterministic pre-flights. | ||
| const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({ | ||
| cue: typeof d?.cue === 'string' ? d.cue : null, | ||
| ...(typeof d?.lean === 'number' ? { lean: d.lean } : {}), | ||
| ...(d?.value != null ? { value: String(d.value) } : {}), | ||
| confidence: typeof d?.confidence === 'number' ? d.confidence : null, | ||
| basis: typeof d?.basis === 'string' ? d.basis : null, | ||
| })) : []; | ||
| return { | ||
@@ -113,2 +123,3 @@ receipt_id: crypto.randomUUID(), | ||
| }, | ||
| ...(drv.length ? { drivers: drv } : {}), | ||
| user_action: null, | ||
@@ -115,0 +126,0 @@ }; |
| { | ||
| "phaedo_fingerprint": { | ||
| "phaedo_protocol_version": "0.1", | ||
| "fingerprint_id": "sample-0000-0000-0000-000000000000", | ||
| "subject_id": "sample-subj-0000-0000-000000000000", | ||
| "schema_revision": 1, | ||
| "updated_at": "2026-06-01T12:00:00.000Z", | ||
@@ -5,0 +8,0 @@ "persona_strength": 0.58, |
+366
-8
@@ -19,7 +19,12 @@ #!/usr/bin/env node | ||
| import { loadFingerprint } from './fingerprint-source.js'; | ||
| import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js'; | ||
| import { hasPairingRecord, loadPairingRecord } from './phone-source.js'; | ||
| import { depositDelegationSuggestions } from './delegation-sync.js'; | ||
| import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js'; | ||
| import { buildDelegationPromotions } from './consult-core.js'; | ||
| import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js'; | ||
| import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js'; | ||
| import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES } from './consult.js'; | ||
| import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js'; | ||
| import { defaultStateDir } from './cache.js'; | ||
| import { buildReceipt, appendReceipt, receiptsCapFromEnv } from './receipts.js'; | ||
| import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js'; | ||
@@ -32,5 +37,5 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url)); | ||
| const RECEIPTS_CAP = receiptsCapFromEnv(process.env); | ||
| async function emitReceipt(via, args, response) { | ||
| async function emitReceipt(via, args, response, drivers) { | ||
| try { | ||
| const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response }); | ||
| const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response, drivers }); | ||
| await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP }); | ||
@@ -42,3 +47,96 @@ } catch (e) { | ||
| // Load the fingerprint for a consult and fold in the act-as-me channel: delegation | ||
| // signals derived from this subject's own override history (§10.6 receipts). The | ||
| // origin-aware resolver then lets a `delegation` correction outrank a `self` signal on | ||
| // the same dimension. Best-effort — if receipts are unreadable, the consult still | ||
| // answers from the plain fingerprint (delegation learning never breaks a consultation). | ||
| async function loadConsultVd(baseDir, { preferCache = false } = {}) { | ||
| // preferCache (escalation): read the at-rest cache instead of a live phone pull, so the | ||
| // escalation doesn't add a fingerprint-read AUTHORIZE prompt on top of its decision card. | ||
| const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir); | ||
| try { | ||
| // Two delegation sources, both origin-aware: live override history (every override) | ||
| // + the subject's ENDORSED, portable delegation rules (authored_delegation). The | ||
| // authored rule thus shapes consult too, not only the §9 injection it already renders in. | ||
| const signals = deriveDelegationSignals(await readReceipts(defaultStateDir())) | ||
| .concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint)); | ||
| return withDelegationSignals(vd, signals); | ||
| } catch (e) { | ||
| process.stderr.write(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`); | ||
| return vd; | ||
| } | ||
| } | ||
| // Act-as-me PROMOTION (delegation-sync.js): when the override history shows an | ||
| // ESTABLISHED pattern that isn't already authored/staged, deposit a delegation | ||
| // SUGGESTION across the sync boundary (relay agent_to_ext) for the subject to review + | ||
| // endorse in the extension. Best-effort and isolated — never throws into the consult. | ||
| // Only proposes a suggestion; the subject still authors (the authored-only invariant). | ||
| let _lastDepositSig = null; // dedup: the promo set last deposited THIS process (extension-side | ||
| // endorsement never round-trips into the agent's fp, so without this | ||
| // every consult would re-encrypt + re-POST the identical suggestion) | ||
| // §10.6 receipts audit channel (receipts-sync.js): deposit the latest receipts to the | ||
| // phone's audit slot (agent_to_phone) so the subject can mark outcomes in the flow | ||
| // rather than via `node receipts.js mark`. Dedup on the latest receipt_id + | ||
| // user_action set: identical state → skip the round-trip. Best-effort; a deposit | ||
| // failure NEVER throws into the tool that triggered it. | ||
| let _lastReceiptsSig = null; | ||
| async function maybeDepositReceiptsDigest() { | ||
| try { | ||
| if (!hasPairingRecord(BASE_DIR)) return; | ||
| const receipts = await readReceipts(defaultStateDir()); | ||
| if (!receipts.length) return; | ||
| // Signature: latest id + a tally of marked states. Catches "new receipt arrived" | ||
| // AND "an existing receipt got marked" (so the next phone drain reflects the mark). | ||
| const latest = receipts[receipts.length - 1]; | ||
| const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {}); | ||
| const sig = `${latest.receipt_id}|${JSON.stringify(tally)}`; | ||
| if (sig === _lastReceiptsSig) return; | ||
| const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts); | ||
| if (res && res.deposited) _lastReceiptsSig = sig; | ||
| else if (res && !res.deposited && res.reason !== 'no-receipts') | ||
| process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`); | ||
| } catch (e) { | ||
| process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${e.message}\n`); | ||
| } | ||
| } | ||
| // Drain phone-deposited outcome marks and apply them via markOutcome. Best-effort; | ||
| // called before tools that read receipts (consult uses delegation derivation; the | ||
| // outcome tool may default to "latest") so a recent phone-side mark is reflected | ||
| // before the next operation. Idempotent on the MCP side. | ||
| async function maybePullPhoneMarks() { | ||
| try { | ||
| if (!hasPairingRecord(BASE_DIR)) return { applied: 0, skipped: 0 }; | ||
| return await drainAndApplyMarks(await loadPairingRecord(BASE_DIR), defaultStateDir()); | ||
| } catch (e) { | ||
| process.stderr.write(`[phaedo-mcp] phone marks drain skipped: ${e.message}\n`); | ||
| return { applied: 0, skipped: 0, reason: e.message }; | ||
| } | ||
| } | ||
| async function maybeDepositDelegationPromotions(vd) { | ||
| try { | ||
| if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to | ||
| const promos = buildDelegationPromotions(await readReceipts(defaultStateDir()), (vd && vd.phaedo_fingerprint) || {}); | ||
| if (!promos.length) return; | ||
| const sig = promos.map(p => `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(','); | ||
| if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip | ||
| const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos); | ||
| if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult | ||
| else if (res && !res.deposited) // promotions existed but didn't land — say why (don't fail silently) | ||
| process.stderr.write(`[phaedo-mcp] ${promos.length} delegation promotion(s) ready but deposit skipped: ${res.reason || 'http ' + res.status}${res.reason === 'no-relay-device-id' ? ' — pairing has no relay lane (local-only pairing)' : ''}\n`); | ||
| } catch (e) { | ||
| process.stderr.write(`[phaedo-mcp] delegation promotion deposit skipped: ${e.message}\n`); | ||
| } | ||
| } | ||
| const CONSULT_OPTS = consultOptsFromEnv(process.env); | ||
| // Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a | ||
| // blocking decision waits for the subject before the agent applies the safe-default | ||
| // (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS. | ||
| const ESCALATION_WINDOW_MS = (() => { | ||
| const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS); | ||
| return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000; | ||
| })(); | ||
| // Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside | ||
@@ -54,4 +152,15 @@ // the server (mcp/consult-policies.json — what policy-editor.html produces). Zero | ||
| const server = new McpServer({ name: 'phaedo', version: '0.1.0' }); | ||
| // Escalation PUSH policy (which signals wake the subject): the subject-configurable knob | ||
| // over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else | ||
| // mcp/escalation-policy.json beside the server) overrides; the portable vault policy | ||
| // (phaedo_fingerprint.escalation_policy) is merged in per-call (loadEscalationPolicy). | ||
| const ESCALATION_POLICY_PATH = process.env.PHAEDO_ESCALATION_POLICY || join(BASE_DIR, 'escalation-policy.json'); | ||
| let ESCALATION_POLICY_FILE = null; | ||
| if (existsSync(ESCALATION_POLICY_PATH)) { | ||
| try { ESCALATION_POLICY_FILE = JSON.parse(readFileSync(ESCALATION_POLICY_PATH, 'utf8')); } | ||
| catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); } | ||
| } | ||
| const server = new McpServer({ name: 'phaedo', version: '0.2.0' }); | ||
| // ── Tool: phaedo_request_injection (binding §3.1) ───────────────────────────── | ||
@@ -79,2 +188,4 @@ server.registerTool( | ||
| }, | ||
| // Read-only: returns the projection, writes nothing. Safe to auto-allow. | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| }, | ||
@@ -117,2 +228,8 @@ async (args) => { | ||
| agent_id: z.string().optional(), | ||
| // RESERVED for v0.3 authenticated agent identity (§10.5 enforcement / OQ9). | ||
| // Today `agent_id` is self-asserted and unverified; v0.3 will carry a signed | ||
| // per-agent credential here so a subject can honestly scope `agents`. Declared | ||
| // now (optional, ignored) so the v0.3 addition is backward-compatible — an | ||
| // agent sending it against this server is simply unaffected. | ||
| agent_credential: z.string().optional(), | ||
| action_descriptor: z | ||
@@ -129,8 +246,17 @@ .object({ | ||
| }, | ||
| // Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a | ||
| // delegation suggestion. Non-destructive and device-local (no external entities). | ||
| annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, | ||
| }, | ||
| async (args) => { | ||
| try { | ||
| const vd = await loadFingerprint(BASE_DIR); | ||
| const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay | ||
| const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS); | ||
| await emitReceipt('phaedo_consult', args, response); // every resolution, abstains included | ||
| // Provenance for the local audit receipt only — the cues that drove this | ||
| // (never in `response`; §10.4). Best-effort: a failure must not block the answer. | ||
| let drivers = []; | ||
| try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ } | ||
| await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included | ||
| await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort) | ||
| await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view | ||
| return { | ||
@@ -170,2 +296,5 @@ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }], | ||
| agent_id: z.string().optional(), | ||
| // RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult. | ||
| // Optional + ignored today; declared so v0.3 enforcement is additive. | ||
| agent_credential: z.string().optional(), | ||
| action_descriptor: z | ||
@@ -181,2 +310,4 @@ .object({ | ||
| }, | ||
| // Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local. | ||
| annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, | ||
| }, | ||
@@ -193,2 +324,3 @@ async (args) => { | ||
| await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' }); | ||
| await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt) | ||
| } | ||
@@ -210,2 +342,228 @@ return { | ||
| // ── Tool: phaedo_record_outcome (spec §10.6 — the act-as-me loop closer) ────── | ||
| // After consulting Phaedo and the SUBJECT decided what to do, record their outcome on | ||
| // the consultation receipt. This is the source of delegation learning: a `rejected` or | ||
| // `modified` outcome — the subject CORRECTING an agent that acted on their behalf — | ||
| // teaches Phaedo how they want delegated work handled (a `delegation`-origin signal that | ||
| // outranks their own `self` pattern in future consults). Honesty rail: record what the | ||
| // SUBJECT chose, never whether YOU (the agent) proceeded — `approved` self-reported by an | ||
| // obedient agent teaches nothing, by design, so the agreement metric can't collapse into | ||
| // "agent obedience". §10.4-safe: returns only {matched, receipt_id, user_action}. | ||
| server.registerTool( | ||
| 'phaedo_record_outcome', | ||
| { | ||
| title: 'Record what the subject decided about a consulted action', | ||
| description: | ||
| 'After you consulted Phaedo and the USER decided what to do, record their outcome on the receipt: ' + | ||
| 'approved | rejected | modified | unknown. Record what the USER chose, NOT whether you proceeded. ' + | ||
| 'A rejected/modified outcome teaches Phaedo how the user wants delegated work handled (the act-as-me ' + | ||
| 'channel). Defaults to the most recent receipt; pass receipt_id to mark a specific one.', | ||
| inputSchema: { | ||
| outcome: z.enum(USER_ACTIONS), | ||
| receipt_id: z.string().optional(), | ||
| // RESERVED for v0.3 learning-loop integrity (OQ16). This is the act-as-me | ||
| // training channel: an adversary inside the same MCP boundary could poison | ||
| // delegation preferences with forged outcome calls. v0.3 will require the | ||
| // recording agent to be authenticated (agent_id + signed agent_credential), | ||
| // rate-limited per dimension, and anomaly-checked. Declared now (optional, | ||
| // ignored) so that enforcement is a backward-compatible addition. | ||
| agent_id: z.string().optional(), | ||
| agent_credential: z.string().optional(), | ||
| }, | ||
| // Writes the subject's decision onto a local receipt (the act-as-me learning signal). | ||
| // Non-destructive + idempotent (re-recording the same outcome is a no-op); device-local. | ||
| // Recommend "Always allow" so the learning isn't gated behind a per-call prompt. | ||
| annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, | ||
| }, | ||
| async (args) => { | ||
| try { | ||
| // Drain any phone-deposited marks first (G1+G2 audit channel): if the subject | ||
| // already marked outcomes from the phone, they should land before the agent's | ||
| // own self-record so "latest" resolves against the freshest state. Best-effort. | ||
| await maybePullPhoneMarks(); | ||
| const res = await markOutcome(defaultStateDir(), args?.receipt_id || 'latest', args.outcome); | ||
| // Refresh the phone's view so the just-applied mark is reflected on next drain. | ||
| await maybeDepositReceiptsDigest(); | ||
| const body = res.matched | ||
| ? { request_type: 'outcome_record', recorded: true, receipt_id: res.receipt_id, user_action: res.user_action } | ||
| : { request_type: 'outcome_record', recorded: false, reason: res.reason }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body }; | ||
| } catch (err) { | ||
| const code = err instanceof PhaedoError ? err.code : 'request_malformed'; | ||
| return { | ||
| content: [{ type: 'text', text: `Phaedo record-outcome error [${code}]: ${err.message}` }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
| ); | ||
| // ── Tool: phaedo_escalate (docs/roadmap/escalation-and-push.md) ─────────────── | ||
| // REAL-TIME escalation for an AUTONOMOUS agent: when an agent acting on the subject's | ||
| // behalf hits a decision it can't make alone, wake the subject's phone for a live | ||
| // approve/deny/modify instead of guessing. Self-gating: it runs the consult itself and | ||
| // only wakes the subject when the signal is genuinely blocking (escalate/clarify) — a | ||
| // proceed* signal returns "just proceed", so the subject isn't pinged for low-stakes | ||
| // calls. Non-blocking: deposits + wakes, returns a request_id immediately; the agent | ||
| // then polls phaedo_escalation_status and HOLDS until it resolves (the safe default if | ||
| // the window lapses is do-not-proceed). §10.4-safe: only the agent's own action | ||
| // descriptor + the consult signal/rationale cross the wire (encrypted), never layer | ||
| // content. | ||
| server.registerTool( | ||
| 'phaedo_escalate', | ||
| { | ||
| title: 'Escalate a blocking decision to the subject in real time', | ||
| description: | ||
| "When you're acting autonomously for the user and hit a decision you shouldn't make alone, " + | ||
| 'escalate it to them live instead of guessing. Runs the consultation and, only if the signal is ' + | ||
| 'blocking (escalate/clarify), wakes the user\'s phone for an approve/deny/modify. Returns a ' + | ||
| 'request_id immediately — then poll phaedo_escalation_status and DO NOT PROCEED until it resolves ' + | ||
| '(if the user doesn\'t answer in time, hold). Use for consequential, hard-to-undo actions.', | ||
| inputSchema: { | ||
| consultation_type: z.enum(CONSULTATION_TYPES).optional(), | ||
| agent_id: z.string().optional(), | ||
| // RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult. | ||
| // Optional + ignored today; declared so v0.3 enforcement is additive. | ||
| agent_credential: z.string().optional(), | ||
| action_descriptor: z | ||
| .object({ | ||
| domain: z.string().optional(), | ||
| reversible: z.boolean().optional(), | ||
| magnitude: z.enum(['low', 'medium', 'high']).optional(), | ||
| amount: z.number().optional(), | ||
| summary: z.string().optional(), | ||
| }) | ||
| .optional(), | ||
| context: z.record(z.any()).optional(), | ||
| }, | ||
| // Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and | ||
| // writes a local receipt — not read-only, not destructive, but openWorld (it reaches off-device). | ||
| annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, | ||
| }, | ||
| async (args) => { | ||
| try { | ||
| const req = { consultation_type: 'action_approval', ...(args || {}) }; | ||
| // Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize | ||
| // prompt on top of the decision card the subject already answers (one prompt, not two). | ||
| const vd = await loadConsultVd(BASE_DIR, { preferCache: true }); | ||
| const response = await resolveConsultation(vd, req, CONSULT_OPTS); | ||
| let drivers = []; | ||
| try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ } | ||
| await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated | ||
| await maybeDepositReceiptsDigest(); // G1+G2 audit channel | ||
| // Self-gate: only wake the subject for a genuinely blocking signal. `escalate` | ||
| // always; `clarify` per the subject's push policy (default: only when the action is | ||
| // consequential — irreversible / high magnitude). A low-stakes clarify is the | ||
| // agent's to resolve in its own flow. | ||
| const pushPolicy = loadEscalationPolicy(vd, { escalationPolicy: ESCALATION_POLICY_FILE }); | ||
| if (!shouldEscalate(response.signal, req.action_descriptor, pushPolicy)) { | ||
| const subThresholdClarify = response.signal === 'clarify'; | ||
| const body = { | ||
| request_type: 'escalation', escalated: false, signal: response.signal, | ||
| rationale_hint: response.rationale_hint, confidence: response.confidence, | ||
| ...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}), | ||
| message: subThresholdClarify | ||
| ? 'Low-stakes clarification — handle it in your own flow (ask your clarifying question, or make a reasonable assumption and state it). It will surface in the user\'s review queue; no need to wake them. Raise an explicit escalate if you truly need a live decision.' | ||
| : 'Signal is not blocking — act on it with your own judgment; no need to interrupt the user.', | ||
| }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body }; | ||
| } | ||
| const pairing = await loadPairingRecord(BASE_DIR); | ||
| const dep = await depositEscalation(pairing, req, response, { windowMs: ESCALATION_WINDOW_MS }); | ||
| const body = dep.deposited | ||
| ? { | ||
| request_type: 'escalation', escalated: true, status: 'pending', | ||
| request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at, | ||
| signal: response.signal, rationale_hint: response.rationale_hint, | ||
| // The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }. | ||
| // sent:true → APNs accepted the wake; reason 'not_configured' → relay APNS_* unset, | ||
| // 'no_token'/'not_registered' → phone never registered its APNs token, 'apns_*' → | ||
| // Apple rejected (e.g. BadDeviceToken = sandbox/prod mismatch). A FOREGROUNDED phone | ||
| // is woken over the WS regardless, so a non-sent wake is not necessarily a failure. | ||
| woke: dep.woke, | ||
| message: 'Escalated to the user. Poll phaedo_escalation_status with this request_id; DO NOT PROCEED until it resolves. If it expires unanswered, hold (do not act).', | ||
| } | ||
| : { | ||
| request_type: 'escalation', escalated: false, status: 'unreachable', reason: dep.reason, | ||
| signal: response.signal, rationale_hint: response.rationale_hint, | ||
| message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired' | ||
| ? 'No real-time channel to the user (no paired device with a relay lane). Hold or fall back to surfacing the decision in your own conversation.' | ||
| : 'Could not reach the user right now. Hold (do not act) or surface the decision in your own conversation.', | ||
| }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body }; | ||
| } catch (err) { | ||
| const body = errorResponse(err, 'escalation'); | ||
| const code = err instanceof PhaedoError ? err.code : 'internal_error'; | ||
| return { content: [{ type: 'text', text: `Phaedo escalation error [${code}]: ${JSON.stringify(body)}` }], isError: true }; | ||
| } | ||
| } | ||
| ); | ||
| // ── Tool: phaedo_escalation_status ──────────────────────────────────────────── | ||
| // Poll a pending escalation (from phaedo_escalate). Returns the subject's decision once | ||
| // they answer — approve/deny/modify — and AUTOMATICALLY records it as the §10.6 outcome | ||
| // (roadmap Q7: a real-time approve/deny IS the strongest act-as-me signal, so it feeds the | ||
| // same override-learning loop as phaedo_record_outcome). While unanswered it returns | ||
| // pending (keep holding); past the window it returns expired (apply the safe-default: hold). | ||
| server.registerTool( | ||
| 'phaedo_escalation_status', | ||
| { | ||
| title: 'Check whether the subject has decided an escalation', | ||
| description: | ||
| 'Poll an escalation you opened with phaedo_escalate. Returns resolved + the decision (approve | deny | ' + | ||
| 'modify) once the user answers, pending while you should keep waiting, or expired once the window lapsed ' + | ||
| '(then HOLD — do not proceed). A resolved decision is recorded automatically as the act-as-me outcome.', | ||
| inputSchema: { | ||
| request_id: z.string(), | ||
| expires_at: z.number().optional(), | ||
| receipt_id: z.string().optional(), | ||
| }, | ||
| // Reaches the relay (openWorld) and, on resolution, writes the outcome onto a local receipt. | ||
| annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, | ||
| }, | ||
| async (args) => { | ||
| try { | ||
| const pairing = await loadPairingRecord(BASE_DIR); | ||
| const r = await pollEscalationOnce(pairing, args.request_id, { expiresAt: args.expires_at }); | ||
| if (r.resolved) { | ||
| // The live decision IS an outcome — record it on the consult receipt automatically. | ||
| let recorded = false; | ||
| try { | ||
| const m = await markOutcome(defaultStateDir(), args.receipt_id || 'latest', r.outcome); | ||
| recorded = !!(m && m.matched); | ||
| await maybeDepositReceiptsDigest(); // G1+G2: phone sees the recorded outcome on next drain | ||
| } catch { /* outcome-record is best-effort; the decision still returns */ } | ||
| const body = { | ||
| request_type: 'escalation_status', status: 'resolved', | ||
| decision: r.decision, proceed: r.proceed, outcome: r.outcome, | ||
| ...(r.note ? { note: r.note } : {}), ...(r.modified != null ? { modified: r.modified } : {}), | ||
| recorded, | ||
| message: !r.proceed | ||
| ? 'The user declined — do not proceed.' | ||
| : (r.decision === 'modify' | ||
| ? (r.modified != null | ||
| ? 'The user approved with changes — proceed using the modified parameters.' | ||
| : `The user wants a change before you proceed${r.note ? `: "${r.note}"` : ''}. Apply their instruction; do not proceed as originally planned.`) | ||
| : 'The user approved — proceed.'), | ||
| }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body }; | ||
| } | ||
| const expired = r.status === 'expired'; | ||
| const body = { | ||
| request_type: 'escalation_status', status: r.status, | ||
| ...(expired ? { proceed: false } : {}), | ||
| message: expired | ||
| ? 'The decision window lapsed with no answer. Apply the safe default: hold — do not proceed.' | ||
| : 'Not answered yet. Keep holding; poll again shortly.', | ||
| }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body }; | ||
| } catch (err) { | ||
| const code = err instanceof PhaedoError ? err.code : 'request_malformed'; | ||
| return { content: [{ type: 'text', text: `Phaedo escalation-status error [${code}]: ${err.message}` }], isError: true }; | ||
| } | ||
| } | ||
| ); | ||
| // ── Resource: phaedo://fingerprint/projection (binding §3.1) ────────────────── | ||
@@ -212,0 +570,0 @@ // Default projection (all layers, standard mode) for resource-only clients. |
+3
-3
| { | ||
| "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", | ||
| "name": "io.github.galavoxx/phaedo-mcp", | ||
| "description": "Carry your Phaedo cognitive fingerprint into any MCP client: inject your style at session start, and consult your decision pattern (proceed/clarify/escalate/decline) at agent decision points. Local, user-owned, privacy-preserving.", | ||
| "version": "0.1.1", | ||
| "description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.", | ||
| "version": "0.2.0", | ||
| "repository": { | ||
@@ -15,3 +15,3 @@ "url": "https://github.com/galavoxx/phaedo-mcp", | ||
| "identifier": "phaedo-mcp", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "transport": { | ||
@@ -18,0 +18,0 @@ "type": "stdio" |
+179
-4
@@ -326,6 +326,39 @@ // ─── Phaedo Context Block ───────────────────────────────────────────────────── | ||
| // Mutually-exclusive behavioral signal pairs. When both fire, injecting both | ||
| // produces a self-contradicting instruction (e.g. "Act without checking" AND | ||
| // "Confirm before acting" under Autonomy). Resolve to the dominant side by | ||
| // observation count; on an exact tie neither is a reliable directive, so drop | ||
| // both rather than inject a contradiction. | ||
| const OPPOSING_SIGNALS = [ | ||
| ['fewer_questions', 'wants_confirmation'], | ||
| ['fewer_options', 'wants_options'], | ||
| ['prefers_casual', 'prefers_formal'], | ||
| ['avoid_bullets', 'prefer_bullets'], | ||
| ['no_headers', 'use_headers'], | ||
| ['skip_reasoning', 'show_reasoning'], | ||
| ['conceptual_only', 'wants_implementation'], | ||
| ['too_long', 'too_short'], | ||
| ['no_examples', 'wants_examples'], | ||
| ['stay_scoped', 'wants_proactive'], | ||
| ['skip_meta', 'show_meta'], | ||
| ]; | ||
| function resolveOpposingSignals(flat) { | ||
| const out = { ...flat }; | ||
| for (const [a, b] of OPPOSING_SIGNALS) { | ||
| const av = out[a] || 0, bv = out[b] || 0; | ||
| if (av > 0 && bv > 0) { | ||
| if (av > bv) delete out[b]; | ||
| else if (bv > av) delete out[a]; | ||
| else { delete out[a]; delete out[b]; } // tie → omit both | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function synthesizeResponseInstructions(behavioralSignals, cognitiveProfile) { | ||
| const flat = {}; | ||
| const raw = {}; | ||
| for (const layer of Object.values(behavioralSignals)) | ||
| for (const [k, v] of Object.entries(layer)) flat[k] = (flat[k] || 0) + v; | ||
| for (const [k, v] of Object.entries(layer)) raw[k] = (raw[k] || 0) + v; | ||
| const flat = resolveOpposingSignals(raw); | ||
@@ -405,2 +438,79 @@ const inst = { format: [], tone: [], depth: [], autonomy: [], decisions: [], pace: [] }; | ||
| // ─── Operating rules (Proposal 0007: authored standing_rules → §9 injection) ── | ||
| // Authored, user-confirmed rules render VERBATIM (§5.2 — never summarized) at the | ||
| // TOP of the profile, above inferred style, so a confirmed rule wins when it | ||
| // conflicts with an inferred habit (§4.3 conflict-priority). Sources every | ||
| // `standing_rules` entry carrying `text` (a kind:"instruction", or a | ||
| // kind:"authorization" that also chose to surface its rule). Ordered by §5.2 | ||
| // `priority` (lower first), capped so rules never crowd out the rest of the persona. | ||
| // Raised 8 → 16 once free-form authoring shipped: a subject with the interview's | ||
| // decision rules (priorities 10–40) plus a few hand-authored rules exceeds 8, and | ||
| // the highest-priority-number (newest hand-authored) rule was being silently | ||
| // dropped from the injected block even though the vault + popup kept it. | ||
| const OPERATING_RULES_BUDGET = 16; | ||
| // A delta-promotion injects as a hard constraint only when its evidence-weighted | ||
| // priority (set by rule-review.js deltaPriority) is at/below this — i.e. strongly | ||
| // backed. Legacy flat-50 endorsements sit above it and stay out of injection. | ||
| const DELTA_INJECT_THRESHOLD = 45; | ||
| // Known-safe sources for injection (allow-list). Was a deny-list ("anything not | ||
| // delta_promotion"), which let any unknown / future / typo'd source through silently. | ||
| // New sources must be added here deliberately. `undefined` source is also allowed | ||
| // (legacy test fixtures + kind:'authorization' rules that don't stamp source — the | ||
| // existing behavior we don't want to regress); delta_promotion is conditional on the | ||
| // evidence-weighted priority gate above. Keep in sync with the source values | ||
| // producers actually write (see grep -E "source:\s*['\"]\w+['\"]"); adding a new | ||
| // producer source without listing it here = the rule silently won't inject. | ||
| const INJECT_SOURCE_ALLOW = new Set([ | ||
| 'interview', // popup.js addOperatingRule + edited questionnaire decision rules | ||
| 'interview_template', // chip-picked-unedited questionnaire rule | ||
| 'conflict_resolution', // conflict-review.js resolveConflict + revertOverride | ||
| 'delegation_promotion', // act-as-me, §9 normative | ||
| ]); | ||
| // Newlines/tabs collapse to a single space — the Operating Rules block is a | ||
| // one-line-per-rule block. Without scrub, a user-typed rule containing "\n" (or a | ||
| // future producer that emits multi-line text) would silently break out of its line | ||
| // and inject as extra bullets / pseudo-headings, derailing the block's structure. | ||
| // Render-time chokepoint catches every writer (popup, conflict-review, mobile, | ||
| // historical data already on disk) without chasing each call site. | ||
| function sanitizeRuleLine(s) { | ||
| return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); | ||
| } | ||
| function renderOperatingRules(fingerprint) { | ||
| const rules = Array.isArray(fingerprint && fingerprint.standing_rules) ? fingerprint.standing_rules : []; | ||
| const seen = new Set(); | ||
| const chosen = rules | ||
| .filter(r => r && typeof r.text === 'string' && r.text.trim()) | ||
| // Curation: the Operating-rules block is for DELIBERATE hard constraints — | ||
| // authored rules + interview-elicited decision rules + act-as-me (delegation) are | ||
| // ALWAYS injected. Auto-promoted style signals (source:'delta_promotion') inject | ||
| // ONLY when strongly evidenced — rule-review.js stamps them an evidence-weighted | ||
| // priority (lower = stronger), so a well-backed endorsement (≤ threshold) earns a | ||
| // slot while thin/legacy flat-50 ones stay out. Excluded promotions remain on the | ||
| // fingerprint and still drive §10 consult; they just don't dilute injection (the | ||
| // binding constraint is instruction-following fidelity, not tokens). | ||
| .filter(r => { | ||
| if (r.source == null) return true; // legacy / no-source — defensive | ||
| if (r.source === 'delta_promotion') { // priority-gated | ||
| return (Number.isFinite(r.priority) ? r.priority : 1e9) <= DELTA_INJECT_THRESHOLD; | ||
| } | ||
| return INJECT_SOURCE_ALLOW.has(r.source); // explicit allow-list | ||
| }) | ||
| // Dedup by normalized text — defensive against near-duplicate authored rules. | ||
| .filter(r => { const k = r.text.trim().toLowerCase().replace(/\s+/g, ' '); if (seen.has(k)) return false; seen.add(k); return true; }) | ||
| .sort((a, b) => (Number.isFinite(a.priority) ? a.priority : 1e9) - (Number.isFinite(b.priority) ? b.priority : 1e9)) | ||
| .slice(0, OPERATING_RULES_BUDGET); | ||
| if (!chosen.length) return []; | ||
| const lines = [ | ||
| '\n## Operating rules (user-confirmed)', | ||
| 'Explicit rules this person has endorsed. Treat them as hard constraints on decisions', | ||
| 'and recommendations, higher priority than inferred style. Apply them silently; do not', | ||
| 'restate them or open replies with "as someone who...".', | ||
| ]; | ||
| for (const r of chosen) { | ||
| const exc = typeof r.exception === 'string' && r.exception.trim() ? ` (Exception: ${sanitizeRuleLine(r.exception)})` : ''; | ||
| lines.push(`- ${sanitizeRuleLine(r.text)}${exc}`); | ||
| } | ||
| return lines; | ||
| } | ||
| // ─── Context block assembly ─────────────────────────────────────────────────── | ||
@@ -411,2 +521,20 @@ | ||
| // ── Framing (2026-07-04): declares the block as background about the subject, | ||
| // not conversational history. Closes the confabulation defect surfaced by the | ||
| // persona-eval cross-judge (see docs/persona-extraction/persona-effectiveness-eval.md | ||
| // §7.4 Category A) where the model was writing "you mentioned…" attributing | ||
| // fingerprint content to prior turns the user never spoke. | ||
| lines.push( | ||
| 'This is a persona model of the subject — background about how they', | ||
| 'communicate and decide, learned across prior sessions. It is NOT part of', | ||
| 'this conversation; the subject has NOT said any of what follows in this', | ||
| 'session. Use it to calibrate tone, depth, format, and how decisions are', | ||
| 'framed. Do NOT attribute anything below to the subject as if they said it', | ||
| 'here (never "you mentioned…", "as you noted…", or "given your focus on X"', | ||
| 'citing content from this profile) — those attributions would be fabrications.', | ||
| ); | ||
| // ── 0. Operating rules (authored, user-confirmed — highest priority) ───────── | ||
| lines.push(...renderOperatingRules(fingerprint)); | ||
| // ── 1. Response instructions (derived from observed behavioral corrections) ── | ||
@@ -474,2 +602,19 @@ const inst = synthesizeResponseInstructions(behavioralSignals, cognitiveProfile); | ||
| lines.push(`\n### ${layer.label}`); | ||
| // A2 residual fix (2026-07-05): content-side softening of the | ||
| // Domain-and-expertise section. The persona-eval §7.4 residual | ||
| // showed the block front-loading a persona domain (e.g. immigration) | ||
| // on prompts that didn't name or invite it. A previous framing- | ||
| // directive iteration at the block-top level (A2, PR #115) net- | ||
| // regressed by making the model over-hedgy on unrelated prompts. | ||
| // This localized directive is scoped ONLY to the Domain-and-expertise | ||
| // section — the specific place where domain content actually gets | ||
| // read as topical steering. Concrete: for Randy this section says | ||
| // "Primary Expertise: Building operational frameworks... business | ||
| // immigration matters...", which the model was reading as "front- | ||
| // load immigration on any HR-adjacent prompt." The framing tells | ||
| // it to treat those answers as BACKGROUND about the subject rather | ||
| // than topics to steer generic prompts toward. | ||
| if (layerId === 'domain_and_expertise') { | ||
| lines.push('*Use as background context about who the subject is — do not steer general prompts toward these topics unless the prompt names them.*'); | ||
| } | ||
| for (const { label, question, answer } of answered) | ||
@@ -501,4 +646,7 @@ lines.push(`${label || question}: ${answer.trim()}`); | ||
| if (layer && typeof layer.summary === 'string' && layer.summary.trim()) { | ||
| learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`); | ||
| learnedLines.push(layer.summary.trim()); | ||
| const clean = sanitizeSummary(layer.summary); // P0 hygiene: drop episodic/opaque/dup lines | ||
| if (clean) { | ||
| learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`); | ||
| learnedLines.push(clean); | ||
| } | ||
| } | ||
@@ -577,2 +725,29 @@ } | ||
| // Projection hygiene (P0, 2026-06-16): defense-in-depth on the injected summary. | ||
| // The producer's renderLayerSummary guards at generation, but a stale or | ||
| // third-party producer could store a violating summary; drop episodic + opaque | ||
| // value lines and dedupe repeated dimensions so the AI never sees them. Regexes | ||
| // MUST stay in sync with checkSummary() in spec/validate-fingerprint.mjs. | ||
| 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; | ||
| var SUMMARY_DIM_RE = /^[-*\s]*([^:]{1,60}):\s*(.+)$/; | ||
| var SUMMARY_OPAQUE_RE = /^[a-z]\d(?:[ _][a-z0-9]+)*$/i; | ||
| function sanitizeSummary(text) { | ||
| const out = []; | ||
| const seen = new Set(); | ||
| for (const raw of String(text).split('\n')) { | ||
| const t = raw.trim(); | ||
| if (!t) { out.push(raw); continue; } | ||
| if (SUMMARY_EPISODIC_RE.test(t)) continue; // drop episodic (§4.6) | ||
| const m = t.match(SUMMARY_DIM_RE); | ||
| if (m) { | ||
| const dim = m[1].trim().toLowerCase(); | ||
| if (seen.has(dim)) continue; // drop duplicate dimension | ||
| if (SUMMARY_OPAQUE_RE.test(m[2].trim())) continue; // drop opaque value | ||
| seen.add(dim); | ||
| } | ||
| out.push(raw); | ||
| } | ||
| return out.join('\n').trim(); | ||
| } | ||
| function renderContextBlock(vd, opts) { | ||
@@ -579,0 +754,0 @@ const v = validateVaultData(vd); |
@@ -129,3 +129,6 @@ // Encryption envelope per Phaedo Protocol spec §7.2. | ||
| globalThis.Phaedo = globalThis.Phaedo || {} | ||
| globalThis.Phaedo.Envelope = { wrap, unwrap } | ||
| // canonicalStringify is exposed for the conformance test vectors (spec §6 / | ||
| // spec/test-vectors): it is the only canonicalization v0.1 actually performs | ||
| // (the metadata AAD binding). See its doc-comment for the RFC 8785 subset note. | ||
| globalThis.Phaedo.Envelope = { wrap, unwrap, canonicalStringify } | ||
| })() |
+16
-1
@@ -47,2 +47,17 @@ // Key derivation helpers using native WebCrypto HKDF-SHA-256. | ||
| /** | ||
| * Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of pair_key, | ||
| * so the store-and-forward fingerprint envelope is decryptable by either device | ||
| * with NO live session or biometric — and never reuses pair_key's HMAC role or | ||
| * seed_key's. Re-derivable from the stored pair_key, so neither side holds state. | ||
| */ | ||
| async function deriveFpSyncKey(pairKey) { | ||
| return hkdfBytes( | ||
| pairKey, | ||
| new Uint8Array(0), | ||
| utf8(P.HKDF_INFO.FP_SYNC_KEY), | ||
| P.SIZES.AES_KEY, | ||
| ) | ||
| } | ||
| async function deriveSessionKey(ephemeralShared, sessionToken) { | ||
@@ -112,5 +127,5 @@ return hkdfBytes( | ||
| globalThis.Phaedo.Kdf = { | ||
| derivePairKey, deriveSeedKey, deriveSessionKey, deriveSAS, formatSAS, | ||
| derivePairKey, deriveSeedKey, deriveFpSyncKey, deriveSessionKey, deriveSAS, formatSAS, | ||
| authEphemeral, verifyAuthEphemeral, | ||
| } | ||
| })() |
@@ -32,2 +32,6 @@ // Phaedo Protocol constants. Single source of truth on the extension side. | ||
| SEED_KEY: 'phaedo-seed-v0.1', | ||
| // Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of | ||
| // pair_key so the store-and-forward fingerprint envelope is decryptable by | ||
| // either device with no live session — and never reuses any other role's key. | ||
| FP_SYNC_KEY: 'phaedo-fp-sync-v0.1', | ||
| }, | ||
@@ -34,0 +38,0 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
291769
33.24%36
2.86%4422
32.91%320
0.95%39
5.41%