phaedo-mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,9 @@ Implements `docs/protocol/mcp-binding.md` §3.1:
14
14
  | prompt | `phaedo` | one-click `/phaedo` — inserts the projection as a message |
15
15
  | tool | `phaedo_consult` | §10.2 request → §10.3 Consultation **signal** (never layer content) |
16
16
  | tool | `phaedo_check_authorization` | deterministic-only pre-flight: does a standing authorization (§10.5) already decide this action? (no inference) |
17
+ | tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
18
+ | tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
19
+ | tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |
17
20
 
18
21
  The projection is rendered by the **same** `context-block.js` the extension
19
22
  injects, so MCP injection is byte-identical to web injection.
@@ -198,7 +201,7 @@ bears on a decision** — without ingesting the fingerprint. `phaedo_consult` ta
198
201
  ```
199
202
 
200
203
  - `consultation_type`: `action_approval` · `domain_risk_check` · `escalation_default` · `voice_draft`.
201
- - `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — the fingerprint has no coverage for this action; fall back to your own default).
204
+ - `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — either the fingerprint has no coverage for this action, or the relevant decision dimension is under an open §4.7 conflict the subject is still reconciling and is withheld; fall back to your own default or escalate to the subject).
202
205
  - `deference_level` (`high`/`medium`/`low`) = how strongly to weight it; sparse data → `low`.
203
206
 
204
207
  ### Fast pre-flight (`phaedo_check_authorization`, §10.5)
package/calibration.js ADDED
@@ -0,0 +1,107 @@
1
+ // Phaedo — calibration reliability curve (build-now tier, slice 2).
2
+ //
3
+ // agreement.js answers "how often was the oracle right, per domain." This answers a
4
+ // DIFFERENT and (per the design thread) more important question: is the oracle's
5
+ // CONFIDENCE honest? When it says 0.8, does that decision actually hold ~80% of the
6
+ // time? A well-calibrated 0.6 is worth more to an agent deciding act-vs-escalate than
7
+ // an overconfident 0.9. So we bucket decisive consult predictions by confidence band
8
+ // and compare the band's MEAN PREDICTED confidence to its REALIZED agreement rate.
9
+ //
10
+ // PURE: (receipts, opts) -> report. No I/O, no globals — same discipline as
11
+ // agreement.js / consult-core.js. Reuses agreement.js's scoring contract (DECISIVE
12
+ // signals + agrees()) so "right" means exactly what it means there.
13
+ //
14
+ // DELIBERATELY NOT WIRED to anything. This is a READOUT (`npm run calibration`).
15
+ // Recalibrating confidence or auto-adjusting deference from this curve is a product
16
+ // decision for a later session (closing the loop), not an engineering default.
17
+ //
18
+ // Metric: for each scored receipt (decisive signal + known user_action, in window),
19
+ // bucket by response.confidence into equal-width bins. Per bin:
20
+ // predicted_mean = mean(confidence), realized_rate = agreements / scored,
21
+ // gap = predicted_mean - realized_rate (positive = OVERconfident).
22
+ // Expected Calibration Error (ECE) = sum over bins of (bin_scored/total_scored)·|gap|.
23
+ // A low ECE means the numbers can be trusted as probabilities.
24
+
25
+ import { DECISIVE, DEFAULT_WINDOW_DAYS } from './agreement.js';
26
+
27
+ function agrees(signal, userAction) {
28
+ if (userAction === 'approved') return signal === 'proceed' || signal === 'proceed_with_note';
29
+ if (userAction === 'rejected') return signal === 'decline';
30
+ return false; // 'modified' — not accepted as-judged
31
+ }
32
+
33
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
34
+
35
+ // (receipts, { windowDays, bins, now }) -> {
36
+ // window: { days, since, now, total_receipts, in_window },
37
+ // scored, ece,
38
+ // bins: [ { lo, hi, scored, agreements, predicted_mean, realized_rate, gap } ],
39
+ // }
40
+ // ece / a bin's realized_rate are null when nothing was scored (no data ≠ 0%).
41
+ export function computeCalibration(receipts, { windowDays = DEFAULT_WINDOW_DAYS, bins = 5, now = Date.now() } = {}) {
42
+ const nBins = Math.max(1, Math.floor(bins));
43
+ const since = now - windowDays * 24 * 60 * 60 * 1000;
44
+ const slot = (conf) => Math.min(nBins - 1, Math.floor(clamp01(conf) * nBins)); // 1.0 → last bin
45
+ const table = Array.from({ length: nBins }, (_, i) => ({
46
+ lo: Math.round((i / nBins) * 100) / 100,
47
+ hi: Math.round(((i + 1) / nBins) * 100) / 100,
48
+ scored: 0, agreements: 0, _confSum: 0, predicted_mean: null, realized_rate: null, gap: null,
49
+ }));
50
+ let inWindow = 0, totalScored = 0;
51
+
52
+ for (const r of Array.isArray(receipts) ? receipts : []) {
53
+ const t = Date.parse(r?.created_at);
54
+ if (!Number.isFinite(t) || t < since || t > now) continue;
55
+ inWindow++;
56
+ const signal = r.response?.signal;
57
+ const action = r.user_action;
58
+ const conf = r.response?.confidence;
59
+ if (!DECISIVE.includes(signal)) continue; // non-directional: no prediction to calibrate
60
+ if (action == null || action === 'unknown') continue; // not yet scorable
61
+ if (typeof conf !== 'number' || !Number.isFinite(conf)) continue;
62
+ const b = table[slot(conf)];
63
+ b.scored++; b._confSum += clamp01(conf);
64
+ if (agrees(signal, action)) b.agreements++;
65
+ totalScored++;
66
+ }
67
+
68
+ let eceSum = 0;
69
+ for (const b of table) {
70
+ if (b.scored > 0) {
71
+ b.predicted_mean = Math.round((b._confSum / b.scored) * 1000) / 1000;
72
+ b.realized_rate = Math.round((b.agreements / b.scored) * 1000) / 1000;
73
+ b.gap = Math.round((b.predicted_mean - b.realized_rate) * 1000) / 1000;
74
+ eceSum += (b.scored / totalScored) * Math.abs(b.predicted_mean - b.realized_rate);
75
+ }
76
+ delete b._confSum;
77
+ }
78
+
79
+ return {
80
+ window: { days: windowDays, since: new Date(since).toISOString(), now: new Date(now).toISOString(), total_receipts: Array.isArray(receipts) ? receipts.length : 0, in_window: inWindow },
81
+ scored: totalScored,
82
+ ece: totalScored > 0 ? Math.round(eceSum * 1000) / 1000 : null,
83
+ bins: table,
84
+ };
85
+ }
86
+
87
+ // ── Dev CLI: `node calibration.js [windowDays] [bins]` / `npm run calibration` ──
88
+ // Local readout only — decrypts the local receipt store with the local key; nothing
89
+ // leaves the device. NOT product UX, NOT fed back into confidence/deference.
90
+ if (process.argv[1] && process.argv[1].endsWith('calibration.js')) {
91
+ const days = Number(process.argv[2]) || DEFAULT_WINDOW_DAYS;
92
+ const nBins = Number(process.argv[3]) || 5;
93
+ Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
94
+ const report = computeCalibration(await readReceipts(defaultStateDir()), { windowDays: days, bins: nBins });
95
+ const { window: w } = report;
96
+ console.log(`Calibration over the last ${w.days}d — ${report.scored} scored of ${w.in_window}/${w.total_receipts} receipts in window`);
97
+ console.log(`ECE (expected calibration error): ${report.ece === null ? ' — ' : report.ece.toFixed(3)} (lower = confidence is honest)\n`);
98
+ console.log('band scored predicted realized gap');
99
+ for (const b of report.bins) {
100
+ const f = (x) => x === null ? ' — ' : (x * 100).toFixed(0).padStart(4) + '%';
101
+ const gap = b.gap === null ? ' — ' : (b.gap > 0 ? '+' : '') + (b.gap * 100).toFixed(0) + '%';
102
+ console.log(`${(b.lo.toFixed(1) + '–' + b.hi.toFixed(1)).padEnd(12)} ${String(b.scored).padStart(5)} ${f(b.predicted_mean)} ${f(b.realized_rate)} ${gap.padStart(6)}`);
103
+ }
104
+ if (report.scored === 0) console.log('\nNo scored decisive receipts yet — set user_action as you accept/reject agent actions.');
105
+ else console.log('\npositive gap = overconfident (predicted > realized); negative = underconfident.');
106
+ }).catch((e) => { console.error(`calibration: ${e.message}`); process.exit(1); });
107
+ }