phaedo-mcp 0.2.1 → 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 (62) 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 +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  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 +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
@@ -0,0 +1,197 @@
1
+ // §10.6 receipts audit channel — MCP ↔ phone transport (G1+G2).
2
+ //
3
+ // Closes the act-as-me learning loop: today receipts written by `phaedo_consult` /
4
+ // `phaedo_escalate` sit at `user_action: null` until the subject runs `node receipts.js
5
+ // mark` — a dev CLI only. This module puts the audit on the subject's PHONE so they can
6
+ // mark outcomes in the flow, which then feeds delegation learning (consult-core.js
7
+ // `deriveDelegationSignals`).
8
+ //
9
+ // Two directions over the existing fp-mailbox transport (relay/mailbox.js):
10
+ //
11
+ // agent_to_phone — MCP deposits a digest of the latest UNMARKED receipts (a single
12
+ // payload with at most N entries, last-write-wins idempotent: the
13
+ // phone always sees the latest snapshot). §10.4-safe: only the
14
+ // fields the phone needs to render — receipt_id, created_at, via,
15
+ // agent_id, action_descriptor, response.signal — never `drivers`
16
+ // (local provenance) and never layer content.
17
+ // phone_to_agent — phone deposits a BATCH of `{ receipt_id, outcome }`; this module
18
+ // drains and calls `markOutcome` per mark. Idempotent on the MCP
19
+ // side (re-marking the same outcome is a no-op), so re-draining is
20
+ // safe and the phone need not track per-mark ACKs — the next
21
+ // receipts digest reflects applied marks (`user_action` set).
22
+ //
23
+ // Crypto: fp_sync_key derived from this server's pair_key. Drained by the PHONE, whose
24
+ // multi-client loop tries each verified client's key — so independent pairing is fine
25
+ // here (unlike agent_to_ext, see delegation-sync.js). The relay only ever sees
26
+ // ciphertext. Best-effort and isolated:
27
+ // a deposit/drain failure must NEVER break the tool that triggered it.
28
+
29
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
30
+ import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
31
+ import { agreementDigest } from './agreement.js';
32
+
33
+ const { Kdf, Envelope } = Phaedo;
34
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
35
+ const DIGEST_CAP = 50; // phone shows recent N — enough for the latest sittings, bounded payload
36
+
37
+ export function relayBaseFromEnv(env = process.env) {
38
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
39
+ }
40
+
41
+ // Strip a receipt down to the §10.4-safe fields the phone renders. NEVER includes
42
+ // `drivers` (local provenance cues that would expose Decision&Risk reasoning to the
43
+ // subject device — fine on this device, but the audit UI doesn't need them and not
44
+ // shipping them keeps the digest small + the boundary tight).
45
+ function digestFields(r) {
46
+ if (!r || typeof r !== 'object') return null;
47
+ return {
48
+ receipt_id: r.receipt_id,
49
+ created_at: r.created_at,
50
+ via: r.via,
51
+ agent_id: r.agent_id || null,
52
+ // The paired client that actually SERVED the call, beside the vendor string it
53
+ // claimed. §10.4-safe (an opaque id the subject's own phone issued, not persona
54
+ // content) and the phone is the one surface that can render it usefully: it holds
55
+ // the client registry, so it can show the friendly NAME where the receipt holds
56
+ // only the id. Without it the Activity screen can only report what the agent said
57
+ // it was — which is precisely the claim §10.5 stopped trusting.
58
+ client_id: r.client_id || null,
59
+ request: {
60
+ consultation_type: r?.request?.consultation_type || null,
61
+ action_descriptor: {
62
+ domain: r?.request?.action_descriptor?.domain ?? null,
63
+ reversible: r?.request?.action_descriptor?.reversible ?? null,
64
+ magnitude: r?.request?.action_descriptor?.magnitude ?? null,
65
+ amount: r?.request?.action_descriptor?.amount ?? null,
66
+ summary: r?.request?.action_descriptor?.summary ?? null,
67
+ },
68
+ },
69
+ response: {
70
+ signal: r?.response?.signal ?? null,
71
+ deference_level: r?.response?.deference_level ?? null,
72
+ },
73
+ user_action: r.user_action ?? null, // already-marked entries stay visible (phone shows status)
74
+ };
75
+ }
76
+
77
+ // Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
78
+ // recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
79
+ // same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
80
+ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
81
+ const list = Array.isArray(receipts) ? receipts : [];
82
+ // Most-recent N (receipts are append-only, ordered by creation in the store).
83
+ const head = list.slice(-cap).map(digestFields).filter(Boolean);
84
+ // Agreement rides the SAME digest, but is computed over the WHOLE store, not the
85
+ // capped head — the rate is a 30-day property of the subject's history, and slicing
86
+ // it to the last 50 entries would silently redefine the metric.
87
+ const generatedAt = now();
88
+ const agreementAt = Date.parse(generatedAt);
89
+ const agreement = agreementDigest(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
90
+ const pairKey = b64uDec(pairKeyB64u);
91
+ let fpSyncKey;
92
+ try {
93
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
94
+ } finally {
95
+ pairKey.fill(0);
96
+ }
97
+ try {
98
+ const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, generated_at: generatedAt } };
99
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
100
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
101
+ plaintext.fill(0);
102
+ return { envelope };
103
+ } finally {
104
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
105
+ }
106
+ }
107
+
108
+ // Decrypt a phone-deposited marks payload. Returns `{ marks: [{receipt_id, outcome}, ...] }`
109
+ // or null on a malformed/wrong-key envelope. Pure (no I/O) so it's testable.
110
+ export async function parseMarksDeposit(pairKeyB64u, envelope) {
111
+ if (!envelope) return null;
112
+ const pairKey = b64uDec(pairKeyB64u);
113
+ let fpSyncKey;
114
+ try { fpSyncKey = await Kdf.deriveFpSyncKey(pairKey); }
115
+ finally { pairKey.fill(0); }
116
+ try {
117
+ const plaintext = await Envelope.unwrap(envelope, fpSyncKey);
118
+ let inner;
119
+ try { inner = JSON.parse(new TextDecoder().decode(plaintext)); }
120
+ finally { if (plaintext.fill) plaintext.fill(0); }
121
+ if (!inner || inner.kind !== 'receipt_marks' || !inner.data) return null;
122
+ const arr = Array.isArray(inner.data.marks) ? inner.data.marks : [];
123
+ return {
124
+ marks: arr
125
+ .map((m) => ({
126
+ receipt_id: typeof m?.receipt_id === 'string' ? m.receipt_id : null,
127
+ outcome: typeof m?.outcome === 'string' ? m.outcome : null,
128
+ }))
129
+ .filter((m) => m.receipt_id && USER_ACTIONS.includes(m.outcome)),
130
+ };
131
+ } catch {
132
+ return null;
133
+ } finally {
134
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
135
+ }
136
+ }
137
+
138
+ // Deposit a receipts digest to the relay's agent_to_phone slot. Best-effort; mirrors
139
+ // `depositDelegationSuggestions` exactly (same auth, same timeout, same isolation —
140
+ // a failure NEVER throws out into the consult/escalate path that called us).
141
+ export async function depositReceiptsDigest(pairing, receipts, deps = {}) {
142
+ const fetchFn = deps.fetch || globalThis.fetch;
143
+ const list = Array.isArray(receipts) ? receipts : [];
144
+ if (!list.length) return { deposited: false, reason: 'no-receipts' };
145
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
146
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
147
+ try {
148
+ const body = await buildReceiptsDeposit(pairing.pair_key, list, deps);
149
+ const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_phone`;
150
+ const authHeader = pairing.relay_device_secret
151
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
152
+ : {};
153
+ const res = await fetchFn(url, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json', ...authHeader },
156
+ body: JSON.stringify(body),
157
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
158
+ });
159
+ return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
160
+ } catch (e) {
161
+ return { deposited: false, reason: String(e && e.message || e) };
162
+ }
163
+ }
164
+
165
+ // Drain phone-deposited outcome marks from the phone_to_agent slot and apply each via
166
+ // markOutcome (idempotent). Returns `{ applied, skipped }`. Best-effort; a transport
167
+ // failure returns `{ applied: 0, skipped: 0, reason }` instead of throwing.
168
+ export async function drainAndApplyMarks(pairing, stateDir, deps = {}) {
169
+ const fetchFn = deps.fetch || globalThis.fetch;
170
+ if (!pairing || !pairing.pair_key) return { applied: 0, skipped: 0, reason: 'unpaired' };
171
+ if (!pairing.relay_device_id) return { applied: 0, skipped: 0, reason: 'no-relay-device-id' };
172
+ try {
173
+ const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/phone_to_agent`;
174
+ const authHeader = pairing.relay_device_secret
175
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
176
+ : {};
177
+ const res = await fetchFn(url, {
178
+ method: 'GET',
179
+ headers: authHeader,
180
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
181
+ });
182
+ if (!res || !res.ok) return { applied: 0, skipped: 0, status: res && res.status };
183
+ const body = await res.json().catch(() => null);
184
+ const parsed = await parseMarksDeposit(pairing.pair_key, body && body.envelope);
185
+ if (!parsed) return { applied: 0, skipped: 0, reason: 'no-marks' };
186
+ let applied = 0, skipped = 0;
187
+ for (const m of parsed.marks) {
188
+ try {
189
+ const r = await markOutcome(stateDir, m.receipt_id, m.outcome);
190
+ if (r.matched) applied++; else skipped++;
191
+ } catch { skipped++; }
192
+ }
193
+ return { applied, skipped, total: parsed.marks.length };
194
+ } catch (e) {
195
+ return { applied: 0, skipped: 0, reason: String(e && e.message || e) };
196
+ }
197
+ }
package/receipts.js CHANGED
@@ -21,11 +21,13 @@
21
21
  // resolver core stays pure (no I/O). Receipt-write failure must never break the
