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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +50 -6
- package/consult-policy.js +427 -33
- package/consult.js +8 -1
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +25 -5
- package/phone-source.js +79 -11
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +33 -2
- package/server.js +305 -15
- package/server.json +3 -3
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
package/vendor/context-block.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/vendor/envelope.js
CHANGED
|
@@ -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 (
|
|
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 =
|
|
42
|
-
|
|
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
|
+
}
|