phaedo-mcp 0.2.0 → 0.2.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/consult-core.js +31 -13
- package/consult.js +17 -2
- package/package.json +4 -3
- package/pairing.js +5 -1
- package/phone-source.js +13 -1
- package/receipts.js +36 -14
- package/server.js +1 -1
- package/server.json +7 -6
package/consult-core.js
CHANGED
|
@@ -500,22 +500,42 @@ export function consultDrivers(vd, request) {
|
|
|
500
500
|
// action's domain when it had one, so a financial override never reshapes writing.
|
|
501
501
|
const PERMISSIVE_SIGNAL = new Set(['proceed', 'proceed_with_note']);
|
|
502
502
|
const CAUTIOUS_SIGNAL = new Set(['escalate', 'decline', 'clarify']);
|
|
503
|
-
|
|
503
|
+
|
|
504
|
+
// A delegated correction's signed weight — direction read from the subject's action
|
|
505
|
+
// RELATIVE to the oracle's signal, NOT the signal class alone. This is the fix for a
|
|
506
|
+
// direction inversion: a lesson requires DISAGREEMENT between signal and action.
|
|
507
|
+
// permissive + rejected → +1 said go, subject didn't proceed → wants CAUTION
|
|
508
|
+
// cautious + approved → -1 said hold, subject proceeded → wants BOLD
|
|
509
|
+
// modified → ±0.5 (ambiguous change; signal-relative, half weight)
|
|
510
|
+
// permissive + approved → 0 AGREEMENT (both proceed) — teaches nothing
|
|
511
|
+
// cautious + rejected → 0 AGREEMENT (both hold) — teaches nothing
|
|
512
|
+
// This keeps the anti-obedience rail intact (a rubber-stamp `approved` on a `proceed`
|
|
513
|
+
// signal is agreement, not a "be bolder" lesson) while no longer misreading the
|
|
514
|
+
// subject AGREEING with a cautious signal as a demand to be bolder — which was
|
|
515
|
+
// eroding the very guardrails the subject was upholding (agreement.js counts
|
|
516
|
+
// decline+rejected as agreement; this now matches).
|
|
517
|
+
function delegationCorrection(signal, userAction) {
|
|
518
|
+
const permissive = PERMISSIVE_SIGNAL.has(signal);
|
|
519
|
+
const cautious = CAUTIOUS_SIGNAL.has(signal);
|
|
520
|
+
if (!permissive && !cautious) return 0; // abstain / unknown signal
|
|
521
|
+
if (userAction === 'modified') return permissive ? 0.5 : -0.5;
|
|
522
|
+
if (permissive && userAction === 'rejected') return 1; // override toward caution
|
|
523
|
+
if (cautious && userAction === 'approved') return -1; // override toward bold (proceeded anyway)
|
|
524
|
+
return 0; // agreement / unknown → no lesson
|
|
525
|
+
}
|
|
504
526
|
|
|
505
527
|
export function deriveDelegationSignals(receipts, { now = new Date().toISOString(), floor = 1 } = {}) {
|
|
506
528
|
const tally = new Map(); // `${cue}|${domain}` → { cue, domain, sum, n }
|
|
507
529
|
for (const r of (Array.isArray(receipts) ? receipts : [])) {
|
|
508
|
-
const w = OVERRIDE_WEIGHT[r && r.user_action];
|
|
509
|
-
if (!w) continue; // only rejected/modified teach
|
|
510
530
|
const sig = r?.response?.signal;
|
|
511
|
-
const
|
|
512
|
-
if (!
|
|
531
|
+
const contribution = delegationCorrection(sig, r && r.user_action); // signed; 0 = agreement/no lesson
|
|
532
|
+
if (!contribution) continue;
|
|
513
533
|
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
|
|
514
534
|
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
515
535
|
if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later)
|
|
516
536
|
const k = `${d.cue}|${domain}`;
|
|
517
537
|
const t = tally.get(k) || { cue: d.cue, domain, sum: 0, n: 0 };
|
|
518
|
-
t.sum +=
|
|
538
|
+
t.sum += contribution; t.n += 1;
|
|
519
539
|
tally.set(k, t);
|
|
520
540
|
}
|
|
521
541
|
}
|
|
@@ -600,22 +620,20 @@ function delegationText(dimension, dir, domain) {
|
|
|
600
620
|
|
|
601
621
|
export function buildDelegationPromotions(receipts, fp, { now = new Date().toISOString(), floor = DELEGATION_PROMOTION_FLOOR } = {}) {
|
|
602
622
|
// Aggregate overrides per CANONICAL (dimension, domain): signed weight + count. Same
|
|
603
|
-
//
|
|
604
|
-
//
|
|
623
|
+
// signal-relative direction rule as deriveDelegationSignals — a lesson requires the
|
|
624
|
+
// subject's action to DISAGREE with the oracle's signal; agreement teaches nothing.
|
|
605
625
|
const tally = new Map(); // `${dimension}|${domain}` → { dimension, domain, sum, n }
|
|
606
626
|
for (const r of (Array.isArray(receipts) ? receipts : [])) {
|
|
607
|
-
const w = OVERRIDE_WEIGHT[r && r.user_action];
|
|
608
|
-
if (!w) continue;
|
|
609
627
|
const sig = r?.response?.signal;
|
|
610
|
-
const
|
|
611
|
-
if (!
|
|
628
|
+
const contribution = delegationCorrection(sig, r && r.user_action);
|
|
629
|
+
if (!contribution) continue;
|
|
612
630
|
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
|
|
613
631
|
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
614
632
|
const dim = CUE_DIMENSION[d && d.cue];
|
|
615
633
|
if (!dim) continue;
|
|
616
634
|
const k = `${dim}|${domain}`;
|
|
617
635
|
const t = tally.get(k) || { dimension: dim, domain, sum: 0, n: 0 };
|
|
618
|
-
t.sum +=
|
|
636
|
+
t.sum += contribution; t.n += 1; tally.set(k, t);
|
|
619
637
|
}
|
|
620
638
|
}
|
|
621
639
|
const keyOf = (dim, domain) => `${dim}|${domain || ''}`;
|
package/consult.js
CHANGED
|
@@ -72,10 +72,25 @@ async function modelJudge(vd, n, opts) {
|
|
|
72
72
|
if (!res.ok) throw new Error(`model endpoint ${res.status}`);
|
|
73
73
|
const data = await res.json();
|
|
74
74
|
const out = parseJudge(data?.choices?.[0]?.message?.content || '');
|
|
75
|
-
// §10.4 enforced
|
|
76
|
-
|
|
75
|
+
// §10.4 enforced STRUCTURALLY: the model's free-text rationale_hint is NOT passed
|
|
76
|
+
// through — a misbehaving or prompt-injected judge could otherwise emit verbatim
|
|
77
|
+
// fingerprint/persona text there (the JUDGE_SYSTEM "never quote" line is a request,
|
|
78
|
+
// not enforcement). We keep the model's decision (signal/confidence/deference) and
|
|
79
|
+
// substitute a fixed, persona-free hint keyed to the signal. parseJudge still
|
|
80
|
+
// requires a non-empty rationale_hint so the model must emit well-formed JSON.
|
|
81
|
+
return shape(n.type, out.signal, out.confidence ?? 0.6, MODEL_SAFE_HINT[out.signal] || 'Your decision pattern bears on this action.', out.deference_level);
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
// Persona-free, signal-keyed hints for the model path (see §10.4 note above).
|
|
85
|
+
const MODEL_SAFE_HINT = {
|
|
86
|
+
proceed: 'Your decision pattern supports proceeding here.',
|
|
87
|
+
proceed_with_note: 'Your decision pattern supports proceeding, with a light note.',
|
|
88
|
+
clarify: 'Your decision pattern suggests clarifying before acting.',
|
|
89
|
+
escalate: 'Your decision pattern suggests holding for your explicit go-ahead.',
|
|
90
|
+
decline: 'Your decision pattern leans against this action.',
|
|
91
|
+
insufficient_signal: 'Your fingerprint does not clearly speak to this action.',
|
|
92
|
+
};
|
|
93
|
+
|
|
79
94
|
// ── Config + orchestrator ─────────────────────────────────────────────────────
|
|
80
95
|
// Local-only by default. `PHAEDO_CONSULT_MODEL` opts INTO the model judge (point
|
|
81
96
|
// it at a general chat model in Ollama, e.g. `llama3.1:8b` — NOT the extraction
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
|
|
8
|
-
"mcpName": "io.github.
|
|
8
|
+
"mcpName": "io.github.phaedo-labs/phaedo-mcp",
|
|
9
9
|
"homepage": "https://phaedo.so",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/
|
|
12
|
+
"url": "git+https://github.com/phaedo-labs/phaedo-mcp.git",
|
|
13
|
+
"directory": "mcp"
|
|
13
14
|
},
|
|
14
15
|
"keywords": [
|
|
15
16
|
"mcp",
|
package/pairing.js
CHANGED
|
@@ -124,6 +124,7 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
|
|
|
124
124
|
// Long-term ECDH → pair_key; SAS (the MITM anchor) over both pubkeys.
|
|
125
125
|
const shared = await Identity.deriveSharedWithPeer(vaultPub);
|
|
126
126
|
const pairKey = await Kdf.derivePairKey(shared, body.client_id);
|
|
127
|
+
shared.fill(0); // raw ECDH secret consumed — zero it (matches phone-source.js key hygiene)
|
|
127
128
|
const sas = Kdf.formatSAS(await Kdf.deriveSAS(extPub, vaultPub));
|
|
128
129
|
|
|
129
130
|
log('');
|
|
@@ -148,11 +149,14 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
|
|
|
148
149
|
// escalation/fp-mailbox deposits get 401.
|
|
149
150
|
const relaySecret = body.relay_device_secret || null;
|
|
150
151
|
|
|
152
|
+
const pairKeyEnc = b64uEnc(pairKey);
|
|
153
|
+
pairKey.fill(0); // persisted as base64url below — zero the long-lived key buffer
|
|
154
|
+
|
|
151
155
|
return {
|
|
152
156
|
endpoint,
|
|
153
157
|
client_id: body.client_id,
|
|
154
158
|
client_secret: body.client_secret,
|
|
155
|
-
pair_key:
|
|
159
|
+
pair_key: pairKeyEnc,
|
|
156
160
|
vault_pub: body.vault_pub,
|
|
157
161
|
sas,
|
|
158
162
|
...(relayId ? { relay_device_id: relayId } : {}),
|
package/phone-source.js
CHANGED
|
@@ -30,7 +30,19 @@ export async function loadPairingRecord(baseDir) {
|
|
|
30
30
|
const path = process.env.PHAEDO_PAIRING
|
|
31
31
|
? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
|
|
32
32
|
: resolve(baseDir, 'pairing.json');
|
|
33
|
-
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(path, 'utf8');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// No pairing file = UNPAIRED (a valid local-only state), not an error. Return
|
|
38
|
+
// null so callers that pass the record to depositEscalation / pollEscalation /
|
|
39
|
+
// the best-effort deposit channels get a graceful "unpaired" instead of an
|
|
40
|
+
// ENOENT throw. (hasPairingRecord() gates the fingerprint-pull path, so it only
|
|
41
|
+
// calls this when the file exists.)
|
|
42
|
+
if (e && e.code === 'ENOENT') return null;
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
const rec = JSON.parse(raw);
|
|
34
46
|
for (const k of ['endpoint', 'client_id', 'client_secret', 'pair_key']) {
|
|
35
47
|
if (!rec[k]) throw new Error(`Pairing record at ${path} is missing "${k}".`);
|
|
36
48
|
}
|
package/receipts.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
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';
|
|
@@ -78,8 +78,26 @@ async function writeStore(stateDir, store, now) {
|
|
|
78
78
|
});
|
|
79
79
|
plaintext.fill(0);
|
|
80
80
|
const p = storePath(stateDir);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Atomic replace: write a temp file then rename over the live store. Receipts are
|
|
82
|
+
// the ONLY copy (readStore throws on a corrupt store) — a crash mid-write must not
|
|
83
|
+
// truncate/corrupt receipts.enc and brick the audit trail + every markOutcome.
|
|
84
|
+
const tmp = `${p}.tmp`;
|
|
85
|
+
await writeFile(tmp, JSON.stringify({ envelope }), { mode: 0o600 });
|
|
86
|
+
await chmod(tmp, 0o600);
|
|
87
|
+
await rename(tmp, p); // atomic on POSIX
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Serialize store mutations per state dir. MCP tool handlers are async and NOT
|
|
91
|
+
// serialized by the SDK, so a concurrent appendReceipt (phaedo_consult) and
|
|
92
|
+
// markOutcome (phaedo_record_outcome/escalation_status) both read the same snapshot
|
|
93
|
+
// and the second write clobbers the first — a lost receipt or lost outcome mark.
|
|
94
|
+
// A per-stateDir promise chain forces read-modify-write to run one at a time.
|
|
95
|
+
const _storeLocks = new Map();
|
|
96
|
+
function withStoreLock(stateDir, fn) {
|
|
97
|
+
const prev = _storeLocks.get(stateDir) || Promise.resolve();
|
|
98
|
+
const next = prev.then(fn, fn); // run regardless of the prior result
|
|
99
|
+
_storeLocks.set(stateDir, next.then(() => {}, () => {})); // a rejection must not break the chain
|
|
100
|
+
return next;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
// Build the §10.6 receipt object from a resolved consultation. Pure. Captures the
|
|
@@ -126,11 +144,13 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
|
|
|
126
144
|
|
|
127
145
|
// Append one receipt; evict oldest beyond the cap. Returns the stored receipt.
|
|
128
146
|
export async function appendReceipt(stateDir, receipt, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
return withStoreLock(stateDir, async () => {
|
|
148
|
+
const store = await readStore(stateDir);
|
|
149
|
+
store.receipts.push(receipt);
|
|
150
|
+
if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
|
|
151
|
+
await writeStore(stateDir, store, now);
|
|
152
|
+
return receipt;
|
|
153
|
+
});
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
// The ONE permitted mutation (§10.6): set user_action on an existing receipt.
|
|
@@ -139,12 +159,14 @@ export async function updateUserAction(stateDir, receiptId, action, { now = Date
|
|
|
139
159
|
if (!USER_ACTIONS.includes(action)) {
|
|
140
160
|
throw new Error(`user_action must be one of: ${USER_ACTIONS.join(', ')}`);
|
|
141
161
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
162
|
+
return withStoreLock(stateDir, async () => {
|
|
163
|
+
const store = await readStore(stateDir);
|
|
164
|
+
const r = store.receipts.find((x) => x.receipt_id === receiptId);
|
|
165
|
+
if (!r) return false;
|
|
166
|
+
r.user_action = action;
|
|
167
|
+
await writeStore(stateDir, store, now);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
// Decrypt and return all receipts (oldest first). The dev/harness read path — and
|
package/server.js
CHANGED
|
@@ -157,7 +157,7 @@ if (existsSync(ESCALATION_POLICY_PATH)) {
|
|
|
157
157
|
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
const server = new McpServer({ name: 'phaedo', version: '0.2.
|
|
160
|
+
const server = new McpServer({ name: 'phaedo', version: '0.2.2' });
|
|
161
161
|
|
|
162
162
|
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
|
|
163
163
|
server.registerTool(
|
package/server.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
-
"name": "io.github.
|
|
4
|
-
"description": "Carry your Phaedo cognitive fingerprint into any MCP client:
|
|
5
|
-
"version": "0.2.
|
|
3
|
+
"name": "io.github.phaedo-labs/phaedo-mcp",
|
|
4
|
+
"description": "Carry your Phaedo cognitive fingerprint into any MCP client: injection, consults, escalation.",
|
|
5
|
+
"version": "0.2.2",
|
|
6
6
|
"repository": {
|
|
7
|
-
"url": "https://github.com/
|
|
8
|
-
"source": "github"
|
|
7
|
+
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
|
+
"source": "github",
|
|
9
|
+
"subfolder": "mcp"
|
|
9
10
|
},
|
|
10
11
|
"packages": [
|
|
11
12
|
{
|
|
12
13
|
"registryType": "npm",
|
|
13
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
15
|
"identifier": "phaedo-mcp",
|
|
15
|
-
"version": "0.2.
|
|
16
|
+
"version": "0.2.2",
|
|
16
17
|
"transport": {
|
|
17
18
|
"type": "stdio"
|
|
18
19
|
}
|