22
22
  // consultation itself (the server warns on stderr and still answers).
23
23
 
24
- import { mkdir, readFile, writeFile, chmod } from 'fs/promises';
24
+ import { mkdir, readFile, writeFile, chmod, rename } from 'fs/promises';
25
25
  import { existsSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
28
+ import { canonicalDomain } from './lib/domain.js';
28
29
  import { getOrCreateCacheKey, defaultStateDir } from './cache.js';
30
+ import { normalizeActionBinding } from './action-binding.js';
29
31
 
30
32
  const { Envelope } = Phaedo;
31
33
 
@@ -78,8 +80,26 @@ async function writeStore(stateDir, store, now) {
78
80
  });
79
81
  plaintext.fill(0);
80
82
  const p = storePath(stateDir);
81
- await writeFile(p, JSON.stringify({ envelope }), { mode: 0o600 });
82
- await chmod(p, 0o600);
83
+ // Atomic replace: write a temp file then rename over the live store. Receipts are
84
+ // the ONLY copy (readStore throws on a corrupt store) — a crash mid-write must not
85
+ // truncate/corrupt receipts.enc and brick the audit trail + every markOutcome.
86
+ const tmp = `${p}.tmp`;
87
+ await writeFile(tmp, JSON.stringify({ envelope }), { mode: 0o600 });
88
+ await chmod(tmp, 0o600);
89
+ await rename(tmp, p); // atomic on POSIX
90
+ }
91
+
92
+ // Serialize store mutations per state dir. MCP tool handlers are async and NOT
93
+ // serialized by the SDK, so a concurrent appendReceipt (phaedo_consult) and
94
+ // markOutcome (phaedo_record_outcome/escalation_status) both read the same snapshot
95
+ // and the second write clobbers the first — a lost receipt or lost outcome mark.
96
+ // A per-stateDir promise chain forces read-modify-write to run one at a time.
97
+ const _storeLocks = new Map();
98
+ function withStoreLock(stateDir, fn) {
99
+ const prev = _storeLocks.get(stateDir) || Promise.resolve();
100
+ const next = prev.then(fn, fn); // run regardless of the prior result
101
+ _storeLocks.set(stateDir, next.then(() => {}, () => {})); // a rejection must not break the chain
102
+ return next;
83
103
  }
