phaedo-mcp 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +50 -6
  12. package/consult-policy.js +427 -33
  13. package/consult.js +8 -1
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +25 -5
  26. package/phone-source.js +79 -11
  27. package/projection.js +4 -1
  28. package/pull.js +19 -3
  29. package/receipts-sync.js +197 -0
  30. package/receipts.js +33 -2
  31. package/server.js +305 -15
  32. package/server.json +3 -3
  33. package/signing-audit.js +111 -0
  34. package/signing-request.js +217 -0
  35. package/signing.js +176 -0
  36. package/vendor/authored-source.js +250 -0
  37. package/vendor/capabilities.js +203 -0
  38. package/vendor/capture/capture-core.js +67 -0
  39. package/vendor/capture/detectors.js +287 -0
  40. package/vendor/capture/exchanges.js +77 -0
  41. package/vendor/capture/model-detect.js +195 -0
  42. package/vendor/capture/owner.js +35 -0
  43. package/vendor/capture/package.json +4 -0
  44. package/vendor/capture/source-id.js +56 -0
  45. package/vendor/capture/state.js +148 -0
  46. package/vendor/capture/transcript.js +153 -0
  47. package/vendor/commitments.js +169 -0
  48. package/vendor/context-block.js +251 -18
  49. package/vendor/envelope.js +50 -4
  50. package/vendor/extraction/capture-combine.js +160 -0
  51. package/vendor/extraction/catalog.js +232 -0
  52. package/vendor/extraction/delta-lift.js +109 -0
  53. package/vendor/extraction/fingerprint-update.js +227 -0
  54. package/vendor/extraction/package.json +4 -0
  55. package/vendor/extraction/persist.js +40 -0
  56. package/vendor/extraction/reconcile.js +269 -0
  57. package/vendor/extraction/resolve.js +178 -0
  58. package/vendor/extraction/rule-promotion.js +137 -0
  59. package/vendor/kdf.js +20 -1
  60. package/vendor/protocol.js +7 -0
  61. package/vendored-files.mjs +82 -0
