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.
- package/README.md +146 -21
- package/agreement.js +48 -2
- package/consult-core.js +77 -3
- package/consult-runtime.js +60 -0
- package/consult.js +22 -1
- package/delegation-sync.js +6 -0
- package/fingerprint-source.js +34 -9
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +6 -2
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +71 -24
- package/package.json +19 -6
- package/phone-source.js +6 -1
- package/receipts.js +1 -1
- package/server.js +52 -62
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/vendor/capture/detectors.js +4 -4
- package/vendor/capture/transcript.js +34 -3
- package/vendor/extraction/persist.js +7 -2
- package/vendor/extraction/reconcile.js +46 -5
- package/vendor/extraction/rule-promotion.js +6 -40
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +4 -0
package/README.md
CHANGED
|
@@ -1,14 +1,98 @@
|
|
|
1
1
|
# Phaedo MCP server (`phaedo-mcp`)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
**projection** so the assistant responds in your style, and adds **consultation**
|
|
7
|
-
(decision/risk signals), **real-time escalation** to your paired phone for a live
|
|
8
|
-
approve / deny / modify on a blocking decision, and **act-as-me** learning from
|
|
9
|
-
your outcome marks. One integration, every MCP client.
|
|
3
|
+
Phaedo learns how you write, work and decide, and keeps that profile on your phone.
|
|
4
|
+
This server lets your AI apps use it: Claude Desktop, Claude Code, Cursor, and any
|
|
5
|
+
other app that supports [MCP](https://modelcontextprotocol.io). With it, they can:
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
- **Answer the way you like** — your preferred length, tone and format, without
|
|
8
|
+
you repeating it in every chat.
|
|
9
|
+
- **Check with you before acting** — an AI agent asks Phaedo whether you'd want it
|
|
10
|
+
to go ahead. When it's unsure, your phone gets an approve / deny card.
|
|
11
|
+
- **Learn from your answers** — each approve or deny teaches it how you want work
|
|
12
|
+
handled when you're not watching.
|
|
13
|
+
- **Get your signature for purchases** — you sign a spending permission on your
|
|
14
|
+
phone with Face ID; the agent never holds the key.
|
|
15
|
+
|
|
16
|
+
Your profile comes from your phone encrypted. The copy kept on this computer, for
|
|
17
|
+
when your phone is offline, is encrypted too.
|
|
18
|
+
|
|
19
|
+
## What you need
|
|
20
|
+
|
|
21
|
+
1. **The Phaedo phone app.** Your profile lives there. The app isn't open to the
|
|
22
|
+
public yet — [phaedo.so](https://phaedo.so) has a link to hear when it is.
|
|
23
|
+
2. **Node.js**, free from [nodejs.org](https://nodejs.org) (the LTS button).
|
|
24
|
+
|
|
25
|
+
Without the phone app there is no profile, and the server says so plainly rather
|
|
26
|
+
than guessing. You can still try Phaedo with a made-up example profile.
|
|
27
|
+
|
|
28
|
+
## Try it with the example profile (no phone needed)
|
|
29
|
+
|
|
30
|
+
This loads a made-up person's profile, so you can see what Phaedo adds before you
|
|
31
|
+
have your own.
|
|
32
|
+
|
|
33
|
+
**Claude Code** — run this in Terminal (it applies to all your projects):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add -s user phaedo -e PHAEDO_FINGERPRINT=sample -- npx -y phaedo-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Claude Desktop** — open Settings → Developer → Edit Config, add the `phaedo`
|
|
40
|
+
entry below inside `mcpServers`, save, then quit and reopen Claude Desktop:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"phaedo": {
|
|
46
|
+
"command": "npx",
|
|
47
|
+
"args": ["-y", "phaedo-mcp"],
|
|
48
|
+
"env": { "PHAEDO_FINGERPRINT": "sample" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If Claude Desktop reports that the server failed to start, it couldn't find
|
|
55
|
+
`npx`. Replace `"npx"` with the full path that this prints:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
which npx
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then start a new chat and ask: *"Load my Phaedo profile."* To stop using the
|
|
62
|
+
example, delete the `env` line in Claude Desktop, or in Claude Code run
|
|
63
|
+
`claude mcp remove -s user phaedo` and add it again without `-e …`. The server
|
|
64
|
+
will then tell you there's no profile until you pair your phone.
|
|
65
|
+
|
|
66
|
+
## Use your own profile (with the phone app)
|
|
67
|
+
|
|
68
|
+
1. **Get this folder.** On [the GitHub page](https://github.com/phaedo-labs/phaedo-mcp),
|
|
69
|
+
click **Code → Download ZIP**, unzip it, and open the `mcp` folder inside.
|
|
70
|
+
2. **Run the installer.**
|
|
71
|
+
- macOS: double-click **`installers/macos/Install Phaedo MCP.command`**. If macOS
|
|
72
|
+
says it can't be opened, right-click it and choose **Open**.
|
|
73
|
+
- Windows: double-click **`installers/windows/Install Phaedo MCP.bat`**.
|
|
74
|
+
- Linux: run `bash installers/linux/install-phaedo-mcp.sh`.
|
|
75
|
+
|
|
76
|
+
It adds Phaedo to Claude Desktop, Cursor and Claude Code — keeping your other
|
|
77
|
+
settings, and backing each file up first — then starts pairing.
|
|
78
|
+
3. **Pair with your phone.** Open Phaedo on your phone. On the same Wi-Fi, approve
|
|
79
|
+
**"Phaedo MCP Server"** and check that the code matches the one in Terminal.
|
|
80
|
+
On a different network, tap **Pair with code** and type the code Terminal shows.
|
|
81
|
+
4. **Quit and reopen your AI apps**, start a new chat, and ask: *"Load my Phaedo
|
|
82
|
+
profile."*
|
|
83
|
+
|
|
84
|
+
You can disconnect this computer at any time from the **Agents** screen on your
|
|
85
|
+
phone.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Reference
|
|
90
|
+
|
|
91
|
+
Everything below is for developers: the full tool list, how the profile is
|
|
92
|
+
loaded, and the test suite.
|
|
93
|
+
|
|
94
|
+
The server implements the MCP binding of the Anamnesis protocol
|
|
95
|
+
(`docs/protocol/mcp-binding.md` §3.1):
|
|
12
96
|
|
|
13
97
|
| Surface | Name | Returns |
|
|
14
98
|
|---|---|---|
|
|
@@ -65,10 +149,14 @@ receipts-audit surfaces (the tools table above) shipped on top of these across t
|
|
|
65
149
|
`../docs/roadmap/mcp-revocable-approval-plan.md`. Phone-gated unlock stays a
|
|
66
150
|
deferred enterprise mode behind a config flag.
|
|
67
151
|
|
|
68
|
-
**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
152
|
+
**Conversation capture is opt-in.** The MCP protocol never hands a server the
|
|
153
|
+
conversation, so the server itself learns nothing from your chats (binding §5).
|
|
154
|
+
Consultation, escalation and `phaedo_record_outcome` record *decisions*, not
|
|
155
|
+
conversation text. Capture happens only through things you turn on: the Claude
|
|
156
|
+
Code hooks (`npm run setup:code-hook`; see *Capture from Claude Code / Cowork*
|
|
157
|
+
below) and, on a Mac, the separately installed accessibility reader that feeds
|
|
158
|
+
`capture-a11y.js`. Both read on this computer, keep only signal names and
|
|
159
|
+
counts, and discard the text.
|
|
72
160
|
Local **stdio** only; **standard** mode only (enhanced-privacy/Stained-Glass and
|
|
73
161
|
remote HTTP/SSE are out of this cut).
|
|
74
162
|
|
|
@@ -129,9 +217,13 @@ foregrounded/reachable at the configured `endpoint`. *(You can still hand-copy
|
|
|
129
217
|
`pairing.sample.json` → `pairing.json` from the extension's `phaedo_local_pairing`
|
|
130
218
|
if you prefer.)*
|
|
131
219
|
|
|
132
|
-
**Phase 1a — local JSON
|
|
133
|
-
|
|
134
|
-
|
|
220
|
+
**Phase 1a — local JSON.** With no pairing record, reads a **vault payload**
|
|
221
|
+
from `PHAEDO_FINGERPRINT` (a path, or the word `sample` for the shipped
|
|
222
|
+
`mcp/sample-fingerprint.json`), then `mcp/fingerprint.json`. With neither, there
|
|
223
|
+
is **no profile**: every tool returns a plain-language `decryption_failed` error
|
|
224
|
+
saying so. The sample is never served unless asked for by name — until 0.3.1 it
|
|
225
|
+
was the silent fallback, so a fresh `npx phaedo-mcp` injected a made-up person's
|
|
226
|
+
style without saying so. The file looks like this:
|
|
135
227
|
```json
|
|
136
228
|
{
|
|
137
229
|
"phaedo_fingerprint": { "layers": { ... }, "fingerprint_id": "...", "persona_strength": 0.6 },
|
|
@@ -223,6 +315,35 @@ detectors, and a model call can never break or stall a session (30s budget, hook
|
|
|
223
315
|
exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
|
|
224
316
|
Dock-launched Claude Desktop, a shell `export` does not.
|
|
225
317
|
|
|
318
|
+
### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
|
|
319
|
+
|
|
320
|
+
`npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
|
|
321
|
+
PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
|
|
322
|
+
(`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
|
|
323
|
+
`eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
|
|
324
|
+
(`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
|
|
325
|
+
§10.6 receipt, `agent_id: claude-code/ship-hook`.
|
|
326
|
+
|
|
327
|
+
- **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
|
|
328
|
+
Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
|
|
329
|
+
`ask`, so the action comes to you with one plain line saying why.
|
|
330
|
+
- **Outcomes are yours, never inferred.** The hook records `approved` only when it has
|
|
331
|
+
evidence you were asked: Claude Code fired PermissionRequest for that command, and the
|
|
332
|
+
command then ran. That holds in every mode except auto / dontAsk, where no person
|
|
333
|
+
answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
|
|
334
|
+
decline). No prompt shown, nothing recorded; the agent is told so, and records your
|
|
335
|
+
reaction only if you give one.
|
|
336
|
+
- **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
|
|
337
|
+
failing or unfinished checks, a large diff (> 1,000 code lines or > 20 files), or a sensitive
|
|
338
|
+
path (CI/deploy config, auth/crypto/secrets by name, or your repo's `.phaedo-sensitive` list,
|
|
339
|
+
read from the base branch) make it high-stakes. It can raise the stakes, never lower them.
|
|
340
|
+
- **Stale profile.** It reads the at-rest profile cache (so it never pings your phone);
|
|
341
|
+
when that copy is over 14 days old the agent tells you once a day, with the refresh:
|
|
342
|
+
`npm run pull` and approve on the phone.
|
|
343
|
+
- **See what it has done:** `npm run ship-readout` (`-- --days 90` to widen).
|
|
344
|
+
- **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
|
|
345
|
+
runs as if the hook weren't there.
|
|
346
|
+
|
|
226
347
|
## Consult at decision points (`phaedo_consult`, §10)
|
|
227
348
|
|
|
228
349
|
Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
|
|
@@ -296,17 +417,21 @@ returns a §10.3 signal. Any failure/timeout/parse error **falls back to the rul
|
|
|
296
417
|
engine**, so the model can only improve on, never break, the floor (and a missing
|
|
297
418
|
model never breaks consultation). The §10.4 boundary is enforced on parse.
|
|
298
419
|
|
|
299
|
-
The **
|
|
300
|
-
`
|
|
301
|
-
|
|
420
|
+
The judge is **off by default** — the rule engine answers. Measured 2026-10-18 against
|
|
421
|
+
one cached profile, `llama3.1:8b` answered a $12k irreversible payment `clarify 0.9` on
|
|
422
|
+
one run and `proceed_with_note 0.9` on the next, where the rules say `clarify` every
|
|
423
|
+
time. Opt in at install time once a model has earned it:
|
|
302
424
|
|
|
303
425
|
```bash
|
|
304
|
-
npm run setup # model judge
|
|
305
|
-
node install.mjs --
|
|
426
|
+
npm run setup # model judge OFF (rule engine) — the default
|
|
427
|
+
node install.mjs --model-judge # ON, auto-detect a local Ollama chat model
|
|
428
|
+
node install.mjs --consult-model qwen2.5:7b # ON, pinned
|
|
306
429
|
node install.mjs --consult-url http://localhost:11434 # custom endpoint
|
|
307
|
-
node install.mjs --no-model-judge # rule engine only
|
|
308
430
|
```
|
|
309
431
|
|
|
432
|
+
Auto-detect prefers `llama3.1:8b` when installed and never picks an extraction
|
|
433
|
+
fine-tune or an embedding model (`nomic-embed-text` cannot answer a chat prompt).
|
|
434
|
+
|
|
310
435
|
Use a **general** chat model (NOT the extraction fine-tune). To run it: `ollama
|
|
311
436
|
pull llama3.1:8b`. Raw env (if configuring a client by hand):
|
|
312
437
|
`PHAEDO_CONSULT_MODEL`, `PHAEDO_CONSULT_MODEL_URL`, `PHAEDO_CONSULT_API_KEY`
|
package/agreement.js
CHANGED
|
@@ -77,6 +77,38 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Split by resolver revision (precedent track Phase 1b) ─────────────────────
|
|
81
|
+
// Which engine answered a receipt: the `resolverRevision` driver the consult stamped (the
|
|
82
|
+
// rules' RESOLVER_REVISION, or `judge:<model>`). A matched pre-flight is a standing rule
|
|
83
|
+
// answering, not the inferred path. Anything else predates the stamp (2026-10-20) or carried
|
|
84
|
+
// no inferred prediction (an abstain) — "not recorded", never guessed.
|
|
85
|
+
export const NOT_RECORDED = 'not recorded';
|
|
86
|
+
export function revisionOf(r) {
|
|
87
|
+
const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'resolverRevision') : null;
|
|
88
|
+
if (d && typeof d.value === 'string' && d.value) return d.value;
|
|
89
|
+
if (r?.via === 'phaedo_check_authorization') return 'standing rule (pre-flight)';
|
|
90
|
+
return NOT_RECORDED;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* (receipts, { windowDays, now }) -> { [revision]: overall-bucket }, so a behaviour change
|
|
95
|
+
* shows up as two rates instead of one blurred one. Same scoring as computeAgreement.
|
|
96
|
+
*/
|
|
97
|
+
export function computeAgreementByRevision(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now() } = {}) {
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const r of Array.isArray(receipts) ? receipts : []) {
|
|
100
|
+
const k = revisionOf(r);
|
|
101
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
102
|
+
groups.get(k).push(r);
|
|
103
|
+
}
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [k, rs] of groups) {
|
|
106
|
+
const rep = computeAgreement(rs, { windowDays, now });
|
|
107
|
+
if (rep.window.in_window > 0) out[k] = rep.overall;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
// ── The transportable summary (receipts digest → phone) ───────────────────────
|
|
81
113
|
// `computeAgreement` is the full local report; this is the bounded slice that rides
|
|
82
114
|
// the `agent_to_phone` receipts digest so the subject can see how well their oracle
|
|
@@ -119,9 +151,23 @@ export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, no
|
|
|
119
151
|
// Decrypts the local receipt store with the local key; nothing leaves the device.
|
|
120
152
|
// NOT product UX and NOT fed back into deference (see header).
|
|
121
153
|
if (process.argv[1] && process.argv[1].endsWith('agreement.js')) {
|
|
122
|
-
const
|
|
154
|
+
const args = process.argv.slice(2);
|
|
155
|
+
const byRevision = args.includes('--by-revision');
|
|
156
|
+
const days = Number(args.find((a) => /^\d+$/.test(a))) || DEFAULT_WINDOW_DAYS;
|
|
123
157
|
Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
|
|
124
|
-
const
|
|
158
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
159
|
+
if (byRevision) {
|
|
160
|
+
const split = computeAgreementByRevision(receipts, { windowDays: days });
|
|
161
|
+
console.log(`Agreement by rule-engine version — last ${days}d\n`);
|
|
162
|
+
const names = Object.keys(split).sort((a, b) => (a === NOT_RECORDED) - (b === NOT_RECORDED) || a.localeCompare(b));
|
|
163
|
+
if (!names.length) console.log('No receipts in the window.');
|
|
164
|
+
for (const k of names) {
|
|
165
|
+
const b = split[k];
|
|
166
|
+
console.log(`${k.padEnd(34)} rate ${b.rate === null ? ' — ' : (b.rate * 100).toFixed(1) + '%'} (${b.agreements}✓ ${b.disagreements}✗ of ${b.scored} scored · ${b.non_directional} non-directional · ${b.awaiting_user_action} awaiting)`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const report = computeAgreement(receipts, { windowDays: days });
|
|
125
171
|
const { overall: o, window: w } = report;
|
|
126
172
|
console.log(`Agreement over the last ${w.days}d — ${w.in_window}/${w.total_receipts} receipts in window\n`);
|
|
127
173
|
const line = (label, b) =>
|
package/consult-core.js
CHANGED
|
@@ -356,6 +356,68 @@ function calibrate(vd, coverage, signalConfidence = 0) {
|
|
|
356
356
|
|
|
357
357
|
const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
|
|
358
358
|
|
|
359
|
+
// ── Amount bands (precedent track G8 B1) ──────────────────────────────────────
|
|
360
|
+
// Until this, the inferred path never read `amount`: $12 and $12,000 answered alike
|
|
361
|
+
// unless the agent ALSO labelled the magnitude, and only authored §10.5 rules saw money.
|
|
362
|
+
// Now an amount sets a FLOOR under the magnitude the inferred path reasons about.
|
|
363
|
+
//
|
|
364
|
+
// Band edges are the subject's own numbers wherever they exist: every amount bound on an
|
|
365
|
+
// authored rule (amount_gt / amount_lt / amount_lte — a grant's ceiling says what is small
|
|
366
|
+
// to them as surely as a prohibition's floor says what is large). Rules scoped to the
|
|
367
|
+
// action's domain (or unscoped) win; failing those, any authored amount anywhere; failing
|
|
368
|
+
// that, DEFAULT_AMOUNT_EDGES — a stated default, recorded as such on the receipt, replaced
|
|
369
|
+
// by the subject's first money rule.
|
|
370
|
+
//
|
|
371
|
+
// CAUTION-ONLY, by construction: the floor is max()'d with the declared magnitude, so it
|
|
372
|
+
// can raise the stakes and never lower them. And it feeds the INFERRED path only — §10.5
|
|
373
|
+
// matching keeps reading the declared magnitude, so an authored grant's `magnitude_max`
|
|
374
|
+
// means exactly what the subject wrote.
|
|
375
|
+
export const DEFAULT_AMOUNT_EDGES = [100, 1000];
|
|
376
|
+
|
|
377
|
+
// ── Resolver revision (precedent track Phase 1b) ──────────────────────────────
|
|
378
|
+
// Which version of the inferred path answered. Stamped on every inferred receipt (as a
|
|
379
|
+
// `resolverRevision` driver — local provenance, never in the response) so agreement can be
|
|
380
|
+
// split before/after a behaviour change instead of blurring into one number.
|
|
381
|
+
//
|
|
382
|
+
// BUMP THIS WHENEVER THE INFERRED PATH'S ANSWERS CHANGE. You will not be able to forget:
|
|
383
|
+
// test-resolver-revision.mjs fingerprints the answers over a fixed grid and fails, naming
|
|
384
|
+
// this constant, when they move under an unchanged revision. Format: <date>.<what changed>.
|
|
385
|
+
// (receipts before 2026-10-20 carry none — the pre-B1 rules and the first days of B1)
|
|
386
|
+
export const RESOLVER_REVISION = '2026-10-19.amount-bands';
|
|
387
|
+
|
|
388
|
+
function ruleAmounts(r) {
|
|
389
|
+
return [r?.amountGt, r?.amountLt, r?.amountLte].filter((x) => typeof x === 'number' && Number.isFinite(x) && x > 0);
|
|
390
|
+
}
|
|
391
|
+
function ruleCoversDomain(r, domain) {
|
|
392
|
+
const ds = Array.isArray(r?.domains) ? r.domains : [];
|
|
393
|
+
if (!ds.length) return true; // unscoped → speaks for every domain
|
|
394
|
+
if (!domain) return false;
|
|
395
|
+
const canon = canonicalDomain(domain);
|
|
396
|
+
return ds.some((d) => domain.includes(d) || (canon && canonicalDomain(d) === canon));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The band edges for this action: { edges: [low, high] (ascending, low may equal high), basis }. */
|
|
400
|
+
export function amountEdges(policies, domain) {
|
|
401
|
+
const list = Array.isArray(policies) ? policies : [];
|
|
402
|
+
const scoped = list.filter((r) => ruleCoversDomain(r, domain)).flatMap(ruleAmounts);
|
|
403
|
+
const any = scoped.length ? scoped : list.flatMap(ruleAmounts);
|
|
404
|
+
if (!any.length) return { edges: DEFAULT_AMOUNT_EDGES.slice(), basis: null };
|
|
405
|
+
return { edges: [Math.min(...any), Math.max(...any)], basis: 'self_reported' };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The magnitude floor an amount implies, or null when it implies none.
|
|
410
|
+
* Above the high edge → `high`; above the low edge → `medium`. With a single authored
|
|
411
|
+
* number ("escalate above $1,000") both edges are that number: above it is `high`.
|
|
412
|
+
*/
|
|
413
|
+
export function amountFloor(n, policies) {
|
|
414
|
+
const a = n?.amount;
|
|
415
|
+
if (typeof a !== 'number' || !Number.isFinite(a) || a <= 0) return null;
|
|
416
|
+
const { edges: [lo, hi], basis } = amountEdges(policies, n.domain);
|
|
417
|
+
const band = a > hi ? 'high' : a > lo ? 'medium' : null;
|
|
418
|
+
return band ? { band, rank: magnitudeRank(band), basis } : null;
|
|
419
|
+
}
|
|
420
|
+
|
|
359
421
|
// ── Request validation/normalization (§10.2) ──────────────────────────────────
|
|
360
422
|
export function normalizeRequest(request) {
|
|
361
423
|
const type = request.consultation_type;
|
|
@@ -411,7 +473,8 @@ const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
|
|
|
411
473
|
function resolveActionApproval(vd, n, suppressed) {
|
|
412
474
|
const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
|
|
413
475
|
const irreversible = n.reversible === false;
|
|
414
|
-
|
|
476
|
+
// Declared stakes, raised (never lowered) by what the amount means to this subject.
|
|
477
|
+
const mag = Math.max(magnitudeRank(n.magnitude), n.amountBand ? n.amountBand.rank : 0);
|
|
415
478
|
const thinEvidence = n.evidenceProvided.length === 0;
|
|
416
479
|
|
|
417
480
|
let signal;
|
|
@@ -481,7 +544,7 @@ function resolveVoiceDraft() {
|
|
|
481
544
|
// sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
|
|
482
545
|
// Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
|
|
483
546
|
// internally so callers pass exactly what they passed to the consult.
|
|
484
|
-
export function consultDrivers(vd, request) {
|
|
547
|
+
export function consultDrivers(vd, request, opts = {}) {
|
|
485
548
|
let n;
|
|
486
549
|
try { n = normalizeRequest(request || {}); } catch { return []; }
|
|
487
550
|
const keys = RELEVANT_CUES[n.type];
|
|
@@ -499,6 +562,13 @@ export function consultDrivers(vd, request) {
|
|
|
499
562
|
const a = readAmbiguity(vd, n.domain, suppressed);
|
|
500
563
|
if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
|
|
501
564
|
}
|
|
565
|
+
// The amount band, when it raised the stakes. basis `self_reported` = the subject's own
|
|
566
|
+
// thresholds; null = the stated default (no subject evidence behind it).
|
|
567
|
+
if (n.type === 'action_approval') {
|
|
568
|
+
let band = null;
|
|
569
|
+
try { band = amountFloor(n, loadPolicies(vd, opts)); } catch { /* provenance is non-critical */ }
|
|
570
|
+
if (band) drivers.push({ cue: 'amountBand', value: band.band, confidence: 1, basis: band.basis });
|
|
571
|
+
}
|
|
502
572
|
return drivers;
|
|
503
573
|
}
|
|
504
574
|
|
|
@@ -796,7 +866,11 @@ const policyCtx = (opts = {}) => ({ now: opts.now, clientId: opts.clientId ?? nu
|
|
|
796
866
|
export function resolveConsultationCore(vd, request = {}, opts = {}) {
|
|
797
867
|
assertVaultUsable(vd);
|
|
798
868
|
const n = normalizeRequest(request);
|
|
799
|
-
|
|
869
|
+
const policies = loadPolicies(vd, opts);
|
|
870
|
+
// G8 B1: the amount's band, read against the subject's own thresholds. On `n` so the
|
|
871
|
+
// resolver sees it; applyPolicies does not read it (§10.5 matches the declared magnitude).
|
|
872
|
+
n.amountBand = amountFloor(n, policies);
|
|
873
|
+
return applyPolicies(ruleResponse(vd, n), n, policies, policyCtx(opts));
|
|
800
874
|
}
|
|
801
875
|
|
|
802
876
|
// Back-compat alias: the prior public name of the sync rule entry. Kept so existing
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Consult runtime — the I/O half of a consultation, shared by every caller that resolves
|
|
2
|
+
// one: the MCP server (server.js) and the Claude Code ship hook (hooks/pre-ship.mjs).
|
|
3
|
+
//
|
|
4
|
+
// One copy, on purpose. This repo has paid twice for two consult paths that drifted — the
|
|
5
|
+
// stale-authority guard once lived on phaedo_check_authorization alone, and the tools an
|
|
6
|
+
// agent actually used answered from a revoked cache with no downgrade. A hook that loaded
|
|
7
|
+
// policies or delegation signals its own way would be the third time.
|
|
8
|
+
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { readFileSync, existsSync } from 'fs';
|
|
11
|
+
import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
|
|
12
|
+
import { readServingClientId } from './phone-source.js';
|
|
13
|
+
import { consultOptsFromEnv, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
|
|
14
|
+
import { defaultStateDir } from './cache.js';
|
|
15
|
+
import { readReceipts } from './receipts.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The consult options for a process rooted at `baseDir` (the mcp/ directory): env config,
|
|
19
|
+
* the subject's deference policies, and this install's own paired-client id.
|
|
20
|
+
*/
|
|
21
|
+
export function buildConsultOpts(baseDir, env = process.env, { warn = (m) => process.stderr.write(m) } = {}) {
|
|
22
|
+
const opts = consultOptsFromEnv(env);
|
|
23
|
+
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
|
|
24
|
+
// the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
|
|
25
|
+
// config: drop the file in and restart the client. (Portable vault policies in
|
|
26
|
+
// phaedo_consult_policies always apply too, regardless of this file.)
|
|
27
|
+
const policiesPath = env.PHAEDO_CONSULT_POLICIES || join(baseDir, 'consult-policies.json');
|
|
28
|
+
if (existsSync(policiesPath)) {
|
|
29
|
+
try { opts.policies = JSON.parse(readFileSync(policiesPath, 'utf8')); }
|
|
30
|
+
catch (e) { warn(`[phaedo-mcp] ignoring malformed consult policies (${policiesPath}): ${e.message}\n`); }
|
|
31
|
+
}
|
|
32
|
+
// §10.5 `agents`: this process's OWN paired-client identity, resolved once at startup.
|
|
33
|
+
// One server serves one paired client, so it never asks a caller who is calling — it
|
|
34
|
+
// reads who it IS. Null (unpaired / unreadable) makes `agents`-scoped RELAXING rules
|
|
35
|
+
// inert, which is the fail-safe direction; the server still answers everything else.
|
|
36
|
+
opts.clientId = readServingClientId(baseDir);
|
|
37
|
+
return opts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Load the fingerprint for a consult and fold in the act-as-me channel: delegation
|
|
41
|
+
// signals derived from this subject's own override history (§10.6 receipts). The
|
|
42
|
+
// origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
|
|
43
|
+
// the same dimension. Best-effort — if receipts are unreadable, the consult still
|
|
44
|
+
// answers from the plain fingerprint (delegation learning never breaks a consultation).
|
|
45
|
+
export async function loadConsultVd(baseDir, { preferCache = false, warn = (m) => process.stderr.write(m) } = {}) {
|
|
46
|
+
// preferCache (escalation, ship hook): read the at-rest cache instead of a live phone
|
|
47
|
+
// pull, so the caller doesn't add a fingerprint-read AUTHORIZE prompt on the phone.
|
|
48
|
+
const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
|
|
49
|
+
try {
|
|
50
|
+
// Two delegation sources, both origin-aware: live override history (every override)
|
|
51
|
+
// + the subject's ENDORSED, portable delegation rules (authored_delegation). The
|
|
52
|
+
// authored rule thus shapes consult too, not only the §9 injection it already renders in.
|
|
53
|
+
const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
|
|
54
|
+
.concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
|
|
55
|
+
return withDelegationSignals(vd, signals);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
warn(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
|
|
58
|
+
return vd;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/consult.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
|
|
18
18
|
deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
|
|
19
19
|
authoredDelegationSignals,
|
|
20
|
+
RESOLVER_REVISION,
|
|
20
21
|
} from './consult-core.js';
|
|
21
22
|
|
|
22
23
|
// Re-export the core surface so existing importers (server.js, tests,
|
|
@@ -110,6 +111,24 @@ export function consultOptsFromEnv(env = process.env) {
|
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// Which engine answered a response this module returned: the opt-in model judge, or the
|
|
115
|
+
// deterministic rules. A WeakMap rather than a field because the §10.3 response shape is
|
|
116
|
+
// closed (additionalProperties:false) and this is local provenance, never for the agent.
|
|
117
|
+
const JUDGED_BY = new WeakMap();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append the `resolverRevision` provenance driver to a consult's drivers: the rules'
|
|
121
|
+
* RESOLVER_REVISION, or `judge:<model>` when the model judge answered. Only when there are
|
|
122
|
+
* drivers already — the receipt schema documents drivers as absent for deterministic
|
|
123
|
+
* pre-flights and abstains, which carry no inferred prediction to attribute.
|
|
124
|
+
* Pass the response EXACTLY as resolveConsultation returned it (before any copy).
|
|
125
|
+
*/
|
|
126
|
+
export function withRevision(drivers, response) {
|
|
127
|
+
if (!Array.isArray(drivers) || !drivers.length) return Array.isArray(drivers) ? drivers : [];
|
|
128
|
+
const judge = response && typeof response === 'object' ? JUDGED_BY.get(response) : undefined;
|
|
129
|
+
return [...drivers, { cue: 'resolverRevision', value: judge ? `judge:${judge}` : RESOLVER_REVISION, confidence: null, basis: null }];
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
// Public async entry: model judge when configured, else the deterministic core.
|
|
114
133
|
// voice_draft always takes the (redirect) core path. Authored policies (§10.5)
|
|
115
134
|
// override either result, so a standing guardrail always wins. Never throws except
|
|
@@ -132,7 +151,9 @@ export async function resolveConsultation(vd, request = {}, opts = {}) {
|
|
|
132
151
|
// Authored guardrails override the model judge too (applied last so they win).
|
|
133
152
|
// Same §10.5 context as the deterministic path — including the serving
|
|
134
153
|
// `client_id`, so `agents` scoping is not bypassed by configuring a model.
|
|
135
|
-
|
|
154
|
+
const out = applyPolicies(judged, n, loadPolicies(vd, opts), { now: opts.now, clientId: opts.clientId ?? null });
|
|
155
|
+
if (out && typeof out === 'object') JUDGED_BY.set(out, String(opts.model));
|
|
156
|
+
return out;
|
|
136
157
|
} catch (err) {
|
|
137
158
|
process.stderr.write?.(`[phaedo-consult] model judge fell back to rules: ${err.message}\n`);
|
|
138
159
|
}
|
package/delegation-sync.js
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
// principal for §10.5 `agents` scoping, so these two properties are in tension until
|
|
16
16
|
// the extension's drain gains the phone's try-each-verified-client loop.
|
|
17
17
|
//
|
|
18
|
+
// RESOLVED BY THE COURIER (verified 2026-09-24): the extension still cannot open an
|
|
19
|
+
// independently-paired MCP's deposit, but it no longer has to. The PHONE drains this lane
|
|
20
|
+
// with every verified client's key (mobile/src/lib/fpSync.ts drainAgentSuggestions) and
|
|
21
|
+
// forwards the suggestions inside its to_ext deposit, which since spec v0.1.70 reaches EVERY
|
|
22
|
+
// paired browser under that browser's own key. Independent pairing is the supported setup.
|
|
23
|
+
//
|
|
18
24
|
// The extension union-merges
|
|
19
25
|
// the suggestions (sync-merge.js mergeAgentSuggestions) into suggested_rules, where the
|
|
20
26
|
// existing popup review card surfaces them for endorsement.
|
package/fingerprint-source.js
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// Phase 1b (phone): if a pairing record is configured (PHAEDO_PAIRING or
|
|
6
6
|
// mcp/pairing.json), authorize → pull /v1/profile → unwrap the §7.2 envelope
|
|
7
7
|
// from the paired phone (phone-source.js). Spec-faithful; no plaintext at rest.
|
|
8
|
-
// Phase 1a (local): otherwise read a local vault-payload JSON
|
|
9
|
-
//
|
|
8
|
+
// Phase 1a (local): otherwise read a local vault-payload JSON. With neither, there is
|
|
9
|
+
// no profile — tools say so (NO_PROFILE_MESSAGE); the shipped sample is opt-in only.
|
|
10
10
|
|
|
11
11
|
import { readFile } from 'fs/promises';
|
|
12
|
-
import { resolve } from 'path';
|
|
12
|
+
import { resolve, dirname } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
13
14
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
14
15
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
15
16
|
import { foldLocalCapture, isLocalCaptureOwner } from './capture-local.js';
|
|
16
17
|
import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
|
|
18
|
+
import { PhaedoError } from './errors.js';
|
|
17
19
|
|
|
18
20
|
// §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
|
|
19
21
|
// A stored fingerprint from a producer that predates identity stamping (or whose
|
|
@@ -74,14 +76,33 @@ export function foldConfigHome(vd) {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
// Resolution order for the local fixture (1a):
|
|
77
|
-
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
|
|
79
|
+
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path, or `sample`)
|
|
78
80
|
// 2. ./fingerprint.json next to the server
|
|
79
|
-
//
|
|
81
|
+
// — nothing else. There is deliberately NO silent fallback to the shipped sample.
|
|
82
|
+
//
|
|
83
|
+
// NO PROFILE IS AN ANSWER, NOT A DEMO. Until 2026-10-20 an unpaired install with no file fell
|
|
84
|
+
// back to sample-fingerprint.json — so a stranger who ran `npx phaedo-mcp` had a made-up
|
|
85
|
+
// person's style injected into their assistant, with nothing anywhere saying so (the response
|
|
86
|
+
// carried `fingerprint_id: sample-0000…`, which no human reads). The sample is still one word
|
|
87
|
+
// away for anyone who wants to try it — PHAEDO_FINGERPRINT=sample — but it has to be asked for.
|
|
88
|
+
const SAMPLE_KEYWORD = 'sample';
|
|
89
|
+
// The sample ships beside this module, whatever base dir the caller serves from.
|
|
90
|
+
const SAMPLE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'sample-fingerprint.json');
|
|
80
91
|
function resolveSourcePath(baseDir) {
|
|
81
|
-
|
|
92
|
+
const env = process.env.PHAEDO_FINGERPRINT;
|
|
93
|
+
if (env === SAMPLE_KEYWORD) return SAMPLE_PATH;
|
|
94
|
+
if (env) return resolve(process.cwd(), env);
|
|
82
95
|
return resolve(baseDir, 'fingerprint.json');
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
// Read by the assistant (a tool error) and, through it, by the person — so plain words, and
|
|
99
|
+
// it says what did NOT happen as well as what to do.
|
|
100
|
+
export const NO_PROFILE_MESSAGE =
|
|
101
|
+
'No Phaedo profile is set up on this computer, so there is nothing to load and Phaedo has ' +
|
|
102
|
+
'not changed how the assistant responds. A profile comes from the Phaedo phone app, which is ' +
|
|
103
|
+
'not open to the public yet (https://phaedo.so to hear when it is). To try Phaedo with a made-up example profile ' +
|
|
104
|
+
'instead, set PHAEDO_FINGERPRINT=sample in this server\'s settings.';
|
|
105
|
+
|
|
85
106
|
// Returns the vault payload:
|
|
86
107
|
// { phaedo_fingerprint, phaedo_behavioral_signals, phaedo_linguistic_profile,
|
|
87
108
|
// phaedo_session_metrics }
|
|
@@ -154,9 +175,13 @@ export async function loadFingerprint(baseDir) {
|
|
|
154
175
|
let raw;
|
|
155
176
|
try {
|
|
156
177
|
raw = await readFile(path, 'utf8');
|
|
157
|
-
} catch {
|
|
158
|
-
//
|
|
159
|
-
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Only the default location may be absent quietly. A path the user NAMED that doesn't
|
|
180
|
+
// read is their mistake to see, not "no profile".
|
|
181
|
+
if (!process.env.PHAEDO_FINGERPRINT && err?.code === 'ENOENT') {
|
|
182
|
+
throw new PhaedoError('decryption_failed', NO_PROFILE_MESSAGE);
|
|
183
|
+
}
|
|
184
|
+
throw new PhaedoError('decryption_failed', `Could not read the Phaedo profile at ${path} (${err?.code || err?.message || err}).`);
|
|
160
185
|
}
|
|
161
186
|
const vd = JSON.parse(raw);
|
|
162
187
|
if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
|