84
104
 
85
105
  // Build the §10.6 receipt object from a resolved consultation. Pure. Captures the
@@ -87,11 +107,17 @@ async function writeStore(stateDir, store, now) {
87
107
  // never the projection (§10.4 holds inside the vault too). `via` records which
88
108
  // tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
89
109
  // full consultations and pre-flight matches separately.
90
- export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
110
+ export function buildReceipt({ via, agentId, clientId, request, response, drivers, now = Date.now() }) {
91
111
  const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
92
112
  // Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
93
113
  // drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
94
114
  // text; coerced to the known shape; absent for deterministic pre-flights.
115
+ // Defensive: a malformed binding must not fail the receipt write (a receipt-write
116
+ // failure must never break the consultation — §10.6). The resolver has already
117
+ // rejected structurally-bad bindings with `request_malformed`, so anything that
118
+ // throws here is a caller bypassing it; record nothing rather than half of it.
119
+ let bnd = null;
120
+ try { bnd = normalizeActionBinding(request?.action_binding); } catch { bnd = null; }
95
121
  const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
96
122
  cue: typeof d?.cue === 'string' ? d.cue : null,
97
123
  ...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
@@ -103,16 +129,33 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
103
129
  receipt_id: crypto.randomUUID(),
104
130
  created_at: new Date(now).toISOString(),
105
131
  via: via || 'phaedo_consult',
132
+ // WHO CLAIMED to be calling. Self-asserted, unverified, and never a gate — the
133
+ // same standing `action_descriptor` was given in v0.1.48. Audit label only.
106
134
  agent_id: typeof agentId === 'string' ? agentId : null,
135
+ // WHO ACTUALLY SERVED the call: this server's own paired-client id, established
136
+ // out-of-band with the phone under a user-verified SAS. This is the identity
137
+ // §10.5 `agents` scoping matches on, so the audit can distinguish which paired
138
+ // client acted from whichever vendor string it chose to claim. Null = unpaired.
139
+ client_id: typeof clientId === 'string' && clientId ? clientId : null,
107
140
  request: {
108
141
  consultation_type: request?.consultation_type || 'action_approval',
109
142
  action_descriptor: {
110
- domain: typeof ad.domain === 'string' ? ad.domain : null,
143
+ // Canonical form, matching what the resolver reasoned about (lib/domain.js).
144
+ // The receipt is the evidence store the promotion tallies key on, so a raw
145
+ // `"Finance"` here would be a class of its own and split the subject's
146
+ // approvals away from `"finance"`.
147
+ domain: typeof ad.domain === 'string' ? (canonicalDomain(ad.domain) || null) : null,
111
148
  reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
112
149
  magnitude: typeof ad.magnitude === 'string' ? ad.magnitude : null,
113
150
  amount: typeof ad.amount === 'number' ? ad.amount : null,
114
151
  summary: typeof ad.summary === 'string' ? ad.summary.slice(0, 500) : null,
115
152
  },
153
+ // §10.2 binding: the concrete call the descriptor above CLAIMS to describe.
154
+ // This is the field that makes a descriptor/action mismatch provable later —
155
+ // the descriptor is the agent's story, this is what it said it would run.
156
+ // Never the arguments themselves: a digest, the tool name, and at most a
157
+ // short human preview.
158
+ ...(bnd ? { action_binding: bnd } : {}),
116
159
  },
117
160
  response: {
118
161
  signal: response?.signal ?? null,
@@ -126,11 +169,13 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
126
169
 
127
170
  // Append one receipt; evict oldest beyond the cap. Returns the stored receipt.
128
171
  export async function appendReceipt(stateDir, receipt, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
129
- const store = await readStore(stateDir);
130
- store.receipts.push(receipt);
131
- if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
132
- await writeStore(stateDir, store, now);
133
- return receipt;
172
+ return withStoreLock(stateDir, async () => {
173
+ const store = await readStore(stateDir);
174
+ store.receipts.push(receipt);
175
+ if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
176
+ await writeStore(stateDir, store, now);
177
+ return receipt;
178
+ });
134
179
  }
135
180
 
136
181
  // The ONE permitted mutation (§10.6): set user_action on an existing receipt.
@@ -139,12 +184,20 @@ export async function updateUserAction(stateDir, receiptId, action, { now = Date
139
184
  if (!USER_ACTIONS.includes(action)) {
140
185
  throw new Error(`user_action must be one of: ${USER_ACTIONS.join(', ')}`);
141
186
  }
142
- const store = await readStore(stateDir);
143
- const r = store.receipts.find((x) => x.receipt_id === receiptId);
144
- if (!r) return false;
145
- r.user_action = action;
146
- await writeStore(stateDir, store, now);
147
- return true;
187
+ return withStoreLock(stateDir, async () => {
188
+ const store = await readStore(stateDir);
189
+ const r = store.receipts.find((x) => x.receipt_id === receiptId);
190
+ if (!r) return false;
191
+ r.user_action = action;
192
+ // WHEN the outcome was recorded, alongside WHAT it was. Not decision latency —
193
+ // the subject may mark in a later sitting — but it does reveal a screenful of
194
+ // receipts stamped in one pass, which §10.7 autonomy promotion treats as
195
+ // bookkeeping rather than deliberate consent. Mutable with `user_action`, and
196
+ // only with it (§10.6: user_action is the sole mutable field; this is part of it).
197
+ r.user_action_at = new Date(now).toISOString();
198
+ await writeStore(stateDir, store, now);
199
+ return true;
200
+ });
148
201
  }
149
202
 
150
203
  // Decrypt and return all receipts (oldest first). The dev/harness read path — and