phaedo-mcp 0.2.1 → 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 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
- const OVERRIDE_WEIGHT = { rejected: 1.0, modified: 0.5 };
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 correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0; // +1 cautious / -1 bold
512
- if (!correct) continue; // abstain/unknown → no direction
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 += correct * w; t.n += 1;
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
- // honesty rail + direction rule as deriveDelegationSignals (only rejected/modified;
604
- // direction read from the oracle's own signal).
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 correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0;
611
- if (!correct) continue;
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 += correct * w; t.n += 1; tally.set(k, t);
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 on parse: only the four signal fields ever leave the function.
76
- return shape(n.type, out.signal, out.confidence ?? 0.6, out.rationale_hint, out.deference_level);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://phaedo.so",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git+https://github.com/phaedo-labs/phaedo.git",
12
+ "url": "git+https://github.com/phaedo-labs/phaedo-mcp.git",
13
13
  "directory": "mcp"
14
14
  },
15
15
  "keywords": [
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: b64uEnc(pairKey),
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
- const rec = JSON.parse(await readFile(path, 'utf8'));
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
- await writeFile(p, JSON.stringify({ envelope }), { mode: 0o600 });
82
- await chmod(p, 0o600);
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
- const store = await readStore(stateDir);
130
- store.receipts.push(receipt);
131
- if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
132
- await writeStore(stateDir, store, now);
133
- return receipt;
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
- const store = await readStore(stateDir);
143
- const r = store.receipts.find((x) => x.receipt_id === receiptId);
144
- if (!r) return false;
145
- r.user_action = action;
146
- await writeStore(stateDir, store, now);
147
- return true;
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.1' });
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,10 +1,10 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.phaedo-labs/phaedo-mcp",
4
- "description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.",
5
- "version": "0.2.1",
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/phaedo-labs/phaedo",
7
+ "url": "https://github.com/phaedo-labs/phaedo-mcp",
8
8
  "source": "github",
9
9
  "subfolder": "mcp"
10
10
  },
@@ -13,7 +13,7 @@
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "phaedo-mcp",
16
- "version": "0.2.1",
16
+ "version": "0.2.2",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }