phaedo-mcp 0.3.0 → 0.3.2

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.
@@ -0,0 +1,402 @@
1
+ // Phaedo — per-source signal ledgers: learning from several browsers adds up (multi-browser, 2026-09-24).
2
+ //
3
+ // THE PROBLEM. A learned (extraction) signal accumulates INSIDE the fingerprint: every pass adds
4
+ // to `observation_count` and pushes `confidence` up by noisy-OR (extraction/reconcile.js). There is
5
+ // no raw store behind it — the fingerprint is the only accumulator. Sync then merged the inferred
6
+ // layers LAST-WRITE-WINS, whole: with one browser that was fine, with two it meant whichever browser
7
+ // extracted last replaced every layer, and the other browser's learning vanished.
8
+ //
9
+ // Neither obvious fix works. SUM double-counts the history both copies share (a work browser that
10
+ // pulled the personal fingerprint and learned 3 more observations would count the first 5 twice).
11
+ // MAX loses one side's new observations.
12
+ //
13
+ // THE FIX: each signal carries a small ledger saying where its evidence came from.
14
+ //
15
+ // signal.ledger = {
16
+ // legacy: { observation_count, confidence, value, last_observed } | null,
17
+ // by_source: { <source_id>: { observation_count, confidence, value, last_observed, context? } },
18
+ // }
19
+ //
20
+ // - A browser only ever ADDS to its own `by_source` entry, so each entry only grows. Merging two
21
+ // copies keeps, per source, the entry with more observations — a grow-only counter per source,
22
+ // which is commutative, idempotent and order-free: every copy converges to the same ledger no
23
+ // matter how many times, or in what order, deposits cross.
24
+ // - `legacy` is everything a signal held before it had a ledger — history the copies SHARE. Two
25
+ // copies' legacy entries are reconciled by max, never summed.
26
+ // - The signal's own `observation_count` / `confidence` / `value` / `last_observed` are DERIVED
27
+ // from the ledger (aggregate), so every reader that ignores the ledger — injection, consult, the
28
+ // phone, the MCP — sees correct totals without changing.
29
+ //
30
+ // `context` ('personal' | 'work') tags which side of the subject's life a browser serves, chosen
31
+ // on the phone when it paired. Recorded now so a later context-aware projection has the data; it
32
+ // does not change any total.
33
+ //
34
+ // Only EXTRACTION evidence is ledgered. `source: 'delta'` signals are snapshots rebuilt from raw
35
+ // counters every pass, and cross-browser they combine as capture sources (capture-combine.js);
36
+ // `questionnaire` self-report is identical on every copy. Both keep last-write-wins, as before.
37
+ //
38
+ // Pure + dependency-free. UMD: a classic script in popup.html (globalThis.PhaedoSignalLedger),
39
+ // a side-effect import in extraction/reconcile.js and background.js, and `require` in Node.
40
+ // MIRRORED by mobile/src/lib/signalLedger.js (Metro cannot import this file); the parity test
41
+ // scripts/test_signal_ledger_parity.mjs RUNS both over the same inputs, so drift fails CI.
42
+
43
+ (function (factory) {
44
+ const api = factory();
45
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
46
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoSignalLedger = api;
47
+ })(function () {
48
+ 'use strict';
49
+
50
+ const CONFIDENCE_CAP = 0.95;
51
+ const round3 = (n) => Number((n || 0).toFixed(3));
52
+
53
+ // Noisy-OR, capped — the same curve extraction/reconcile.js combineConfidence uses.
54
+ function combineConfidence(a, b) {
55
+ return Math.min(CONFIDENCE_CAP, 1 - (1 - (a || 0)) * (1 - (b || 0)));
56
+ }
57
+
58
+ function unionArrays(a, b) {
59
+ return Array.from(new Set([...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]));
60
+ }
61
+
62
+ function maxIso(a, b) {
63
+ if (!a) return b || null;
64
+ if (!b) return a;
65
+ return a > b ? a : b;
66
+ }
67
+
68
+ // Stable serialization (sorted keys) — the deterministic last-resort tie-break.
69
+ function stable(v) {
70
+ if (Array.isArray(v)) return '[' + v.map(stable).join(',') + ']';
71
+ if (v && typeof v === 'object') {
72
+ return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stable(v[k])).join(',') + '}';
73
+ }
74
+ return JSON.stringify(v === undefined ? null : v);
75
+ }
76
+
77
+ /** Same dedupe key as extraction/reconcile.js sigKey. */
78
+ function sigKey(s) {
79
+ return `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
80
+ }
81
+
82
+ /** Is this signal the kind whose evidence is ledgered? */
83
+ function isLedgered(s) {
84
+ return !!s && s.source !== 'delta' && s.source !== 'questionnaire';
85
+ }
86
+
87
+ function entryOf(s) {
88
+ const e = {
89
+ observation_count: s.observation_count || 1,
90
+ confidence: round3(s.confidence),
91
+ value: s.value,
92
+ last_observed: s.last_observed || null,
93
+ };
94
+ if (s.context) e.context = s.context;
95
+ return e;
96
+ }
97
+
98
+ // A scalar entry's per-value observation counts, `{ stable(value): n }`. Entries written before
99
+ // this existed (and `legacy`, which snapshots a whole signal) count wholly toward their one value.
100
+ function valueCountsOf(e) {
101
+ if (!e || Array.isArray(e.value)) return {};
102
+ if (e.value_counts && typeof e.value_counts === 'object') return e.value_counts;
103
+ return { [stable(e.value)]: e.observation_count || 1 };
104
+ }
105
+
106
+ // Add one increment to a source's entry: counts sum, confidence noisy-ORs, arrays union.
107
+ // A SCALAR keeps a per-value tally and shows the value with the most observations — a single
108
+ // current value, set by whichever observation first carried the most confidence, pinned the
109
+ // first answer seen: 1 "long" then 30 "short" still read "long" (review, 2026-09-30).
110
+ function accumulate(entry, inc, now) {
111
+ const n = inc.observation_count || 1;
112
+ if (!entry) {
113
+ const out = {
114
+ observation_count: n,
115
+ confidence: round3(inc.confidence),
116
+ value: inc.value,
117
+ last_observed: inc.last_observed || now || null,
118
+ };
119
+ if (!Array.isArray(inc.value)) out.value_counts = { [stable(inc.value)]: n };
120
+ return out;
121
+ }
122
+ let value = entry.value;
123
+ let value_counts = null;
124
+ if (Array.isArray(entry.value) && Array.isArray(inc.value)) {
125
+ value = unionArrays(entry.value, inc.value);
126
+ } else {
127
+ value_counts = { ...valueCountsOf(entry) };
128
+ const k = stable(inc.value);
129
+ value_counts[k] = (value_counts[k] || 0) + n;
130
+ // Most observations wins; on a tie the entry keeps what it showed unless the new observation
131
+ // is the better-supported one — the pre-tally rule, now only a tie-break.
132
+ let bestK = stable(entry.value), bestN = value_counts[bestK] || 0;
133
+ for (const [key, cnt] of Object.entries(value_counts)) {
134
+ if (cnt > bestN || (cnt === bestN && key === k && (inc.confidence || 0) > (entry.confidence || 0))) {
135
+ bestK = key; bestN = cnt;
136
+ }
137
+ }
138
+ value = bestK === k ? inc.value : (bestK === stable(entry.value) ? entry.value : JSON.parse(bestK));
139
+ }
140
+ const out = {
141
+ observation_count: (entry.observation_count || 1) + n,
142
+ confidence: round3(combineConfidence(entry.confidence, inc.confidence)),
143
+ value,
144
+ last_observed: maxIso(entry.last_observed, inc.last_observed) || now || null,
145
+ };
146
+ if (value_counts) out.value_counts = value_counts;
147
+ if (entry.context) out.context = entry.context;
148
+ return out;
149
+ }
150
+
151
+ // Two copies of ONE source's entry: the one with more observations is the newer (entries only
152
+ // grow). Ties fall to the later observation, then to a deterministic compare, so merge(a, b)
153
+ // and merge(b, a) pick the same entry.
154
+ function pickEntry(a, b) {
155
+ if (!a) return b || null;
156
+ if (!b) return a;
157
+ const ca = a.observation_count || 0, cb = b.observation_count || 0;
158
+ if (ca !== cb) return ca > cb ? a : b;
159
+ const la = a.last_observed || '', lb = b.last_observed || '';
160
+ if (la !== lb) return la > lb ? a : b;
161
+ return stable(a) >= stable(b) ? a : b;
162
+ }
163
+
164
+ /** The ledger's entries in a fixed order: legacy first, then sources sorted by id. */
165
+ function orderedEntries(ledger) {
166
+ const out = [];
167
+ if (ledger && ledger.legacy) out.push(ledger.legacy);
168
+ const bs = (ledger && ledger.by_source) || {};
169
+ for (const id of Object.keys(bs).sort()) if (bs[id]) out.push(bs[id]);
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Recompute a signal's visible totals from its ledger. Returns a new signal.
175
+ *
176
+ * A SCALAR value is the one with the most observations behind it, summed across every entry that
177
+ * holds it — not the highest-confidence entry. Confidence caps at 0.95 after a handful of
178
+ * observations, so "highest confidence" degenerated into "most recent to reach the cap": after 50
179
+ * observations of "short" and 4 of "long" a browser's own entry could pin "long" for good
180
+ * (review, 2026-09-30). Ties fall to confidence, then recency, then entry order (legacy first).
181
+ */
182
+ function aggregate(signal) {
183
+ const entries = orderedEntries(signal.ledger);
184
+ if (!entries.length) return signal;
185
+ let count = 0, conf = 0, last = null;
186
+ let arrayValue = null;
187
+ const tally = new Map(); // stable(value) → { value, n, conf, last, order }
188
+ entries.forEach((e, i) => {
189
+ count += e.observation_count || 0;
190
+ conf = combineConfidence(conf, e.confidence);
191
+ last = maxIso(last, e.last_observed);
192
+ if (Array.isArray(e.value)) { arrayValue = unionArrays(arrayValue, e.value); return; }
193
+ // Each entry's own per-value tally, so a browser that saw both values contributes both.
194
+ for (const [k, cnt] of Object.entries(valueCountsOf(e))) {
195
+ const t = tally.get(k) || { value: k === stable(e.value) ? e.value : JSON.parse(k), n: 0, conf: 0, last: '', order: i };
196
+ t.n += cnt || 0;
197
+ t.conf = Math.max(t.conf, e.confidence || 0);
198
+ t.last = (e.last_observed || '') > t.last ? (e.last_observed || '') : t.last;
199
+ tally.set(k, t);
200
+ }
201
+ });
202
+ let best = null;
203
+ for (const t of tally.values()) {
204
+ if (!best || t.n > best.n || (t.n === best.n && (t.conf > best.conf ||
205
+ (t.conf === best.conf && (t.last > best.last || (t.last === best.last && t.order < best.order)))))) best = t;
206
+ }
207
+ const out = { ...signal, observation_count: count, confidence: round3(conf), value: arrayValue || (best ? best.value : signal.value) };
208
+ if (last) out.last_observed = last;
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Evidence a build WITHOUT ledgers added to a ledgered signal. Such a build (a browser profile
214
+ * not yet reloaded) accumulates the visible totals and carries the ledger through untouched, so
215
+ * the next aggregate anywhere would recompute from the ledger and silently drop those
216
+ * observations (review, 2026-09-30). The excess of the visible count over the ledger's sum is
217
+ * that unattributed evidence; it is folded into `legacy`, whose copies merge by max — so the same
218
+ * excess on two copies still counts once.
219
+ */
220
+ function normalize(signal) {
221
+ if (!signal || !signal.ledger) return signal;
222
+ const sum = orderedEntries(signal.ledger).reduce((a, e) => a + (e.observation_count || 0), 0);
223
+ const excess = (signal.observation_count || 0) - sum;
224
+ if (excess <= 0) return signal;
225
+ const prev = signal.ledger.legacy;
226
+ const legacy = prev
227
+ ? { ...prev, observation_count: (prev.observation_count || 0) + excess,
228
+ confidence: round3(Math.max(prev.confidence || 0, signal.confidence || 0)) }
229
+ : { observation_count: excess, confidence: round3(signal.confidence), value: signal.value, last_observed: signal.last_observed || null };
230
+ return { ...signal, ledger: { ...signal.ledger, legacy } };
231
+ }
232
+
233
+ /**
234
+ * Record one extraction increment against `sourceId`. `existing` may be null (a new signal) or a
235
+ * signal without a ledger yet, whose whole current evidence becomes `legacy`. Returns the new
236
+ * signal with its totals re-derived.
237
+ */
238
+ function attribute(existing, incoming, sourceId, opts) {
239
+ const now = (opts && opts.now) || null;
240
+ const context = opts && opts.context;
241
+ existing = normalize(existing);
242
+ const base = existing ? { ...existing } : { ...incoming };
243
+ const ledger = existing && existing.ledger
244
+ ? { legacy: existing.ledger.legacy || null, by_source: { ...(existing.ledger.by_source || {}) } }
245
+ : { legacy: existing ? entryOf(existing) : null, by_source: {} };
246
+ const entry = accumulate(ledger.by_source[sourceId] || null, incoming, now);
247
+ if (context) entry.context = context;
248
+ ledger.by_source[sourceId] = entry;
249
+ if (existing) {
250
+ base.source = incoming.source || existing.source;
251
+ base.session_refs = unionArrays(existing.session_refs, incoming.session_refs);
252
+ }
253
+ base.ledger = ledger;
254
+ return aggregate(base);
255
+ }
256
+
257
+ /**
258
+ * Merge two copies of the same signal (same sigKey). `base` supplies every non-ledger field.
259
+ * A copy without a ledger contributes its totals as `legacy`. If NEITHER copy has a ledger, or the
260
+ * signal is not ledgered, `base` wins unchanged — exactly the behaviour before ledgers existed.
261
+ */
262
+ function mergeSignalCopies(base, other) {
263
+ if (!other) return base;
264
+ if (!base) return other;
265
+ // A delta snapshot is rebuilt from raw counters and REPLACES whatever held its key
266
+ // (reconcile.js mergeSignal) — nothing to add from either side.
267
+ if (base.source === 'delta' || other.source === 'delta') return base;
268
+ // A self-report that the OTHER copy has since seen corroborated: that copy carries the
269
+ // self-report as its `legacy` entry plus the observations on top, so it is the fuller one.
270
+ if (!isLedgered(base)) return (isLedgered(other) && other.ledger) ? other : base;
271
+ if (!isLedgered(other)) return base;
272
+ if (!base.ledger && !other.ledger) return base;
273
+ base = normalize(base);
274
+ other = normalize(other);
275
+ const lb = base.ledger || { legacy: entryOf(base), by_source: {} };
276
+ const lo = other.ledger || { legacy: entryOf(other), by_source: {} };
277
+ const by_source = {};
278
+ for (const id of new Set([...Object.keys(lb.by_source || {}), ...Object.keys(lo.by_source || {})])) {
279
+ const e = pickEntry((lb.by_source || {})[id] || null, (lo.by_source || {})[id] || null);
280
+ if (e) by_source[id] = e;
281
+ }
282
+ const merged = {
283
+ ...base,
284
+ session_refs: unionArrays(base.session_refs, other.session_refs),
285
+ ledger: { legacy: pickEntry(lb.legacy || null, lo.legacy || null), by_source },
286
+ };
287
+ return aggregate(merged);
288
+ }
289
+
290
+ /** Average signal confidence — reconcile.js reconcileLayer's layer-level `confidence`. */
291
+ function layerConfidence(signals) {
292
+ return round3(signals.reduce((a, s) => a + ((s && s.confidence) || 0), 0) / (signals.length || 1));
293
+ }
294
+
295
+ /**
296
+ * Merge two copies of a layer. `base` keeps every non-signal field (questionnaire responses,
297
+ * label, summary). Signals union by key; a signal on both sides merges through its ledger. Returns
298
+ * `base` itself when nothing changes, so a no-op merge never reads as a write.
299
+ */
300
+ function mergeLayer(base, other) {
301
+ if (!other || !Array.isArray(other.signals) || !other.signals.length) return base;
302
+ if (!base) {
303
+ // A layer only the other copy has: only its ATTRIBUTED evidence crosses, never its
304
+ // non-signal fields (questionnaire responses, label) — last-write-wins decides those, as it
305
+ // always has.
306
+ const carried = other.signals.filter((o) => isLedgered(o) && o.ledger);
307
+ if (!carried.length) return base;
308
+ return { layer_version: other.layer_version || '0.1', signals: carried, confidence: layerConfidence(carried) };
309
+ }
310
+ const bs = Array.isArray(base.signals) ? base.signals : [];
311
+ const byKey = new Map(bs.map((s) => [sigKey(s), s]));
312
+ let changed = false;
313
+ for (const o of other.signals) {
314
+ if (!o) continue;
315
+ const k = sigKey(o);
316
+ const b = byKey.get(k);
317
+ const m = b ? mergeSignalCopies(b, o) : (isLedgered(o) && o.ledger ? o : null);
318
+ // A signal only the OTHER copy has joins when it carries a ledger — i.e. it came from a
319
+ // build that attributes evidence. A ledger-less signal there is the same pre-ledger state
320
+ // last-write-wins always treated as the loser's, and it stays that way.
321
+ if (!m || m === b) continue;
322
+ if (b && stable(m) === stable(b)) continue;
323
+ byKey.set(k, m);
324
+ changed = true;
325
+ }
326
+ if (!changed) return base;
327
+ const signals = Array.from(byKey.values());
328
+ return { ...base, signals, confidence: layerConfidence(signals) };
329
+ }
330
+
331
+ /** Merge two `layers` maps. Returns `base` itself when nothing changes. */
332
+ function mergeLayers(base, other) {
333
+ if (!other || typeof other !== 'object') return base;
334
+ if (!base || typeof base !== 'object') return other;
335
+ let out = null;
336
+ for (const id of Object.keys(other)) {
337
+ const m = mergeLayer(base[id], other[id]);
338
+ if (m !== base[id]) { out = out || { ...base }; out[id] = m; }
339
+ }
340
+ return out || base;
341
+ }
342
+
343
+ /**
344
+ * Take each layer's `summary` from `from` where the two copies hold the SAME signals.
345
+ *
346
+ * A summary is derived from signals — only the extension renders it (reconcile.js), and it
347
+ * re-renders after a sync merge. So where the signals agree, the rendered copy is the right one
348
+ * and a stale copy is just stale. Without this the copies never converged: the phone kept the
349
+ * summary of an older merge, every popup open saw "a change" in it, wrote, re-rendered and wrote
350
+ * again — the flash loop, and the live sync behind it never ran (review, 2026-09-30).
351
+ * extension merge: from = its own layers (it rendered them);
352
+ * phone merge: from = the browser's deposit (the phone never renders).
353
+ * Returns `layers` itself when nothing is adopted.
354
+ */
355
+ function adoptSummaries(layers, from) {
356
+ if (!layers || !from || typeof layers !== 'object' || typeof from !== 'object') return layers;
357
+ let out = null;
358
+ for (const id of Object.keys(layers)) {
359
+ const a = layers[id], b = from[id];
360
+ if (!a || !b || typeof b.summary !== 'string' || a.summary === b.summary) continue;
361
+ if (stable(a.signals || []) !== stable(b.signals || [])) continue;
362
+ out = out || { ...layers };
363
+ out[id] = { ...a, summary: b.summary };
364
+ }
365
+ return out || layers;
366
+ }
367
+
368
+ /**
369
+ * Were two fingerprints' summaries rendered under the same RULES? A summary withholds dimensions
370
+ * that an authored rule governs or an open conflict contests (reconcile.js). Identical signals
371
+ * therefore render DIFFERENT summaries when the rules differ, and adopting one rendered without a
372
+ * rule the other copy holds would leak the dimension that rule governs (review, 2026-09-30). The
373
+ * callers adopt summaries only when this holds.
374
+ */
375
+ function summaryContextMatches(a, b) {
376
+ if (!a || !b) return false;
377
+ return stable(a.standing_rules || []) === stable(b.standing_rules || []) &&
378
+ stable(a.conflict_records || []) === stable(b.conflict_records || []);
379
+ }
380
+
381
+ /** Every source id that has contributed evidence anywhere in a fingerprint, with its context. */
382
+ function contributingSources(fp) {
383
+ const out = {};
384
+ const layers = (fp && fp.layers) || {};
385
+ for (const id of Object.keys(layers)) {
386
+ for (const s of (layers[id] && layers[id].signals) || []) {
387
+ const bs = s && s.ledger && s.ledger.by_source;
388
+ if (!bs) continue;
389
+ for (const src of Object.keys(bs)) {
390
+ if (!out[src]) out[src] = { context: null };
391
+ if (bs[src] && bs[src].context) out[src].context = bs[src].context;
392
+ }
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+
398
+ return {
399
+ CONFIDENCE_CAP, sigKey, isLedgered, attribute, aggregate, normalize, mergeSignalCopies,
400
+ mergeLayer, mergeLayers, adoptSummaries, summaryContextMatches, contributingSources, stable,
401
+ };
402
+ });