@@ -291,7 +291,9 @@ function extractValueSignals(fingerprint) {
291
291
  let hasValues = false, valueCount = 0, futureOriented = false, growthMinded = false;
292
292
  for (const layer of Object.values(fingerprint.layers || {})) {
293
293
  for (const { answer } of layer.responses || []) {
294
- const t = (answer || '').trim();
294
+ // Coerced, not assumed. `answer` is not required by fingerprint.schema.json, so a legal
295
+ // fingerprint can carry any type here — `(answer || '')` still throws on a number or object.
296
+ const t = (typeof answer === 'string' ? answer : '').trim();
295
297
  if (!t) continue;
296
298
  if (/\b(what matters (most )?(to me|for me)|I believe in|at my core|my (core )?(principle|value|belief)|I care (deeply|strongly )?(about|for)|what I (value|stand for)|fundamentally I|it('s| is) important to me|I('m| am) committed to)\b/i.test(t)) {
297
299
  hasValues = true; valueCount++;
@@ -470,14 +472,132 @@ const INJECT_SOURCE_ALLOW = new Set([
470
472
  // and inject as extra bullets / pseudo-headings, derailing the block's structure.
471
473
  // Render-time chokepoint catches every writer (popup, conflict-review, mobile,
472
474
  // historical data already on disk) without chasing each call site.
475
+ // The block's own closing tag. Anything that renders inside the block and reproduces this ends
476
+ // the persona early as far as the model is concerned — everything after the forged tag reads as
477
+ // outside it. Reached from summaries, interview answers, layer labels and rule text, all of which
478
+ // carry attacker-influenceable content (see extraction/direct.js for the upstream chain).
479
+ // Neutralised by breaking the tag, not by deleting the text: the subject's own words are never
480
+ // silently removed from what the model is told about them.
481
+ const BLOCK_TAG_RE = /<\/?\s*phaedo_user_profile\s*>/gi;
482
+ function neutralizeBlockTag(s) {
483
+ return String(s == null ? '' : s).replace(BLOCK_TAG_RE, (m) => m.replace('<', '‹'));
484
+ }
485
+
473
486
  function sanitizeRuleLine(s) {
474
- return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
487
+ return neutralizeBlockTag(String(s == null ? '' : s)).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
488
+ }
489
+ // §5.2 scope activation (v0.2 / OQ5) for domain/task-scoped rules. MIRRORS
490
+ // user-config.js instructionActiveFor (this file is vendored standalone into
491
+ // mcp/vendor/, so it cannot require the §5 module; parity is locked by
492
+ // scripts/test_user_config.mjs). Same substring namespace as the §10.5 resolver:
493
+ // the session's domain/task_hint CONTAINS the rule's scope entry, case-insensitive.
494
+ // Fail-closed: scoped + context known + no match → inactive.
495
+ function scopeActive(rule, sessionContext) {
496
+ const scope = rule && rule.scope;
497
+ if (scope !== 'domain' && scope !== 'task') return true;
498
+ const ctx = sessionContext && typeof sessionContext === 'object' ? sessionContext : {};
499
+ const hay = scope === 'domain'
500
+ ? (typeof ctx.domain === 'string' ? ctx.domain.toLowerCase() : '')
501
+ : (typeof ctx.task_hint === 'string' ? ctx.task_hint.toLowerCase() : '');
502
+ if (!hay) return false;
503
+ const keys = scope === 'domain' ? rule.scope_domains : rule.scope_tasks;
504
+ return (Array.isArray(keys) ? keys : []).some(k => typeof k === 'string' && k && hay.includes(k.toLowerCase()));
505
+ }
506
+ // A scoped rule injected WITHOUT session context gets an explicit conditional
507
+ // prefix — the consuming model activates it contextually (the §5.2 model-mediated
508
+ // branch). A consumer that CAN evaluate scope filters deterministically instead.
509
+ function scopePrefix(rule) {
510
+ const scope = rule && rule.scope;
511
+ if (scope === 'domain' && Array.isArray(rule.scope_domains) && rule.scope_domains.length) {
512
+ return `Only when the session involves ${rule.scope_domains.map(sanitizeRuleLine).join(' or ')}: `;
513
+ }
514
+ if (scope === 'task' && Array.isArray(rule.scope_tasks) && rule.scope_tasks.length) {
515
+ return `Only when the task involves ${rule.scope_tasks.map(sanitizeRuleLine).join(' or ')}: `;
516
+ }
517
+ return '';
518
+ }
519
+ // Which of two identical rules to keep. Mirrors sync-merge.js `richness` deliberately — the merge
520
+ // prefers the copy carrying a structured decision value or an exception, and the injection seam
521
+ // must not then discard the copy the merge chose to keep.
522
+ function ruleRichness(r) {
523
+ return (r && r.decision && r.decision.value != null ? 2 : 0) + (r && r.exception ? 1 : 0);
475
524
  }
476
- function renderOperatingRules(fingerprint) {
525
+
526
+ // Does this rule NARROW what the agent may do? Only §5.2 `authorization` rules carry an `effect`,
527
+ // and `autoproceed` / `bias_bold` are the two that EXPAND autonomy — the same split
528
+ // mcp/consult-policy.js calls RELAXING. Anything without an effect is a plain instruction, and an
529
+ // instruction is a constraint on behaviour, so it counts as restrictive. Erring that way is the
530
+ // point: the budget should shed permissions, not prohibitions.
531
+ const RELAXING_EFFECTS = new Set(['autoproceed', 'bias_bold']);
532
+ function isRestrictiveRule(r) {
533
+ return !(r && typeof r.effect === 'string' && RELAXING_EFFECTS.has(r.effect));
534
+ }
535
+
536
+ /**
537
+ * How many authored rules the injection budget is currently dropping, and which.
538
+ *
539
+ * Exposed so the POPUP can tell the subject. The console warning inside renderOperatingRules is
540
+ * for a developer; the person whose rules are being silently withheld is not reading the service
541
+ * worker log. Computed by running the REAL selection — a second implementation that agreed today
542
+ * and drifted tomorrow would report a number the injected block does not honour, which is worse
543
+ * than saying nothing.
544
+ */
545
+ function operatingRulesBudget(fingerprint, sessionContext) {
546
+ const before = _lastDropped;
547
+ _lastDropped = null;
548
+ try {
549
+ renderOperatingRules(fingerprint, sessionContext);
550
+ const dropped = _lastDropped || [];
551
+ return { dropped: dropped.length, texts: dropped.map(r => String(r.text || '').trim()) };
552
+ } finally {
553
+ _lastDropped = before;
554
+ }
555
+ }
556
+ let _lastDropped = null;
557
+
558
+ function renderOperatingRules(fingerprint, sessionContext) {
559
+ // Scope semantics (§5.2 v0.2): with session context, scoped rules filter
560
+ // deterministically (fail-closed); without it, they inject with a conditional
561
+ // prefix. hasContext is per-scope-relevant field: a context carrying only a
562
+ // domain still leaves task-scoped rules on the conditional-prefix branch.
563
+ const ctx = sessionContext && typeof sessionContext === 'object' ? sessionContext : null;
564
+ const hasDomainCtx = !!(ctx && typeof ctx.domain === 'string' && ctx.domain);
565
+ const hasTaskCtx = !!(ctx && typeof ctx.task_hint === 'string' && ctx.task_hint);
477
566
  const rules = Array.isArray(fingerprint && fingerprint.standing_rules) ? fingerprint.standing_rules : [];
478
- const seen = new Set();
479
- const chosen = rules
567
+ let chosen = rules
480
568
  .filter(r => r && typeof r.text === 'string' && r.text.trim())
569
+ // LAPSED RULES ARE NOT INJECTED. §9 never read `expires_at`, while the §10.5 resolver
570
+ // enforces it — so the consult path correctly refused a grant the injection path was
571
+ // simultaneously telling the model the subject had endorsed. Same artifact, two answers,
572
+ // and the injected one presents it as a standing hard constraint.
573
+ //
574
+ // ABSENT and UNREADABLE differ, exactly as in mcp/consult-policy.js: absent means no expiry,
575
+ // present-but-unparseable means already lapsed. An unreadable bound must never read as an
576
+ // unbounded one.
577
+ .filter(r => {
578
+ if (r.expires_at === undefined || r.expires_at === null) return true;
579
+ if (typeof r.expires_at !== 'string') return false;
580
+ const t = Date.parse(r.expires_at);
581
+ return Number.isFinite(t) && Date.now() < t;
582
+ })
583
+ .filter(r => {
584
+ if (r.scope !== 'domain' && r.scope !== 'task') return true;
585
+ // A DEGENERATE scope — `scope:'domain'` with no usable `scope_domains` — is dropped, not
586
+ // rendered. Without this it survived the filter (no context => "keep, prefixed below") and
587
+ // then `scopePrefix` returned '' because the key list was empty, so a rule the subject
588
+ // scoped to something narrow rendered as an UNCONDITIONAL global constraint.
589
+ //
590
+ // The deterministic consumer already treats it as permanently inactive
591
+ // (user-config.js instructionActiveFor is fail-closed), so the two consumers disagreed
592
+ // completely about the same rule. spec/validate-fingerprint.mjs warns about exactly this
593
+ // — "or worse, a lenient consumer would apply it globally" — but the validator is a CI
594
+ // tool, not on the read path. Fail closed here too.
595
+ const keys = r.scope === 'domain' ? r.scope_domains : r.scope_tasks;
596
+ const usable = Array.isArray(keys) && keys.some((k) => typeof k === 'string' && k.trim());
597
+ if (!usable) return false;
598
+ const canEvaluate = r.scope === 'domain' ? hasDomainCtx : hasTaskCtx;
599
+ return canEvaluate ? scopeActive(r, ctx) : true; // no context → keep, prefixed below
600
+ })
481
601
  // Curation: the Operating-rules block is for DELIBERATE hard constraints —
482
602
  // authored rules + interview-elicited decision rules + act-as-me (delegation) are
483
603
  // ALWAYS injected. Auto-promoted style signals (source:'delta_promotion') inject
@@ -493,10 +613,69 @@ function renderOperatingRules(fingerprint) {
493
613
  }
494
614
  return INJECT_SOURCE_ALLOW.has(r.source); // explicit allow-list
495
615
  })
496
- // Dedup by normalized text — defensive against near-duplicate authored rules.
497
- .filter(r => { const k = r.text.trim().toLowerCase().replace(/\s+/g, ' '); if (seen.has(k)) return false; seen.add(k); return true; })
498
- .sort((a, b) => (Number.isFinite(a.priority) ? a.priority : 1e9) - (Number.isFinite(b.priority) ? b.priority : 1e9))
499
- .slice(0, OPERATING_RULES_BUDGET);
616
+ // SORT BEFORE DEDUP. It used to run the other way, and dedup keeps the first entry it sees —
617
+ // so of two identical-text copies at different priorities, whichever the merge happened to
618
+ // put first survived. Keeping the weaker one then loses it to the budget below while the
619
+ // stronger one has already been discarded. Priority order first, so the copy that survives
620
+ // is the one the subject weighted highest, on every device.
621
+ .sort((a, b) => (Number.isFinite(a.priority) ? a.priority : 1e9) - (Number.isFinite(b.priority) ? b.priority : 1e9));
622
+
623
+ // Collapse identical rules — and collapse them the way sync-merge.js does, keeping the RICHER
624
+ // copy. Plain first-wins discarded an authored `exception`, so the block told the model the
625
+ // subject endorsed an UNQUALIFIED hard constraint they had actually qualified. Which copy won
626
+ // depended purely on array order, and §5.2 rules merge by UNION across devices, so that order
627
+ // is arbitrary — the same vault could render either version.
628
+ //
629
+ // The key includes the SCOPE, not just the text. An unscoped copy and a domain-scoped copy of
630
+ // the same sentence are different constraints, and collapsing them either widens a rule the
631
+ // subject narrowed (finding I4's failure, reintroduced through the back door) or narrows a
632
+ // global one. They stay separate lines.
633
+ //
634
+ // COUPLED TO sync-merge.js `richness`: the merge already prefers the copy with an exception or
635
+ // a structured decision value. This is the same choice at the injection seam, and
636
+ // scripts/test_injection_integrity.mjs asserts the two agree rather than trusting the comment.
637
+ const byKey = new Map();
638
+ for (const r of chosen) {
639
+ const scopeKey = r.scope === 'domain' ? `d:${(r.scope_domains || []).join(',')}`
640
+ : r.scope === 'task' ? `t:${(r.scope_tasks || []).join(',')}`
641
+ : '';
642
+ const k = `${scopeKey}|${r.text.trim().toLowerCase().replace(/\s+/g, ' ')}`;
643
+ const prev = byKey.get(k);
644
+ // Strictly greater: the priority sort already put the stronger copy first, so an equal-richness
645
+ // later copy must not displace it.
646
+ if (!prev || ruleRichness(r) > ruleRichness(prev)) byKey.set(k, r);
647
+ }
648
+ chosen = [...byKey.values()];
649
+
650
+ // THE BUDGET DROPS GRANTS BEFORE PROHIBITIONS. Truncation is deliberate curation — rules must
651
+ // not crowd out the rest of the persona — but it was purely priority-ordered, and a rule with
652
+ // no `priority` sorts to 1e9 and goes first. That is the wrong fail direction: this codebase's
653
+ // rule is that missing grants fail safe and missing prohibitions do not. A dropped
654
+ // "auto-proceed on low-stakes X" costs a confirmation prompt; a dropped "never do X" costs X.
655
+ //
656
+ // Selection is re-ordered, RENDERING is not — the lines below stay in priority order, so a
657
+ // priority-90 prohibition does not get read as outranking a priority-1 instruction.
658
+ const overBudget = chosen.length - OPERATING_RULES_BUDGET;
659
+ let droppedRules = [];
660
+ if (overBudget > 0) {
661
+ const keep = new Set(chosen
662
+ .map((r, i) => ({ r, i, restrictive: isRestrictiveRule(r) }))
663
+ .sort((a, b) => (b.restrictive - a.restrictive) || (a.i - b.i))
664
+ .slice(0, OPERATING_RULES_BUDGET)
665
+ .map(e => e.r));
666
+ const dropped = chosen.filter(r => !keep.has(r));
667
+ droppedRules = dropped;
668
+ chosen = chosen.filter(r => keep.has(r));
669
+ // Not silent. The subject authored these and the block does not carry them; saying so in the
670
+ // block itself would tell the model it is missing constraints, which invites hedging on every
671
+ // answer — so it goes to the console instead. Surfacing it in the popup, where the subject can
672
+ // actually act on it, is tracked in docs/roadmap/next-steps.md.
673
+ _lastDropped = droppedRules;
674
+ if (typeof console !== 'undefined' && console.warn) {
675
+ console.warn(`[phaedo] ${dropped.length} authored rule(s) exceeded the ${OPERATING_RULES_BUDGET}-rule injection budget and were not injected:`,
676
+ dropped.map(r => r.text.trim().slice(0, 60)));
677
+ }
678
+ }
500
679
  if (!chosen.length) return [];
501
680
  const lines = [
502
681
  '\n## Operating rules (user-confirmed)',
@@ -506,14 +685,18 @@ function renderOperatingRules(fingerprint) {
506
685
  ];
507
686
  for (const r of chosen) {
508
687
  const exc = typeof r.exception === 'string' && r.exception.trim() ? ` (Exception: ${sanitizeRuleLine(r.exception)})` : '';
509
- lines.push(`- ${sanitizeRuleLine(r.text)}${exc}`);
688
+ // Prefix a scoped rule only when this render could NOT evaluate its scope
689
+ // (matched-and-filtered rules render plain — the condition already held).
690
+ const needsPrefix = (r.scope === 'domain' && !hasDomainCtx) || (r.scope === 'task' && !hasTaskCtx);
691
+ const pre = needsPrefix ? scopePrefix(r) : '';
692
+ lines.push(`- ${pre}${sanitizeRuleLine(r.text)}${exc}`);
510
693
  }
511
694
  return lines;
512
695
  }
513
696
 
514
697
  // ─── Context block assembly ───────────────────────────────────────────────────
515
698
 
516
- function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfile = {}, cognitiveProfile = null, sessionMetrics = {}, mentalModels = null) {
699
+ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfile = {}, cognitiveProfile = null, sessionMetrics = {}, mentalModels = null, sessionContext = null) {
517
700
  const lines = ['<phaedo_user_profile>'];
518
701
 
519
702
  // ── Framing (2026-07-04): declares the block as background about the subject,
@@ -532,7 +715,7 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
532
715
  );
533
716
 
534
717
  // ── 0. Operating rules (authored, user-confirmed — highest priority) ─────────
535
- lines.push(...renderOperatingRules(fingerprint));
718
+ lines.push(...renderOperatingRules(fingerprint, sessionContext));
536
719
 
537
720
  // ── 1. Response instructions (derived from observed behavioral corrections) ──
538
721
  const inst = synthesizeResponseInstructions(behavioralSignals, cognitiveProfile);
@@ -593,9 +776,14 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
593
776
  for (const [layerId, layer] of Object.entries(fingerprint.layers)) {
594
777
  // A layer may be extraction-only (added by Loop 2 reconcile) with no
595
778
  // questionnaire responses — guard before filtering.
596
- const answered = (layer.responses || []).filter(r => r.answer.trim());
779
+ // `answer` is NOT required by fingerprint.schema.json, so a schema-LEGAL fingerprint could
780
+ // omit it — and `r.answer.trim()` then threw. The throw escapes rebuildContext inside an
781
+ // un-wrapped async chrome.storage callback in content.js, so the injection AND the capture
782
+ // wiring after it never ran: no persona at all, silently. buildLayerConfidence already
783
+ // type-guarded this exact field; this line did not.
784
+ const answered = (layer.responses || []).filter(r => typeof r?.answer === 'string' && r.answer.trim());
597
785
  if (!answered.length) continue;
598
- lines.push(`\n### ${layer.label}`);
786
+ lines.push(`\n### ${neutralizeBlockTag(layer.label)}`);
599
787
  // A2 residual fix (2026-07-05): content-side softening of the
600
788
  // Domain-and-expertise section. The persona-eval §7.4 residual
601
789
  // showed the block front-loading a persona domain (e.g. immigration)
@@ -613,8 +801,11 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
613
801
  if (layerId === 'domain_and_expertise') {
614
802
  lines.push('*Use as background context about who the subject is — do not steer general prompts toward these topics unless the prompt names them.*');
615
803
  }
804
+ // Neutralised, not stripped — an authored answer is the subject's own words. `answer` is
805
+ // free text the subject typed and `label`/`question` come from the questionnaire schema,
806
+ // but a restored or synced fingerprint can carry either from elsewhere.
616
807
  for (const { label, question, answer } of answered)
617
- lines.push(`${label || question}: ${answer.trim()}`);
808
+ lines.push(`${neutralizeBlockTag(label || question)}: ${neutralizeBlockTag(answer.trim())}`);
618
809
  const layerSigs = behavioralSignals[layerId];
619
810
  if (layerSigs) {
620
811
  const observed = Object.entries(layerSigs)
@@ -641,7 +832,7 @@ function buildContextBlock(fingerprint, behavioralSignals = {}, linguisticProfil
641
832
  if (layer && typeof layer.summary === 'string' && layer.summary.trim()) {
642
833
  const clean = sanitizeSummary(layer.summary); // P0 hygiene: drop episodic/opaque/dup lines
643
834
  if (clean) {
644
- learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`);
835
+ learnedLines.push(`\n### ${neutralizeBlockTag(layer.label || layerId.replace(/_/g, ' '))}`);
645
836
  learnedLines.push(clean);
646
837
  }
647
838
  }
@@ -714,6 +905,14 @@ function validateVaultData(vd) {
714
905
  }
715
906
  for (const k of ['phaedo_behavioral_signals', 'phaedo_linguistic_profile', 'phaedo_session_metrics'])
716
907
  if (vd[k] !== undefined && vd[k] !== null && !isPlainObject(vd[k])) errors.push(`${k} must be an object`);
908
+ // §5 sibling artifact (config home migration, part 2). Shape-guard only — full
909
+ // §5 conformance is spec/validate-fingerprint.mjs validateUserConfiguration.
910
+ const cfg = vd.phaedo_user_configuration;
911
+ if (cfg !== undefined && cfg !== null) {
912
+ if (!isPlainObject(cfg)) errors.push('phaedo_user_configuration must be an object');
913
+ else if (cfg.standing_instructions !== undefined && !Array.isArray(cfg.standing_instructions))
914
+ errors.push('phaedo_user_configuration.standing_instructions must be an array');
915
+ }
717
916
  return { ok: errors.length === 0, empty, errors };
718
917
  }
719
918
 
@@ -725,7 +924,13 @@ function validateVaultData(vd) {
725
924
  var SUMMARY_EPISODIC_RE = /(https?:\/\/|\bwww\.)|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|\b[a-z0-9][a-z0-9-]*\.(com|org|net|io|so|co|ai|app|xyz|info)\b/i;
726
925
  var SUMMARY_DIM_RE = /^[-*\s]*([^:]{1,60}):\s*(.+)$/;
727
926
  var SUMMARY_OPAQUE_RE = /^[a-z]\d(?:[ _][a-z0-9]+)*$/i;
927
+ // Summary lines carry EXTRACTION output — the most attacker-influenceable content in the block
928
+ // (extraction/direct.js documents the chain). Tag-neutralised before any other hygiene runs.
728
929
  function sanitizeSummary(text) {
930
+ return _sanitizeSummaryInner(neutralizeBlockTag(text));
931
+ }
932
+
933
+ function _sanitizeSummaryInner(text) {
729
934
  const out = [];
730
935
  const seen = new Set();
731
936
  for (const raw of String(text).split('\n')) {
@@ -752,14 +957,41 @@ function renderContextBlock(vd, opts) {
752
957
  ctxWarn(msg); // loud, not silent — a malformed producer is a bug
753
958
  return ''; // …but still degrade gracefully (don't crash the page)
754
959
  }
755
- const fingerprint = vd.phaedo_fingerprint;
960
+ let fingerprint = vd.phaedo_fingerprint;
756
961
  if (!fingerprint) return ''; // v.empty — legitimately nothing to render
962
+ // §5 canonical when present (config home migration, part 2): the sibling
963
+ // artifact's standing_instructions REPLACE the A-home standing_rules for this
964
+ // render. Inline mirror of user-config.js foldStandingInstructions (this file
965
+ // is vendored standalone); parity locked by scripts/test_context_block.mjs.
966
+ //
967
+ // §5.4 is checked HERE too, not just at the drain: the configuration must name THIS
968
+ // fingerprint. Replace the fingerprint (a §8.2 restore, or a merge from another device) while
969
+ // an older configuration is still in the vault and its fingerprint_id names something gone —
970
+ // folding it anyway would put one identity's authored rules into another's context block, and
971
+ // the rendered output looks perfectly well-formed either way.
972
+ const cfg = vd.phaedo_user_configuration;
973
+ const cfgGoverns = cfg && typeof cfg === 'object' && fingerprint
974
+ && typeof fingerprint.fingerprint_id === 'string' && fingerprint.fingerprint_id
975
+ && cfg.fingerprint_id === fingerprint.fingerprint_id
976
+ && !(typeof fingerprint.subject_id === 'string' && fingerprint.subject_id
977
+ && typeof cfg.subject_id === 'string' && cfg.subject_id
978
+ && cfg.subject_id !== fingerprint.subject_id);
979
+ if (cfgGoverns && Array.isArray(cfg.standing_instructions)) {
980
+ fingerprint = { ...fingerprint };
981
+ if (cfg.standing_instructions.length) fingerprint.standing_rules = cfg.standing_instructions;
982
+ else delete fingerprint.standing_rules;
983
+ }
757
984
  const behavioralSignals = vd.phaedo_behavioral_signals || {};
758
985
  const linguisticProfile = vd.phaedo_linguistic_profile || {};
759
986
  const sessionMetrics = vd.phaedo_session_metrics || {};
760
987
  const cognitiveProfile = synthesizeCognitiveProfile(linguisticProfile, sessionMetrics);
761
988
  const mentalModels = synthesizeMentalModels(linguisticProfile);
762
- return buildContextBlock(fingerprint, behavioralSignals, linguisticProfile, cognitiveProfile, sessionMetrics, mentalModels);
989
+ // §5.2 v0.2 scope activation: a consumer that knows the session's domain /
990
+ // task_hint (e.g. the MCP injection request's §9.2 session_context) passes it
991
+ // via opts.sessionContext so scoped rules filter deterministically; without it
992
+ // they render with a conditional scope prefix.
993
+ const sessionContext = (opts && opts.sessionContext) || null;
994
+ return buildContextBlock(fingerprint, behavioralSignals, linguisticProfile, cognitiveProfile, sessionMetrics, mentalModels, sessionContext);
763
995
  }
764
996
 
765
997
  globalThis.PhaedoContext = {
@@ -767,6 +999,7 @@ globalThis.PhaedoContext = {
767
999
  synthesizeCognitiveProfile,
768
1000
  synthesizeMentalModels,
769
1001
  renderContextBlock,
1002
+ operatingRulesBudget,
770
1003
  validateVaultData,
771
1004
  SIGNAL_LABELS,
772
1005
  };
@@ -33,13 +33,59 @@
33
33
  * contains only strings. NOT full RFC 8785 — when §8.3 signing lands and we
34
34
  * canonicalize artifact bytes with numeric fields, switch to a JCS impl.
35
35
  */
36
+ // RFC 8785 (JCS) canonical JSON serialization — full method (spec §6, v0.1.44).
37
+ // In ECMAScript, JSON.stringify already emits RFC 8785-conformant scalars:
38
+ // shortest-round-trip ES number formatting ("1e+21", "1e-7", -0 → "0") and
39
+ // minimal string escaping (short escapes \b \t \n \f \r \" \\, with \u00XX
40
+ // only for the remaining control chars < 0x20). What it does NOT guarantee is
41
+ // recursive key ordering, and it silently serializes non-finite numbers as
42
+ // null. Full RFC 8785 here = UTF-16 code-unit key sort (Array.prototype.sort's
43
+ // default string order) + explicit REJECTION of values JSON cannot express
44
+ // (NaN/Infinity throw instead of corrupting the canonical bytes as "null";
45
+ // a top-level undefined throws instead of returning the invalid "undefined").
46
+ // Inside containers, undefined/function/symbol follow JSON.stringify
47
+ // semantics: omitted from objects, null in arrays. (The pre-v0.1.44 subset
48
+ // emitted invalid JSON for an undefined-valued object property.)
49
+ // MUST stay byte-identical to mobile/src/lib/envelope.ts (and the mirror in
50
+ // scripts/test_cross_compat.mjs); locked by TC-CANON vectors.
51
+ const notJson = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol'
36
52
  function canonicalStringify(value) {
37
- if (value === null || typeof value !== 'object') return JSON.stringify(value)
53
+ if (typeof value === 'number' && !Number.isFinite(value)) {
54
+ throw new Error('canonicalStringify: non-finite numbers are not expressible in RFC 8785 JSON')
55
+ }
56
+ if (value === null || typeof value !== 'object') {
57
+ const s = JSON.stringify(typeof value === 'string' ? value.normalize('NFC') : value)
58
+ if (s === undefined) throw new Error('canonicalStringify: a ' + typeof value + ' is not expressible in JSON')
59
+ return s
60
+ }
38
61
  if (Array.isArray(value)) {
39
- return '[' + value.map(canonicalStringify).join(',') + ']'
62
+ return '[' + value.map(v => notJson(v) ? 'null' : canonicalStringify(v)).join(',') + ']'
63
+ }
64
+ // §6.2 NFC. "Implementations MUST normalize text to NFC before canonicalization to avoid
65
+ // signature breakage from equivalent but byte different strings." It was implemented NOWHERE.
66
+ //
67
+ // Not currently breaking §8.3, because signer and verifier work from the same bytes — but that
68
+ // holds only while nothing re-normalizes, and Apple platforms produce NFD routinely (HFS+/APFS
69
+ // filename APIs, some IME and paste paths). The failure it protects against is the worst kind:
70
+ // a signature that verified yesterday stops verifying today, with both sides holding text that
71
+ // compares equal to a human and to `===` after normalization, and nothing in the error saying
72
+ // why. Cheap now; archaeology later.
73
+ //
74
+ // KEYS TOO, not just values. Two objects whose keys differ only by normalization would
75
+ // otherwise canonicalize to different bytes AND could collide into one key after
76
+ // normalization — so the sort happens on the NORMALIZED keys, and a collision is rejected
77
+ // rather than silently dropping one of them.
78
+ const seen = new Map()
79
+ for (const k of Object.keys(value)) {
80
+ if (notJson(value[k])) continue
81
+ const nk = k.normalize('NFC')
82
+ if (seen.has(nk)) throw new Error('canonicalStringify: keys collide under NFC: ' + JSON.stringify(k))
83
+ seen.set(nk, value[k])
40
84
  }
41
- const keys = Object.keys(value).sort()
42
- return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k])).join(',') + '}'
85
+ const keys = [...seen.keys()].sort() // UTF-16 code-unit order (RFC 8785 §3.2.3), post-NFC
86
+ const parts = []
87
+ for (const k of keys) parts.push(JSON.stringify(k) + ':' + canonicalStringify(seen.get(k)))
88
+ return '{' + parts.join(',') + '}'
43
89
  }
44
90
 
45
91
  function utf8(s) { return new TextEncoder().encode(s) }
@@ -0,0 +1,160 @@
1
+ // Phaedo capture — cross-surface delta combination (the owner-side merge core).
2
+ //
3
+ // A capture surface (the Claude Code hook, later the a11y helper) deposits its
4
+ // CUMULATIVE Path-A counters as lifted source='delta' signals. The owner must
5
+ // fold those in so that counts ACCUMULATE ACROSS SURFACES — web too_long ×11 +
6
+ // claude-code too_long ×2 must merge to ×13 — but reconcile's delta semantics
7
+ // are replace-by-key (a delta signal is a snapshot, mergeSignal replaces it
8
+ // wholesale). Feeding a surface's signals straight into reconcile would therefore
9
+ // CLOBBER the web-accumulated counts with the (smaller) surface counts.
10
+ //
11
+ // The fix is to combine BEFORE reconcile, from first-party inputs only:
12
+ // - the extension's OWN lift (from its raw phaedo_behavioral_signals counters —
13
+ // never from counts already stored in the fingerprint, which are themselves
14
+ // combined and would double-count on re-merge), plus
15
+ // - each capture source's latest snapshot (vault `phaedo_capture_sources`,
16
+ // replaced per source_device_id on drain — idempotent).
17
+ // The combined signal (summed observation_count, confidence recomputed from the
18
+ // total) then flows through the existing replace-by-key reconcile unchanged, so
19
+ // stored shape, rendering, and §4.7 resolution are untouched.
20
+ //
21
+ // Every delta-writing merge path MUST pass captureSources (background.js does,
22
+ // for both the extract path and the drain path) — a path that skips it would
23
+ // replace the combined count with an own-only count on its next write.
24
+
25
+ import { countConfidence } from './delta-lift.js';
26
+ import { buildFingerprintUpdate } from './fingerprint-update.js';
27
+ import { valKey } from './resolve.js';
28
+
29
+ // Identity of one underlying counter across surfaces. Same counter name → same
30
+ // (layer, field, polarity, value[, domain]) via SIGNAL_MAP on every surface, so
31
+ // value is part of the key (too_long/'short' vs too_short/'thorough' share
32
+ // field+polarity but are different counters and must NOT merge).
33
+ const combineKey = (s) =>
34
+ `${s.layer}::${s.field}::${s.polarity}::${valKey(s.value)}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
35
+
36
+ // The dimension key WITHOUT the value — this is exactly reconcile.js sigKey. Two delta
37
+ // signals that share it but differ in value (too_long/'short' vs too_short/'thorough',
38
+ // same field+positive polarity) are OPPOSING counters that reconcile would collapse
39
+ // (mergeSignal's delta branch replaces wholesale on key match), keeping whichever is
40
+ // enumerated last. See the majority-resolution pass in combineDeltaSignals.
41
+ const dimKey = (s) =>
42
+ `${s.layer}::${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
43
+
44
+ // Combine the extension's own lifted delta signals with capture-source snapshots.
45
+ // Returns one delta signal per RECONCILE DIMENSION (field, polarity[, domain]) with
46
+ // observation_count = Σ of that value's counters across surfaces, and — when opposing
47
+ // values collide on a dimension — only the MAJORITY value survives. Non-delta signals
48
+ // must not be passed here (callers strip; applyCaptureSources below does it).
49
+ export function combineDeltaSignals(ownSignals, sources, now) {
50
+ // Pass 1: sum each distinct counter (value included) across surfaces.
51
+ const byKey = new Map();
52
+ const fold = (sig) => {
53
+ if (!sig || sig.source !== 'delta') return;
54
+ const k = combineKey(sig);
55
+ const prev = byKey.get(k);
56
+ if (!prev) { byKey.set(k, { ...sig, session_refs: [] }); return; }
57
+ const total = (prev.observation_count || 0) + (sig.observation_count || 0);
58
+ byKey.set(k, {
59
+ ...prev,
60
+ observation_count: total,
61
+ confidence: countConfidence(total),
62
+ last_observed: [prev.last_observed, sig.last_observed].filter(Boolean).sort().pop() || now,
63
+ });
64
+ };
65
+ for (const sig of ownSignals || []) fold(sig);
66
+ for (const src of sources || []) for (const sig of (src && src.signals) || []) fold(sig);
67
+
68
+ // Pass 2: resolve opposing values on the same reconcile dimension to the MAJORITY
69
+ // (highest observation_count) — WITHOUT this, reconcile silently keeps the
70
+ // last-enumerated value (deterministically the capture source's minority signal),
71
+ // inverting the fingerprint (web too_long×11 'short' lost to hook too_short×2
72
+ // 'thorough'). Ties and array-valued fields (which reconcile UNIONS, not replaces —
73
+ // formatting_prefs etc.) keep every entry: a tie leaves no majority to assert, and
74
+ // union fields don't collide. Deterministic: higher count wins, then valKey order.
75
+ const byDim = new Map();
76
+ for (const s of byKey.values()) {
77
+ if (Array.isArray(s.value)) { byDim.set(combineKey(s), [s]); continue; } // union field — never collapse
78
+ const dk = dimKey(s);
79
+ const arr = byDim.get(dk) || [];
80
+ arr.push(s);
81
+ byDim.set(dk, arr);
82
+ }
83
+ const out = [];
84
+ for (const arr of byDim.values()) {
85
+ if (arr.length === 1) { out.push(arr[0]); continue; }
86
+ const scalars = arr.filter((s) => !Array.isArray(s.value));
87
+ if (scalars.length <= 1) { out.push(...arr); continue; }
88
+ const max = Math.max(...scalars.map((s) => s.observation_count || 0));
89
+ const top = scalars.filter((s) => (s.observation_count || 0) === max);
90
+ if (top.length > 1) { out.push(...arr); continue; } // genuine tie → let reconcile keep both (no false majority)
91
+ out.push(top[0]); // majority wins; minority opposing counters dropped from the update
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // ── Per-source snapshot validity + merge (shared by every owner tier) ────────
97
+ // A stored snapshot is { surface, captured_at, signals[] } keyed by
98
+ // source_device_id. Deposits carry the surface's CUMULATIVE counts, so between
99
+ // two snapshots of the same source the newer captured_at strictly supersedes the
100
+ // older — replace, never sum. Mirrored on the phone in
101
+ // mobile/src/lib/captureSources.js (keep in lockstep — the phone can't import
102
+ // this ES module through Metro).
103
+
104
+ // A snapshot is usable only if it carries at least one source='delta' signal —
105
+ // the only kind the combine folds (a future extraction-backend deposit is
106
+ // rejected at the drain, background.js mergeCaptureDelta).
107
+ export function isValidSnapshot(snap) {
108
+ return !!(snap && typeof snap === 'object' && Array.isArray(snap.signals)
109
+ && snap.signals.some((s) => s && s.source === 'delta'));
110
+ }
111
+
112
+ // Merge an incoming per-source snapshot map (e.g. forwarded by the phone owner in
113
+ // its to_ext deposit) into the locally stored one. Per source: missing → take;
114
+ // newer captured_at → replace; older/equal-but-different → keep local (cumulative
115
+ // counts only grow, so the newer stamp is always the superset). Returns
116
+ // { changed, sources } without mutating either input.
117
+ export function mergeSourceSnapshots(prior, incoming) {
118
+ const out = { ...(prior || {}) };
119
+ let changed = false;
120
+ for (const [sourceId, snap] of Object.entries(incoming || {})) {
121
+ if (!sourceId || !isValidSnapshot(snap)) continue;
122
+ const cur = out[sourceId];
123
+ if (cur && JSON.stringify(cur) === JSON.stringify(snap)) continue; // idempotent re-forward
124
+ const curAt = cur && cur.captured_at ? String(cur.captured_at) : '';
125
+ const inAt = snap.captured_at ? String(snap.captured_at) : '';
126
+ if (cur && curAt >= inAt) continue; // stale forward — keep the newer local copy
127
+ out[sourceId] = snap;
128
+ changed = true;
129
+ }
130
+ return { changed, sources: out };
131
+ }
132
+
133
+ // Rewrite an extraction `update` so its delta signals become the cross-surface
134
+ // combined set. Pure: strips source='delta' signals out of each layer doc, combines
135
+ // them with the capture sources, and folds the combined set back in (as layer docs
136
+ // via the same builder the lift path uses). Extraction (non-delta) signals pass
137
+ // through untouched. reconcileLayer recomputes per-layer confidence/summary at
138
+ // merge time, so concatenating signals into the layer doc is sufficient here.
139
+ export function applyCaptureSources(update, sources, now) {
140
+ const own = [];
141
+ const stripped = {};
142
+ for (const [layerId, doc] of Object.entries(update || {})) {
143
+ const signals = Array.isArray(doc.signals) ? doc.signals : [];
144
+ // Signals embedded in a layer doc have no `layer` field (the doc key IS the
145
+ // layer) — re-attach it, or the combine key would never match a source's.
146
+ own.push(...signals.filter((s) => s && s.source === 'delta').map((s) => ({ ...s, layer: s.layer || layerId })));
147
+ stripped[layerId] = { ...doc, signals: signals.filter((s) => !s || s.source !== 'delta') };
148
+ }
149
+ const combined = combineDeltaSignals(own, sources, now);
150
+ if (!combined.length) return stripped;
151
+ const deltaUpdate = buildFingerprintUpdate(combined, { now });
152
+ const out = { ...stripped };
153
+ for (const [layerId, doc] of Object.entries(deltaUpdate)) {
154
+ const base = out[layerId];
155
+ out[layerId] = base
156
+ ? { ...base, signals: [...(base.signals || []), ...(doc.signals || [])] }
157
+ : doc;
158
+ }
159
+ return out;
160
+ }