phaedo-mcp 0.2.2 → 0.3.1
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 +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- 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 +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- 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 +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- 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 +184 -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 +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/ap2.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Anamnesis ↔ AP2 profile — standing authorizations as Intent Mandates.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The standing risk to Anamnesis is not that a rival builds a
|
|
4
|
+
// better decision oracle; it is the standards cold-start. One implementer is a
|
|
5
|
+
// format, not a standard, and evangelism has not moved that number. Binding to a
|
|
6
|
+
// standard that ALREADY has implementers does: every AP2 participant becomes a
|
|
7
|
+
// partial Anamnesis consumer without adopting Anamnesis.
|
|
8
|
+
//
|
|
9
|
+
// AP2 (Google → FIDO Alliance, May 2026; Mastercard's Verifiable Intent
|
|
10
|
+
// co-contributed; the FIDO Payments TWG is chaired by Mastercard and Visa) defines
|
|
11
|
+
// an INTENT MANDATE: a verifiable credential, signed by the user with a
|
|
12
|
+
// hardware-backed device key, TTL'd, carrying structured constraints and the
|
|
13
|
+
// agent's stated understanding of the request ("prompt playback"), plus an
|
|
14
|
+
// intentionally open-ended risk payload.
|
|
15
|
+
//
|
|
16
|
+
// Read that against §10.5. An Intent Mandate is a standing authorization
|
|
17
|
+
// (`match` + `amount_gt` + `expires_at`) rendered as a credential the card networks
|
|
18
|
+
// already accept — and Phaedo's escalate-to-phone flow is architecturally the signing
|
|
19
|
+
// ceremony, once §8.3 gives the phone a signing key. The two designs arrived
|
|
20
|
+
// at the same primitive from opposite ends. "Prompt playback" is the payments
|
|
21
|
+
// industry independently discovering §10.2 `action_binding`.
|
|
22
|
+
//
|
|
23
|
+
// WHAT THIS MODULE DOES, AND DOES NOT. It is a PURE SERIALIZER: it renders the
|
|
24
|
+
// subject's authorizations into the Intent Mandate payload shape, and renders a
|
|
25
|
+
// consultation signal into a risk-payload entry. It does NOT sign — and today
|
|
26
|
+
// NOTHING does: §8.3 asymmetric signing is unbuilt, so no subject signing key
|
|
27
|
+
// exists anywhere in the product yet. That is the honest current state; the
|
|
28
|
+
// intended design is that the key is generated non-extractably on the phone and
|
|
29
|
+
// the ceremony that turns this payload into a credential is the phone's, not the
|
|
30
|
+
// server's. Deliberately unreachable from here either way. Scope + the P-256 vs
|
|
31
|
+
// Ed25519 decision: docs/roadmap/asymmetric-signing-scope-2026-07-23.md.
|
|
32
|
+
//
|
|
33
|
+
// The mapping is lossy in ONE direction on purpose. Anamnesis carries dispositions
|
|
34
|
+
// and inferred signals that have no place in a payment credential, so only the
|
|
35
|
+
// deterministic, subject-authored half crosses: authorizations, their bounds, and
|
|
36
|
+
// their expiry. Nothing inferred, no layer content, no persona. That is the §10.4
|
|
37
|
+
// boundary applied to a new transport, and it is also why this is safe to hand to a
|
|
38
|
+
// merchant's stack.
|
|
39
|
+
//
|
|
40
|
+
// PURE: no I/O, no crypto, no network.
|
|
41
|
+
|
|
42
|
+
import { signingInput } from './signing.js';
|
|
43
|
+
|
|
44
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
45
|
+
|
|
46
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
47
|
+
|
|
48
|
+
// AP2 expresses shopping constraints as structured fields, not prose. §10.5 `match`
|
|
49
|
+
// is already structured, so the translation is mechanical — the only real decision is
|
|
50
|
+
// which constraints have a faithful counterpart and which must be dropped rather than
|
|
51
|
+
// approximated. `magnitude` has none (it is a behavioral notion, not a commerce one),
|
|
52
|
+
// so it is carried as an advisory annotation, never as a spend limit.
|
|
53
|
+
function matchToConstraints(match) {
|
|
54
|
+
const m = isObj(match) ? match : {};
|
|
55
|
+
const out = {};
|
|
56
|
+
const domains = Array.isArray(m.domains) ? m.domains.map((d) => String(d)).filter(Boolean) : [];
|
|
57
|
+
if (domains.length) out.categories = domains;
|
|
58
|
+
// AP2's `max_amount` is a spend cap, so only a genuine CEILING may fill it.
|
|
59
|
+
// `amount_lt` is that ceiling. `amount_gt` is a FLOOR — "fires above N" — and on an
|
|
60
|
+
// autoproceed rule it means "auto-proceed only the LARGE ones", the opposite of a
|
|
61
|
+
// cap. Mapping it to `max_amount` (as this did before `amount_lt` existed) would
|
|
62
|
+
// have told a merchant the subject capped spending at exactly the figure above
|
|
63
|
+
// which they wanted extra scrutiny. It is carried as an annotation instead.
|
|
64
|
+
// Both ceiling forms map to `max_amount`, and when both are present the LOWER wins — AP2 has
|
|
65
|
+
// one field and no way to say "exclusive", so the credential must not assert more headroom than
|
|
66
|
+
// the rule grants. `amount_lte: 20` becomes `max_amount: 20`; `amount_lt: 20` also becomes 20,
|
|
67
|
+
// which is the pre-existing (slightly generous by one cent) mapping, unchanged.
|
|
68
|
+
const ceilings = [m.amount_lt, m.amount_lte].filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
69
|
+
if (ceilings.length) {
|
|
70
|
+
out.max_amount = Math.min(...ceilings);
|
|
71
|
+
}
|
|
72
|
+
if (typeof m.amount_gt === 'number' && Number.isFinite(m.amount_gt)) {
|
|
73
|
+
out.annotations = { ...(out.annotations || {}), anamnesis_amount_floor: m.amount_gt };
|
|
74
|
+
}
|
|
75
|
+
if (typeof m.reversible === 'boolean') out.refundable_only = m.reversible;
|
|
76
|
+
if (m.magnitude_max && MAG_RANK[m.magnitude_max] !== undefined) {
|
|
77
|
+
out.annotations = { ...(out.annotations || {}), anamnesis_magnitude_max: m.magnitude_max };
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One §10.5 authorization → the Intent Mandate constraint block it corresponds to,
|
|
83
|
+
// or null when it has no faithful translation.
|
|
84
|
+
//
|
|
85
|
+
// Only AUTOPROCEED authorizations become mandates. That is the whole semantic match:
|
|
86
|
+
// an Intent Mandate is a grant of authority to act without the human present, and
|
|
87
|
+
// `autoproceed` is the only Anamnesis effect that grants anything. An `escalate` or
|
|
88
|
+
// `decline` rule is the ABSENCE of a mandate — the correct AP2 rendering of "ask me
|
|
89
|
+
// first" is not a restrictive credential, it is no credential at all, and emitting
|
|
90
|
+
// one would invert its meaning to a merchant.
|
|
91
|
+
export function authorizationToIntentConstraints(rule, { now = Date.now() } = {}) {
|
|
92
|
+
if (!isObj(rule)) return null;
|
|
93
|
+
if (rule.kind !== undefined && rule.kind !== 'authorization') return null;
|
|
94
|
+
if (rule.effect !== 'autoproceed') return null;
|
|
95
|
+
// A lapsed authorization must not be exported as live authority.
|
|
96
|
+
const exp = Date.parse(rule.expires_at);
|
|
97
|
+
if (Number.isFinite(exp) && exp <= now) return null;
|
|
98
|
+
// Nor must a CLIENT-SCOPED one (§10.5 `agents`). AP2 has no vocabulary for "this
|
|
99
|
+
// specific paired client", so a scoped grant cannot be expressed in a mandate — and
|
|
100
|
+
// emitting it unscoped would tell a merchant the subject granted unconditionally
|
|
101
|
+
// what they in fact granted to exactly one client. That is the same inversion this
|
|
102
|
+
// function already refuses for restrictive rules, and it crosses to a third party,
|
|
103
|
+
// where it is least correctable. A scope that cannot be carried must instead
|
|
104
|
+
// withhold the credential: the correct AP2 rendering of a grant this format cannot
|
|
105
|
+
// express is no grant at all. (An ABSENT `agents` is unscoped and exports normally;
|
|
106
|
+
// an empty array is a scope naming nobody, so it withholds too.)
|
|
107
|
+
if (Array.isArray(rule.agents)) return null;
|
|
108
|
+
return {
|
|
109
|
+
source_rule_id: rule.instruction_id || rule.id || null,
|
|
110
|
+
constraints: matchToConstraints(rule.match),
|
|
111
|
+
// §10.5 require_binding maps onto AP2's own posture that a mandate authorizes a
|
|
112
|
+
// BOUNDED transaction, not open-ended spending. Carried through so a verifier can
|
|
113
|
+
// see the subject demanded the agent commit to a concrete action.
|
|
114
|
+
requires_action_binding: rule.require_binding === true,
|
|
115
|
+
expires_at: Number.isFinite(exp) ? new Date(exp).toISOString() : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The unsigned Intent Mandate payload for a subject's live authorizations.
|
|
120
|
+
//
|
|
121
|
+
// Returns null when the subject has granted nothing — which is the common case and
|
|
122
|
+
// the right answer: no authority, no mandate. Callers MUST NOT synthesize an empty
|
|
123
|
+
// mandate, because an empty credential reads to a merchant as "authorized with no
|
|
124
|
+
// constraints", the exact inversion this module exists to avoid.
|
|
125
|
+
export function buildIntentMandatePayload(fp, opts = {}) {
|
|
126
|
+
const {
|
|
127
|
+
now = Date.now(),
|
|
128
|
+
agentId = null,
|
|
129
|
+
promptPlayback = null, // the agent's stated understanding (AP2 "prompt playback")
|
|
130
|
+
consultSignal = null, // §10.3 signal, if a consultation preceded this
|
|
131
|
+
} = opts;
|
|
132
|
+
|
|
133
|
+
// A standing authorization lives in EITHER home (spec §10.5: "a `consult_policies` rule
|
|
134
|
+
// or `standing_rules` authorization"), so an Intent Mandate — the subject's live
|
|
135
|
+
// authorizations — must read both. Reading only `standing_rules` silently dropped every
|
|
136
|
+
// autoproceed guardrail authored through the extension's Decision-guardrails form (which
|
|
137
|
+
// writes `consult_policies`), so a subject with real grants got an empty mandate. The
|
|
138
|
+
// per-rule guards are unchanged and live in authorizationToIntentConstraints: autoproceed
|
|
139
|
+
// only, not lapsed, and never a client-scoped grant (AP2 can't express `agents`).
|
|
140
|
+
const standingRules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
|
|
141
|
+
const policyRules = Array.isArray(fp?.consult_policies?.rules) ? fp.consult_policies.rules : [];
|
|
142
|
+
// §10.5 tombstones — a REVOKED authorization must never become a mandate grant. This is
|
|
143
|
+
// the highest-stakes read of the two homes: a grant here is what a payment credential is
|
|
144
|
+
// minted against, so a rule the subject revoked that survived in a stale or restored copy
|
|
145
|
+
// would sign away authority they took back. Same consumer MUST as normalizePolicies.
|
|
146
|
+
const revoked = new Set(
|
|
147
|
+
(Array.isArray(fp?.consult_policies?.revoked) ? fp.consult_policies.revoked : [])
|
|
148
|
+
.map((t) => (t && typeof t === 'object' && t.id != null ? String(t.id) : null))
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
);
|
|
151
|
+
const grants = [];
|
|
152
|
+
const seen = new Set(); // dedupe by source rule id — the same authorization must not
|
|
153
|
+
for (const r of [...standingRules, ...policyRules]) { // become two mandate grants
|
|
154
|
+
if (r && typeof r === 'object') {
|
|
155
|
+
const rid = r.id != null ? String(r.id) : (r.instruction_id != null ? String(r.instruction_id) : null);
|
|
156
|
+
if (rid && revoked.has(rid)) continue;
|
|
157
|
+
}
|
|
158
|
+
const g = authorizationToIntentConstraints(r, { now });
|
|
159
|
+
if (!g) continue;
|
|
160
|
+
if (g.source_rule_id && seen.has(g.source_rule_id)) continue;
|
|
161
|
+
if (g.source_rule_id) seen.add(g.source_rule_id);
|
|
162
|
+
grants.push(g);
|
|
163
|
+
}
|
|
164
|
+
if (!grants.length) return null;
|
|
165
|
+
|
|
166
|
+
// The mandate expires with the FIRST grant to lapse — an aggregate credential must
|
|
167
|
+
// not outlive the narrowest authority inside it.
|
|
168
|
+
const expiries = grants.map((g) => Date.parse(g.expires_at)).filter(Number.isFinite);
|
|
169
|
+
const expiresAt = expiries.length ? new Date(Math.min(...expiries)).toISOString() : null;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
mandate_type: 'intent',
|
|
173
|
+
// Version the profile, not the payment protocol: AP2 moves on its own cadence and
|
|
174
|
+
// a verifier needs to know which mapping produced this.
|
|
175
|
+
anamnesis_profile: '0.1',
|
|
176
|
+
issued_at: new Date(now).toISOString(),
|
|
177
|
+
expires_at: expiresAt,
|
|
178
|
+
...(agentId ? { agent_id: String(agentId) } : {}),
|
|
179
|
+
...(promptPlayback ? { prompt_playback: String(promptPlayback).slice(0, 500) } : {}),
|
|
180
|
+
grants,
|
|
181
|
+
// AP2 leaves the risk payload open-ended for exactly this kind of signal. It is
|
|
182
|
+
// the ONE place a behavioral system has something to say to a payments network,
|
|
183
|
+
// and it says it in the §10.4 currency: a constrained signal, never persona.
|
|
184
|
+
risk_payload: buildRiskPayload({ consultSignal }),
|
|
185
|
+
// Unsigned by construction. The server CANNOT sign: per §8.3 the subject's key is
|
|
186
|
+
// non-extractable in the phone's Secure Enclave, so signing is a ceremony that belongs
|
|
187
|
+
// to the device holding the key. `intentMandateSigningInput()` below computes the exact
|
|
188
|
+
// bytes the phone would be asked to sign. Until a signature comes back, this object is
|
|
189
|
+
// a proposal, never a credential.
|
|
190
|
+
signature: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The exact bytes a §8.3 signature over an Intent Mandate covers: the §6 RFC 8785 canonical
|
|
196
|
+
* form of the payload with `signature` omitted. This is the seam between the server (which
|
|
197
|
+
* builds the mandate) and the phone (which holds the only key that can sign it) — both sides
|
|
198
|
+
* must agree on these bytes or the resulting signature verifies nowhere.
|
|
199
|
+
*
|
|
200
|
+
* Omitting `signature` is what keeps the input stable once the signature is attached, so a
|
|
201
|
+
* verifier recomputes the same bytes from the finished credential.
|
|
202
|
+
*/
|
|
203
|
+
export function intentMandateSigningInput(payload) {
|
|
204
|
+
return signingInput(payload, { field: 'signature' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// §10.3 signal → AP2 risk-payload entry. Deliberately minimal: a merchant learns
|
|
208
|
+
// that the subject's own oracle was consulted and what it said — which is exactly the
|
|
209
|
+
// claim that makes a mandate more trustworthy — and learns nothing about the subject.
|
|
210
|
+
export function buildRiskPayload({ consultSignal = null } = {}) {
|
|
211
|
+
const payload = { anamnesis_consulted: !!consultSignal };
|
|
212
|
+
if (consultSignal) payload.anamnesis_signal = String(consultSignal);
|
|
213
|
+
return payload;
|
|
214
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// §10.7 — fatigue-aware autonomy promotion.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS SOLVES. Phaedo can add a gate and never remove one. The
|
|
4
|
+
// delegation-promotion channel learns only from DISAGREEMENTS
|
|
5
|
+
// (consult-core.js `buildDelegationPromotions`), and §10.5 permanently caps what
|
|
6
|
+
// an authored disposition may do at `bias_cautious`: "an authored disposition may
|
|
7
|
+
// make the agent MORE deferential, never less." Expanding autonomy requires the
|
|
8
|
+
// subject to hand-author a `kind:"authorization"` rule with a `match`. Almost
|
|
9
|
+
// nobody will. So in real use the oracle accumulates friction and never sheds it,
|
|
10
|
+
// and the 2026 approval-fatigue literature is unanimous about where that ends:
|
|
11
|
+
// approval stops being a decision and becomes a reflex, which is both a usability
|
|
12
|
+
// failure and an attack surface.
|
|
13
|
+
//
|
|
14
|
+
// THE OBJECTION, AND WHY IT SHAPES THE DESIGN. "Learn from approvals" is exactly
|
|
15
|
+
// what the honesty rail forbids — rubber stamps must not teach. That objection is
|
|
16
|
+
// correct, and it is the reason this module is not a counter. A subject clicking
|
|
17
|
+
// approve fourteen times in ninety seconds is not expressing a policy; they are
|
|
18
|
+
// tired. So the gate here is not "how many approvals" but "is this evidence of
|
|
19
|
+
// DELIBERATION":
|
|
20
|
+
//
|
|
21
|
+
// - spread over distinct days, not a single sitting (volume ≠ deliberation)
|
|
22
|
+
// - no contrary evidence at all: one rejection or modification in the class kills it
|
|
23
|
+
// - the oracle must already be tracking the subject well in that domain
|
|
24
|
+
// (agreement rate — the interlock the agreement read-out exists to provide)
|
|
25
|
+
// - not batch-marked: approvals recorded in one burst are bookkeeping, not consent
|
|
26
|
+
//
|
|
27
|
+
// AND WHAT IS PROPOSED IS DELIBERATELY SMALL. Reversible only. Never `high`
|
|
28
|
+
// magnitude. A priced class is bounded by an `amount_lt` CEILING set to the
|
|
29
|
+
// SECOND-highest amount approved, never the highest — one outlier must not set the
|
|
30
|
+
// bar (see spendCeiling). Always `require_binding: true`, so the relaxation cannot
|
|
31
|
+
// be unlocked by a fabricated descriptor. Always `expires_at`, so an autonomy grant
|
|
32
|
+
// the subject stops wanting lapses by itself.
|
|
33
|
+
//
|
|
34
|
+
// AND IT IS STILL ONLY A PROPOSAL. This writes nothing authored. It stages a
|
|
35
|
+
// `suggested_rules` entry the subject reviews and endorses, exactly like every
|
|
36
|
+
// other promotion channel — the authored-only invariant holds. The producer
|
|
37
|
+
// stages; the user authors.
|
|
38
|
+
//
|
|
39
|
+
// PURE: (receipts, fp, agreement, opts) -> suggestions[]. No I/O, no globals.
|
|
40
|
+
|
|
41
|
+
import { computeAgreement, DEFAULT_WINDOW_DAYS } from './agreement.js';
|
|
42
|
+
|
|
43
|
+
// ── The bar. Every one of these is a way of asking "was this deliberate?" ─────
|
|
44
|
+
export const MIN_APPROVALS = 5; // enough instances to be a pattern
|
|
45
|
+
export const MIN_DISTINCT_DAYS = 3; // ...spread across days, not one sitting
|
|
46
|
+
export const MIN_AGREEMENT = 0.8; // ...in a domain the oracle already tracks well
|
|
47
|
+
export const MIN_AGREEMENT_SCORED = 5; // ...on enough scored decisions to mean anything
|
|
48
|
+
export const BATCH_WINDOW_MS = 60_000; // marks this close together are bookkeeping
|
|
49
|
+
export const MAX_BATCH_FRACTION = 0.5; // ...and may not carry the majority of the evidence
|
|
50
|
+
export const DEFAULT_TTL_DAYS = 30; // a grant expires unless re-earned
|
|
51
|
+
|
|
52
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
54
|
+
const RANK_MAG = ['low', 'medium', 'high'];
|
|
55
|
+
|
|
56
|
+
const uuid = () =>
|
|
57
|
+
(typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
|
|
58
|
+
: `aut-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
|
|
60
|
+
const utcDay = (iso) => String(iso || '').slice(0, 10); // RFC 3339 → YYYY-MM-DD
|
|
61
|
+
|
|
62
|
+
// A promotable class is (domain × reversible-true). Magnitude is not part of the
|
|
63
|
+
// key — it is the CEILING we derive from what was actually observed, so a class
|
|
64
|
+
// seen only at `low` never proposes a rule that would fire at `medium`.
|
|
65
|
+
function classKey(domain) { return String(domain || '').toLowerCase(); }
|
|
66
|
+
|
|
67
|
+
// Receipts that count as evidence FOR autonomy in a class. Deliberately narrow:
|
|
68
|
+
// the oracle must have been decisive, the subject must have approved, and the
|
|
69
|
+
// action must be one we would ever propose relaxing.
|
|
70
|
+
function isPromotableApproval(r) {
|
|
71
|
+
const ad = r?.request?.action_descriptor || {};
|
|
72
|
+
if (r?.user_action !== 'approved') return false;
|
|
73
|
+
if (r?.response?.signal !== 'proceed' && r?.response?.signal !== 'proceed_with_note') return false;
|
|
74
|
+
if (ad.reversible !== true) return false; // irreversible is never auto-proceeded
|
|
75
|
+
if (ad.magnitude === 'high') return false; // nor is a major action
|
|
76
|
+
if (ad.amount !== undefined && !(typeof ad.amount === 'number' && Number.isFinite(ad.amount) && ad.amount >= 0))
|
|
77
|
+
return false; // a malformed amount is not evidence of anything
|
|
78
|
+
if (!ad.domain) return false; // an unscoped autoproceed rule is a blank cheque
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The spend ceiling for a money class: the SECOND-highest amount approved, not the
|
|
83
|
+
// highest.
|
|
84
|
+
//
|
|
85
|
+
// Using the maximum would let ONE outlier set the bar — approve five $20 orders and a
|
|
86
|
+
// single $500 one, and the rule would thereafter auto-proceed anything up to $500.
|
|
87
|
+
// Requiring two approvals at a level before the rule reaches it is the money-shaped
|
|
88
|
+
// version of the "spread across days, not one sitting" rule: a bound has to be
|
|
89
|
+
// demonstrated more than once.
|
|
90
|
+
//
|
|
91
|
+
// Returns null when the class carries no amounts at all (then no ceiling is needed),
|
|
92
|
+
// or when fewer than two priced approvals exist (then no ceiling can be justified, so
|
|
93
|
+
// the class is not promotable).
|
|
94
|
+
export function spendCeiling(receipts) {
|
|
95
|
+
const amounts = receipts
|
|
96
|
+
.map((r) => r?.request?.action_descriptor?.amount)
|
|
97
|
+
.filter((a) => typeof a === 'number' && Number.isFinite(a));
|
|
98
|
+
if (!amounts.length) return null; // unpriced class — no ceiling needed
|
|
99
|
+
if (amounts.length < 2) return undefined; // one priced approval cannot justify a bound
|
|
100
|
+
const sorted = amounts.slice().sort((a, b) => b - a);
|
|
101
|
+
return sorted[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Anything that argues AGAINST relaxing this class. One is enough to disqualify:
|
|
105
|
+
// a class the subject has ever rejected or edited is not settled.
|
|
106
|
+
function isContraryEvidence(r, domain) {
|
|
107
|
+
const ad = r?.request?.action_descriptor || {};
|
|
108
|
+
if (classKey(ad.domain) !== domain) return false;
|
|
109
|
+
return r?.user_action === 'rejected' || r?.user_action === 'modified';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Were these approvals RECORDED in a burst? Marking a screenful of receipts in one
|
|
113
|
+
// pass is bookkeeping, not consent — so if most of the supporting evidence was
|
|
114
|
+
// stamped inside one short window, it does not support a policy. Receipts without
|
|
115
|
+
// a mark timestamp (older stores) are treated as non-batch: absence of evidence
|
|
116
|
+
// is not evidence of fatigue, and the other gates still apply.
|
|
117
|
+
export function batchFraction(receipts, { windowMs = BATCH_WINDOW_MS } = {}) {
|
|
118
|
+
const stamps = receipts
|
|
119
|
+
.map((r) => Date.parse(r?.user_action_at))
|
|
120
|
+
.filter((t) => Number.isFinite(t))
|
|
121
|
+
.sort((a, b) => a - b);
|
|
122
|
+
if (stamps.length < 2) return 0;
|
|
123
|
+
let best = 1;
|
|
124
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
125
|
+
let j = i;
|
|
126
|
+
while (j < stamps.length && stamps[j] - stamps[i] <= windowMs) j++;
|
|
127
|
+
best = Math.max(best, j - i);
|
|
128
|
+
}
|
|
129
|
+
return best / receipts.length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function proposedText(domain, magMax, ceiling = null) {
|
|
133
|
+
const scope = magMax === 'low' ? 'small, reversible' : 'routine, reversible';
|
|
134
|
+
// The ceiling is the single most consequential term on a money rule, so it belongs
|
|
135
|
+
// in the sentence the subject reads — not only in the terms line beneath it.
|
|
136
|
+
// "up to", not "under" — the ceiling is INCLUSIVE now (M7), and this sentence is what the
|
|
137
|
+
// subject reads before endorsing. Prose that says "under 20" for a rule that fires AT 20
|
|
138
|
+
// misdescribes the authority being granted, on the one screen where accuracy is consent.
|
|
139
|
+
const under = ceiling !== null ? ` up to ${ceiling}` : '';
|
|
140
|
+
return `Let agents go ahead with ${scope} ${domain} actions${under} without asking me first.`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// (receipts, fp, opts) -> suggestion[] — the §10.5 authorizations worth PROPOSING.
|
|
144
|
+
//
|
|
145
|
+
// `fp` supplies what is already authored or already staged, so an endorsed or
|
|
146
|
+
// pending suggestion is never re-proposed (the no-nag rule every promotion
|
|
147
|
+
// channel follows).
|
|
148
|
+
export function buildAutonomyPromotions(receipts, fp, opts = {}) {
|
|
149
|
+
const {
|
|
150
|
+
now = Date.now(),
|
|
151
|
+
windowDays = DEFAULT_WINDOW_DAYS,
|
|
152
|
+
minApprovals = MIN_APPROVALS,
|
|
153
|
+
minDistinctDays = MIN_DISTINCT_DAYS,
|
|
154
|
+
minAgreement = MIN_AGREEMENT,
|
|
155
|
+
minAgreementScored = MIN_AGREEMENT_SCORED,
|
|
156
|
+
maxBatchFraction = MAX_BATCH_FRACTION,
|
|
157
|
+
ttlDays = DEFAULT_TTL_DAYS,
|
|
158
|
+
} = opts;
|
|
159
|
+
|
|
160
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
161
|
+
const since = now - windowDays * DAY_MS;
|
|
162
|
+
const inWindow = list.filter((r) => {
|
|
163
|
+
const t = Date.parse(r?.created_at);
|
|
164
|
+
return Number.isFinite(t) && t >= since && t <= now;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Already covered? An authored autoproceed for the domain, or a live suggestion.
|
|
168
|
+
const authored = new Set(
|
|
169
|
+
(Array.isArray(fp?.standing_rules) ? fp.standing_rules : [])
|
|
170
|
+
.filter((r) => r?.kind === 'authorization' && r?.effect === 'autoproceed')
|
|
171
|
+
.flatMap((r) => (Array.isArray(r?.match?.domains) ? r.match.domains : []).map(classKey)),
|
|
172
|
+
);
|
|
173
|
+
const staged = new Set(
|
|
174
|
+
(Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [])
|
|
175
|
+
.filter((s) => s?.source === 'autonomy_promotion')
|
|
176
|
+
.flatMap((s) => (Array.isArray(s?.match?.domains) ? s.match.domains : []).map(classKey)),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const agreement = computeAgreement(list, { windowDays, now });
|
|
180
|
+
|
|
181
|
+
const byClass = new Map();
|
|
182
|
+
for (const r of inWindow) {
|
|
183
|
+
if (!isPromotableApproval(r)) continue;
|
|
184
|
+
const key = classKey(r.request.action_descriptor.domain);
|
|
185
|
+
const bucket = byClass.get(key) || { receipts: [], days: new Set(), magRank: 0, clients: new Set() };
|
|
186
|
+
bucket.receipts.push(r);
|
|
187
|
+
bucket.days.add(utcDay(r.created_at));
|
|
188
|
+
bucket.magRank = Math.max(bucket.magRank, MAG_RANK[r.request.action_descriptor.magnitude] ?? 0);
|
|
189
|
+
// §10.5 scoping (step 5): WHICH paired client's actions earned this. Recorded from
|
|
190
|
+
// the receipt's `client_id` — the serving client, established out-of-band with the
|
|
191
|
+
// phone — never from `agent_id`, which is the caller's claim about itself.
|
|
192
|
+
if (typeof r.client_id === 'string' && r.client_id) bucket.clients.add(r.client_id);
|
|
193
|
+
byClass.set(key, bucket);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const [domain, bucket] of byClass) {
|
|
198
|
+
if (authored.has(domain) || staged.has(domain)) continue; // no-nag
|
|
199
|
+
if (bucket.receipts.length < minApprovals) continue; // not yet a pattern
|
|
200
|
+
if (bucket.days.size < minDistinctDays) continue; // one sitting is not a policy
|
|
201
|
+
if (inWindow.some((r) => isContraryEvidence(r, domain))) continue; // any pushback disqualifies the class
|
|
202
|
+
if (batchFraction(bucket.receipts, opts) > maxBatchFraction) continue; // batch-marked = bookkeeping
|
|
203
|
+
|
|
204
|
+
// The interlock: don't hand autonomy to an oracle that isn't tracking this
|
|
205
|
+
// subject here. `(none)` is how computeAgreement keys undomained receipts.
|
|
206
|
+
const dom = agreement.domains[domain];
|
|
207
|
+
if (!dom || dom.scored < minAgreementScored || dom.rate === null || dom.rate < minAgreement) continue;
|
|
208
|
+
|
|
209
|
+
// Money needs a ceiling, and a ceiling needs to have been demonstrated twice.
|
|
210
|
+
// `undefined` means the class is priced but cannot justify a bound — skip it
|
|
211
|
+
// rather than propose an unbounded spend authorization.
|
|
212
|
+
const ceiling = spendCeiling(bucket.receipts);
|
|
213
|
+
if (ceiling === undefined) continue;
|
|
214
|
+
if (ceiling !== null && !(ceiling > 0)) continue; // a 0 ceiling can never match
|
|
215
|
+
|
|
216
|
+
const magMax = RANK_MAG[bucket.magRank] || 'low';
|
|
217
|
+
|
|
218
|
+
// §10.5 `agents` — scope the proposed grant to the clients that EARNED it.
|
|
219
|
+
//
|
|
220
|
+
// Only meaningful now that scoping is enforced (v0.1.53) and authorable on the
|
|
221
|
+
// phone (#197). Before that, writing this field would have shipped something that
|
|
222
|
+
// looked like a boundary and was not — the reason it was deliberately left off.
|
|
223
|
+
//
|
|
224
|
+
// Strictly narrowing versus the previous behaviour, which granted every paired
|
|
225
|
+
// client whatever any one of them earned. A client that contributed approvals but
|
|
226
|
+
// cannot be attributed (receipts written before v0.1.53 carry no `client_id`) is
|
|
227
|
+
// simply left out — it keeps asking, which is the fail-safe direction.
|
|
228
|
+
//
|
|
229
|
+
// The empty case is the one that matters: when NO receipt in the class can be
|
|
230
|
+
// attributed, the field is OMITTED rather than written as `[]`. Absent means
|
|
231
|
+
// unscoped (today's behaviour, preserved for pre-v0.1.53 history); `[]` means a
|
|
232
|
+
// scope naming nobody, which would stage a grant that can never fire. Getting
|
|
233
|
+
// those two confused is precisely the defect the v0.1.54 hardening pass caught.
|
|
234
|
+
const agents = [...bucket.clients].sort();
|
|
235
|
+
out.push({
|
|
236
|
+
suggestion_id: uuid(),
|
|
237
|
+
status: 'suggested',
|
|
238
|
+
kind: 'authorization',
|
|
239
|
+
source: 'autonomy_promotion',
|
|
240
|
+
proposed_text: proposedText(domain, magMax, ceiling),
|
|
241
|
+
match: {
|
|
242
|
+
domains: [domain],
|
|
243
|
+
reversible: true,
|
|
244
|
+
magnitude_max: magMax,
|
|
245
|
+
// INCLUSIVE (M7). This staged `amount_lt: ceiling`, and `ceiling` is the SECOND-HIGHEST
|
|
246
|
+
// amount approved — so five $20 approvals minted a rule matching `amount < 20`, which
|
|
247
|
+
// excludes every purchase that earned it. The grant was unfireable by construction, and
|
|
248
|
+
// silently: it appeared in the subject's rules, read correctly in prose, and never
|
|
249
|
+
// matched. §10.5 gained `amount_lte` for this.
|
|
250
|
+
...(ceiling !== null ? { amount_lte: ceiling } : {}),
|
|
251
|
+
},
|
|
252
|
+
effect: 'autoproceed',
|
|
253
|
+
// Non-negotiable on a PROPOSED relaxation: it may only apply to a request
|
|
254
|
+
// bound to a concrete call, and it lapses unless the subject re-earns it.
|
|
255
|
+
require_binding: true,
|
|
256
|
+
expires_at: new Date(now + ttlDays * DAY_MS).toISOString(),
|
|
257
|
+
...(agents.length ? { agents } : {}),
|
|
258
|
+
note: agents.length
|
|
259
|
+
// Say the grant is limited, without printing an opaque `c_…` the subject
|
|
260
|
+
// cannot recognise — only the phone holds the registry that names it.
|
|
261
|
+
? `you have approved every ${domain} action like this that Phaedo has seen — limited to the agent that earned it`
|
|
262
|
+
: `you have approved every ${domain} action like this that Phaedo has seen`,
|
|
263
|
+
evidence: {
|
|
264
|
+
observation_count: bucket.receipts.length,
|
|
265
|
+
distinct_days: bucket.days.size,
|
|
266
|
+
agreement_rate: dom.rate,
|
|
267
|
+
evidence_basis: 'observed',
|
|
268
|
+
last_observed: bucket.receipts[bucket.receipts.length - 1]?.created_at || null,
|
|
269
|
+
// Provenance for the audit: how many paired clients this evidence came from,
|
|
270
|
+
// and whether any of it predates client attribution. Distinct from `agents`,
|
|
271
|
+
// which is the enforceable scope.
|
|
272
|
+
client_count: bucket.clients.size,
|
|
273
|
+
unattributed: bucket.receipts.some((r) => !(typeof r.client_id === 'string' && r.client_id)),
|
|
274
|
+
},
|
|
275
|
+
created: new Date(now).toISOString(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Deterministic order — this rides a last-write-wins mailbox deposit.
|
|
279
|
+
return out.sort((a, b) => a.match.domains[0].localeCompare(b.match.domains[0]));
|
|
280
|
+
}
|
package/build-package.mjs
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Make the npm package self-contained. The MCP server reaches
|
|
3
|
-
// the repo root (one source of truth, shared with the extension): the injection
|
|
4
|
-
// renderer (context-block.js, via projection.js)
|
|
5
|
-
// (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
2
|
+
// Make the npm package self-contained. The MCP server reaches three sets of files
|
|
3
|
+
// at the repo root (one source of truth, shared with the extension): the injection
|
|
4
|
+
// renderer (context-block.js, via projection.js), the classic crypto modules
|
|
5
|
+
// (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js), and the capture
|
|
6
|
+
// stack — capture/* for the SessionEnd hook plus the extraction modules the hook
|
|
7
|
+
// and the mcp-local tier-3 fold need (loaded via lib/capture-home.js). The
|
|
8
|
+
// published package ships only mcp/, so this copies those root modules into
|
|
9
|
+
// mcp/vendor/ (capture/extraction keep their subdirectories — their imports are
|
|
10
|
+
// intra-directory relative, so the copies resolve each other unchanged); the
|
|
11
|
+
// consumers prefer the location per their own doc-comments. Runs as `prepack`
|
|
12
|
+
// (npm pack / publish), `pretest`, and `npm run build`. Idempotent; mcp/vendor/
|
|
13
|
+
// is gitignored (a build artifact).
|
|
10
14
|
|
|
11
15
|
import { copyFileSync, mkdirSync } from 'fs';
|
|
12
16
|
import { dirname, join, resolve } from 'path';
|
|
13
17
|
import { fileURLToPath } from 'url';
|
|
18
|
+
import { ROOT_FILES, CAPTURE_FILES, EXTRACTION_FILES } from './vendored-files.mjs';
|
|
14
19
|
|
|
15
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
21
|
const ROOT = resolve(HERE, '..');
|
|
17
22
|
const VENDOR = join(HERE, 'vendor');
|
|
18
|
-
|
|
23
|
+
// Single source of truth, shared with scripts/export-public-repo.mjs (they drifted once).
|
|
24
|
+
const FILES = ROOT_FILES;
|
|
25
|
+
// The capture hook + tier-3 fold closure. Keep in lockstep with what
|
|
26
|
+
// lib/capture-home.js consumers actually import — test-vendored-capture.mjs
|
|
27
|
+
// exercises the whole set from a rootless copy, so a missing module fails CI.
|
|
28
|
+
|
|
29
|
+
|
|
19
30
|
|
|
20
31
|
mkdirSync(VENDOR, { recursive: true });
|
|
21
32
|
for (const f of FILES) copyFileSync(resolve(ROOT, f), join(VENDOR, f));
|
|
22
|
-
|
|
33
|
+
mkdirSync(join(VENDOR, 'capture'), { recursive: true });
|
|
34
|
+
for (const f of CAPTURE_FILES) copyFileSync(resolve(ROOT, 'capture', f), join(VENDOR, 'capture', f));
|
|
35
|
+
mkdirSync(join(VENDOR, 'extraction'), { recursive: true });
|
|
36
|
+
for (const f of EXTRACTION_FILES) copyFileSync(resolve(ROOT, 'extraction', f), join(VENDOR, 'extraction', f));
|
|
37
|
+
process.stdout.write(`[phaedo-mcp] bundled ${FILES.length} root + ${CAPTURE_FILES.length} capture + ${EXTRACTION_FILES.length} extraction modules → mcp/vendor/\n`);
|