phaedo-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +301 -0
- package/agreement.js +95 -0
- package/build-package.mjs +22 -0
- package/cache.js +76 -0
- package/consult-core.js +439 -0
- package/consult-policies.sample.json +36 -0
- package/consult-policy.js +136 -0
- package/consult.js +118 -0
- package/errors.js +23 -0
- package/fingerprint-source.js +71 -0
- package/hooks/session-start.mjs +48 -0
- package/install.mjs +368 -0
- package/installers/linux/install-phaedo-mcp.sh +32 -0
- package/installers/macos/Install Phaedo MCP.command +37 -0
- package/installers/windows/Install Phaedo MCP.bat +39 -0
- package/lib/node-vault.js +34 -0
- package/lib/phaedo-crypto.js +36 -0
- package/package.json +49 -0
- package/pair.js +29 -0
- package/pairing.js +184 -0
- package/pairing.sample.json +8 -0
- package/phone-source.js +141 -0
- package/policy-editor.html +146 -0
- package/projection.js +115 -0
- package/pull.js +26 -0
- package/receipts.js +211 -0
- package/sample-fingerprint.json +107 -0
- package/server.js +262 -0
- package/server.json +19 -0
- package/vendor/context-block.js +599 -0
- package/vendor/envelope.js +130 -0
- package/vendor/identity.js +122 -0
- package/vendor/kdf.js +114 -0
- package/vendor/protocol.js +56 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Phaedo §10.5 consultation — standing authorizations / deference policies
|
|
2
|
+
// (user-authored guardrails).
|
|
3
|
+
//
|
|
4
|
+
// Lets the user PRE-DECIDE how consultation resolves for given domains / magnitudes
|
|
5
|
+
// / reversibility / numeric thresholds — e.g. "escalate any irreversible financial
|
|
6
|
+
// action over $5,000", "decline autonomous external email", "auto-proceed low-stakes
|
|
7
|
+
// writing". These are AUTHORED standing rules (§5-style: subject-confirmed, applied
|
|
8
|
+
// verbatim, not inferred), so a matched FORCE rule is an authorization: it OVERRIDES
|
|
9
|
+
// the inferred base signal and resolves the decision deterministically at
|
|
10
|
+
// confidence 1.0 / deference high — more authoritative than the inferred pattern.
|
|
11
|
+
// This is what makes consultation trustworthy enough to delegate. (Per the A→B fork
|
|
12
|
+
// decision, these live in the fingerprint — `phaedo_fingerprint.consult_policies` —
|
|
13
|
+
// for v0.1; the §5 User-Configuration home is the v0.2 destination. Extraction MUST
|
|
14
|
+
// NOT write this structure — locked by scripts/test_consult_policies_invariant.mjs.)
|
|
15
|
+
//
|
|
16
|
+
// Deterministic + local. PORTABLE: policies ride the vault as
|
|
17
|
+
// `phaedo_consult_policies` and travel with the user (authoring UI is a follow-up;
|
|
18
|
+
// a local JSON via PHAEDO_CONSULT_POLICIES works today for testing). §10.3 response
|
|
19
|
+
// shape is preserved — policy provenance goes in `rationale_hint` for transparency.
|
|
20
|
+
|
|
21
|
+
const MAG = { low: 0, medium: 1, high: 2 };
|
|
22
|
+
const LADDER = ['proceed', 'proceed_with_note', 'clarify', 'escalate']; // bold → cautious
|
|
23
|
+
// "Force" effects set the signal outright (the user's hard pre-decision) — these are
|
|
24
|
+
// the authorizations. (bias_* effects are soft nudges, not authorizations.)
|
|
25
|
+
const FORCE = { escalate: 'escalate', require_human: 'escalate', decline: 'decline', autoproceed: 'proceed' };
|
|
26
|
+
const FORCE_NOTES = {
|
|
27
|
+
escalate: 'a standing rule surfaces this for a live decision',
|
|
28
|
+
require_human: 'a standing rule requires human review here',
|
|
29
|
+
decline: 'a standing rule declines this action',
|
|
30
|
+
autoproceed: 'a standing rule pre-approves low-stakes actions here',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const magRank = (m) => MAG[String(m || '').toLowerCase()] ?? 1;
|
|
34
|
+
|
|
35
|
+
// Normalize a raw policy doc (array, or { rules: [...] }) → usable rules. Defensive:
|
|
36
|
+
// silently skips malformed/unknown-effect rules. Never throws.
|
|
37
|
+
export function normalizePolicies(raw) {
|
|
38
|
+
const rules = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.rules) ? raw.rules : []);
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const r of rules) {
|
|
41
|
+
if (!r || typeof r !== 'object') continue;
|
|
42
|
+
const effect = String(r.effect || '').toLowerCase();
|
|
43
|
+
if (!(effect in FORCE) && effect !== 'bias_cautious' && effect !== 'bias_bold') continue;
|
|
44
|
+
const m = (r.match && typeof r.match === 'object') ? r.match : {};
|
|
45
|
+
out.push({
|
|
46
|
+
id: r.id || null,
|
|
47
|
+
domains: (Array.isArray(m.domains) ? m.domains : []).map((d) => String(d).toLowerCase()).filter(Boolean),
|
|
48
|
+
magMin: m.magnitude_min != null ? magRank(m.magnitude_min) : null,
|
|
49
|
+
magMax: m.magnitude_max != null ? magRank(m.magnitude_max) : null,
|
|
50
|
+
reversible: typeof m.reversible === 'boolean' ? m.reversible : null,
|
|
51
|
+
amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null,
|
|
52
|
+
effect,
|
|
53
|
+
note: typeof r.note === 'string' ? r.note : null,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Merge policy sources, most-portable first:
|
|
60
|
+
// 1. inside the fingerprint (`phaedo_fingerprint.consult_policies`) — rides
|
|
61
|
+
// `inner.data` on the phone pull, so it travels everywhere the fingerprint
|
|
62
|
+
// does (extension ↔ phone ↔ MCP) with NO profile-schema change. Preferred
|
|
63
|
+
// home for authored, synced policies.
|
|
64
|
+
// 2. a top-level vault sibling (`phaedo_consult_policies`) — local/extension use.
|
|
65
|
+
// 3. opts.policies — a loaded local file (server: mcp/consult-policies.json).
|
|
66
|
+
// Defensive — never throws.
|
|
67
|
+
export function loadPolicies(vd, opts = {}) {
|
|
68
|
+
let list = [];
|
|
69
|
+
try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ }
|
|
70
|
+
try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ }
|
|
71
|
+
try { if (opts.policies) list = list.concat(normalizePolicies(opts.policies)); } catch { /* ignore */ }
|
|
72
|
+
return list;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ruleMatches(rule, n) {
|
|
76
|
+
if (rule.domains.length) {
|
|
77
|
+
const d = String(n.domain || '').toLowerCase();
|
|
78
|
+
if (!d || !rule.domains.some((k) => d.includes(k))) return false;
|
|
79
|
+
}
|
|
80
|
+
const mag = magRank(n.magnitude);
|
|
81
|
+
if (rule.magMin != null && mag < rule.magMin) return false;
|
|
82
|
+
if (rule.magMax != null && mag > rule.magMax) return false;
|
|
83
|
+
if (rule.reversible != null && n.reversible !== rule.reversible) return false;
|
|
84
|
+
// Numeric threshold (e.g. "over $5,000"): a rule with amount_gt only matches when
|
|
85
|
+
// the action carries a numeric amount strictly above it. No amount on the action →
|
|
86
|
+
// the threshold rule does not fire (cannot assert it's over the bar).
|
|
87
|
+
if (rule.amountGt != null && !(typeof n.amount === 'number' && n.amount > rule.amountGt)) return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The first matching AUTHORIZATION (force rule) for a normalized request, or null.
|
|
92
|
+
// Authorizations are force effects only — bias rules are soft preferences, not
|
|
93
|
+
// authorizations, so they are ignored here. Deterministic: no inference, no model.
|
|
94
|
+
// This is the engine behind the lightweight `phaedo_check_authorization` pre-flight
|
|
95
|
+
// (brief M3) — an agent can ask "is this already decided by a standing rule?" without
|
|
96
|
+
// paying for a full consultation. voice_draft is exempt (a redirect, not a decision).
|
|
97
|
+
export function matchAuthorization(n, policies) {
|
|
98
|
+
if (!policies || !policies.length || n.type === 'voice_draft') return null;
|
|
99
|
+
const matched = policies.filter((r) => ruleMatches(r, n));
|
|
100
|
+
const force = matched.find((r) => r.effect in FORCE);
|
|
101
|
+
if (!force) return null;
|
|
102
|
+
return { rule: force, signal: FORCE[force.effect], note: force.note || FORCE_NOTES[force.effect] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Apply the first matching FORCE rule (escalate / decline / autoproceed) — the
|
|
106
|
+
// user's hard pre-decision, which overrides the inferred signal at high deference.
|
|
107
|
+
// Else apply the first matching BIAS rule (nudge one step on the ladder). Returns a
|
|
108
|
+
// NEW response (§10.3 shape preserved; provenance in rationale_hint). voice_draft is
|
|
109
|
+
// never policy-overridden (it's a redirect, not a decision).
|
|
110
|
+
export function applyPolicies(response, n, policies) {
|
|
111
|
+
if (!policies || !policies.length || n.type === 'voice_draft') return response;
|
|
112
|
+
|
|
113
|
+
const auth = matchAuthorization(n, policies);
|
|
114
|
+
if (auth) {
|
|
115
|
+
return {
|
|
116
|
+
...response,
|
|
117
|
+
signal: auth.signal,
|
|
118
|
+
confidence: 1.0, // authored authorization: a deterministic, certain pre-decision
|
|
119
|
+
deference_level: 'high',
|
|
120
|
+
rationale_hint: `Per your standing rule: ${auth.note}.`.replace(/\s+/g, ' ').trim().slice(0, 280),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const matched = policies.filter((r) => ruleMatches(r, n));
|
|
125
|
+
const bias = matched[0];
|
|
126
|
+
if (!bias) return response;
|
|
127
|
+
const i = LADDER.indexOf(response.signal);
|
|
128
|
+
if (i === -1) return response; // e.g. `decline` — not on the proceed↔escalate ladder
|
|
129
|
+
const j = Math.max(0, Math.min(LADDER.length - 1, i + (bias.effect === 'bias_cautious' ? 1 : -1)));
|
|
130
|
+
if (j === i) return response;
|
|
131
|
+
return {
|
|
132
|
+
...response,
|
|
133
|
+
signal: LADDER[j],
|
|
134
|
+
rationale_hint: `${response.rationale_hint} (Nudged ${bias.effect === 'bias_cautious' ? 'cautious' : 'bold'} per your preference.)`.slice(0, 280),
|
|
135
|
+
};
|
|
136
|
+
}
|
package/consult.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Phaedo §10 Agent Consultation — MCP-binding wrapper around the pure resolver.
|
|
2
|
+
//
|
|
3
|
+
// The deterministic resolver lives in consult-core.js (PURE: (vd,request)->response,
|
|
4
|
+
// no model/transport/IO). This module adds the OPTIONAL local model judge and the
|
|
5
|
+
// env config, and re-exports the core entry. Deterministic shapes in the core +
|
|
6
|
+
// model/transport here keeps the open-core line a FILE boundary (brief Invariant 7):
|
|
7
|
+
// the core is the publishable reference resolver; the judge is the proprietary-side
|
|
8
|
+
// upgrade. Any judge failure/timeout/parse-error falls back to the core, so the
|
|
9
|
+
// model can only improve on — never break — the deterministic floor. Honors §10.4
|
|
10
|
+
// (response carries only signals, never layer content).
|
|
11
|
+
|
|
12
|
+
import { buildInjectionResponse } from './projection.js';
|
|
13
|
+
import { loadPolicies, applyPolicies } from './consult-policy.js';
|
|
14
|
+
import { PhaedoError } from './errors.js';
|
|
15
|
+
import {
|
|
16
|
+
resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest,
|
|
17
|
+
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape,
|
|
18
|
+
} from './consult-core.js';
|
|
19
|
+
|
|
20
|
+
// Re-export the core surface so existing importers (server.js, tests,
|
|
21
|
+
// spec/test-schemas.mjs) keep their `from './consult.js'` paths.
|
|
22
|
+
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape };
|
|
23
|
+
|
|
24
|
+
// ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ──────────────
|
|
25
|
+
// When a local model is configured (PHAEDO_CONSULT_MODEL + an Ollama/OpenAI-
|
|
26
|
+
// compatible endpoint), the judge reasons over the FULL projection — the richest
|
|
27
|
+
// on-device context — and returns a §10.3 signal. The model sees the fingerprint
|
|
28
|
+
// only on-device (same trust boundary as the WebLLM extraction); the §10.4
|
|
29
|
+
// boundary on the OUTPUT is enforced on parse (only the signal fields cross).
|
|
30
|
+
const JUDGE_SYSTEM =
|
|
31
|
+
'You are the user’s decision proxy. Given their cognitive fingerprint and a pending action, ' +
|
|
32
|
+
'judge how THEIR pattern bears on it — not your own opinion. Reply with STRICT JSON only:\n' +
|
|
33
|
+
'{"signal": "...", "confidence": 0.0, "rationale_hint": "...", "deference_level": "..."}\n' +
|
|
34
|
+
'signal ∈ proceed | proceed_with_note | clarify | escalate | decline | insufficient_signal ' +
|
|
35
|
+
'(use insufficient_signal only when the fingerprint truly does not speak to this). ' +
|
|
36
|
+
'deference_level ∈ high | medium | low (how strongly to weight this; sparse fingerprint → low). ' +
|
|
37
|
+
'rationale_hint: ONE short sentence describing the user’s PATTERN — never quote or dump their profile text. ' +
|
|
38
|
+
'Output ONLY the JSON object, no prose.';
|
|
39
|
+
|
|
40
|
+
function parseJudge(text) {
|
|
41
|
+
const m = String(text).match(/\{[\s\S]*\}/);
|
|
42
|
+
if (!m) throw new Error('no JSON in model output');
|
|
43
|
+
const o = JSON.parse(m[0]);
|
|
44
|
+
if (!SIGNALS.includes(o.signal)) throw new Error(`bad signal: ${o.signal}`);
|
|
45
|
+
if (typeof o.rationale_hint !== 'string' || !o.rationale_hint.trim()) throw new Error('missing rationale_hint');
|
|
46
|
+
return o;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function modelJudge(vd, n, opts) {
|
|
50
|
+
const projection = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' }).projection;
|
|
51
|
+
const decision = {
|
|
52
|
+
consultation_type: n.type,
|
|
53
|
+
domain: n.domain, reversible: n.reversible, magnitude: n.magnitude,
|
|
54
|
+
evidence_provided: n.evidenceProvided,
|
|
55
|
+
summary: (n.summary || undefined),
|
|
56
|
+
};
|
|
57
|
+
const user = `# The user's cognitive fingerprint\n${projection}\n\n# Pending decision\n${JSON.stringify(decision, null, 2)}`;
|
|
58
|
+
const body = {
|
|
59
|
+
model: opts.model,
|
|
60
|
+
messages: [{ role: 'system', content: JUDGE_SYSTEM }, { role: 'user', content: user }],
|
|
61
|
+
temperature: 0.2,
|
|
62
|
+
stream: false,
|
|
63
|
+
};
|
|
64
|
+
const res = await fetch(`${opts.modelUrl.replace(/\/$/, '')}/v1/chat/completions`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json', ...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}) },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 15000),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) throw new Error(`model endpoint ${res.status}`);
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
const out = parseJudge(data?.choices?.[0]?.message?.content || '');
|
|
73
|
+
// §10.4 enforced on parse: only the four signal fields ever leave the function.
|
|
74
|
+
return shape(n.type, out.signal, out.confidence ?? 0.6, out.rationale_hint, out.deference_level);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Config + orchestrator ─────────────────────────────────────────────────────
|
|
78
|
+
// Local-only by default. `PHAEDO_CONSULT_MODEL` opts INTO the model judge (point
|
|
79
|
+
// it at a general chat model in Ollama, e.g. `llama3.1:8b` — NOT the extraction
|
|
80
|
+
// fine-tune, which is trained to emit signals, not to reason about decisions).
|
|
81
|
+
export function consultOptsFromEnv(env = process.env) {
|
|
82
|
+
return {
|
|
83
|
+
model: env.PHAEDO_CONSULT_MODEL || null,
|
|
84
|
+
modelUrl: env.PHAEDO_CONSULT_MODEL_URL || 'http://localhost:11434',
|
|
85
|
+
apiKey: env.PHAEDO_CONSULT_API_KEY || null, // for an OpenAI-compatible endpoint
|
|
86
|
+
timeoutMs: Number(env.PHAEDO_CONSULT_TIMEOUT_MS) || 15000,
|
|
87
|
+
policies: null, // a loaded policy doc; the server fills this from the policy file
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Public async entry: model judge when configured, else the deterministic core.
|
|
92
|
+
// voice_draft always takes the (redirect) core path. Authored policies (§10.5)
|
|
93
|
+
// override either result, so a standing guardrail always wins. Never throws except
|
|
94
|
+
// for §10.2 input errors (request_malformed) and a missing fingerprint
|
|
95
|
+
// (decryption_failed).
|
|
96
|
+
export async function resolveConsultation(vd, request = {}, opts = {}) {
|
|
97
|
+
if (!vd || !vd.phaedo_fingerprint) {
|
|
98
|
+
throw new PhaedoError('decryption_failed', 'No fingerprint available from the configured source.');
|
|
99
|
+
}
|
|
100
|
+
// vd contract guard (M0.4): reject a type-breached payload BEFORE the model judge
|
|
101
|
+
// sees it — same structured error the deterministic core raises.
|
|
102
|
+
const vdErrors = validateVaultShape(vd);
|
|
103
|
+
if (vdErrors.length) {
|
|
104
|
+
throw new PhaedoError('internal_error', `vault data malformed: ${vdErrors.join('; ')}`);
|
|
105
|
+
}
|
|
106
|
+
const n = normalizeRequest(request);
|
|
107
|
+
if (opts.model && opts.modelUrl && n.type !== 'voice_draft') {
|
|
108
|
+
try {
|
|
109
|
+
const judged = await modelJudge(vd, n, opts);
|
|
110
|
+
// Authored guardrails override the model judge too (applied last so they win).
|
|
111
|
+
return applyPolicies(judged, n, loadPolicies(vd, opts));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
process.stderr.write?.(`[phaedo-consult] model judge fell back to rules: ${err.message}\n`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Deterministic path (the core applies policies itself).
|
|
117
|
+
return resolveConsultationCore(vd, request, opts);
|
|
118
|
+
}
|
package/errors.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Phaedo MCP — shared error type + the §9.5 error codes.
|
|
2
|
+
//
|
|
3
|
+
// Standalone (no other imports) so the PURE consultation core (consult-core.js)
|
|
4
|
+
// can throw the SAME PhaedoError class that projection.js and server.js use for
|
|
5
|
+
// `instanceof` checks — without importing the injection renderer (context-block.js).
|
|
6
|
+
// projection.js re-exports these, so existing `import { PhaedoError } from
|
|
7
|
+
// './projection.js'` sites keep working and keep one class identity.
|
|
8
|
+
|
|
9
|
+
export const ERROR_CODES = [
|
|
10
|
+
'mode_not_supported',
|
|
11
|
+
'layer_not_available',
|
|
12
|
+
'decryption_failed',
|
|
13
|
+
'persona_strength_below_threshold',
|
|
14
|
+
'request_malformed',
|
|
15
|
+
'internal_error',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export class PhaedoError extends Error {
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message || code);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Fingerprint source for the Phaedo MCP server. The server depends only on
|
|
2
|
+
// loadFingerprint()'s return shape, so the source swaps without touching the
|
|
3
|
+
// tool/resource code. Two modes, auto-selected:
|
|
4
|
+
//
|
|
5
|
+
// Phase 1b (phone): if a pairing record is configured (PHAEDO_PAIRING or
|
|
6
|
+
// mcp/pairing.json), authorize → pull /v1/profile → unwrap the §7.2 envelope
|
|
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 (or the shipped
|
|
9
|
+
// sample) — the decoupled stand-in that runs out of the box.
|
|
10
|
+
|
|
11
|
+
import { readFile } from 'fs/promises';
|
|
12
|
+
import { resolve } from 'path';
|
|
13
|
+
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
14
|
+
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
15
|
+
|
|
16
|
+
// Resolution order for the local fixture (1a):
|
|
17
|
+
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
|
|
18
|
+
// 2. ./fingerprint.json next to the server
|
|
19
|
+
// 3. ./sample-fingerprint.json (shipped demo fixture)
|
|
20
|
+
function resolveSourcePath(baseDir) {
|
|
21
|
+
if (process.env.PHAEDO_FINGERPRINT) return resolve(process.cwd(), process.env.PHAEDO_FINGERPRINT);
|
|
22
|
+
return resolve(baseDir, 'fingerprint.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Returns the vault payload:
|
|
26
|
+
// { phaedo_fingerprint, phaedo_behavioral_signals, phaedo_linguistic_profile,
|
|
27
|
+
// phaedo_session_metrics }
|
|
28
|
+
// Throws on unreadable/invalid source — the tool layer maps that to
|
|
29
|
+
// decryption_failed (the §9.5 "couldn't obtain plaintext fingerprint" case).
|
|
30
|
+
export async function loadFingerprint(baseDir) {
|
|
31
|
+
// Phase 1b: a configured pairing record means pull the live fingerprint from
|
|
32
|
+
// the paired phone over the encrypted boundary (no plaintext at rest). On
|
|
33
|
+
// success, refresh the encrypted at-rest cache; if the phone is unreachable,
|
|
34
|
+
// serve the cache so the server still works phone-absent (frictionless default).
|
|
35
|
+
if (hasPairingRecord(baseDir)) {
|
|
36
|
+
const rec = await loadPairingRecord(baseDir);
|
|
37
|
+
const stateDir = defaultStateDir();
|
|
38
|
+
try {
|
|
39
|
+
const vd = await loadFingerprintFromPhone(rec);
|
|
40
|
+
await writeCache(stateDir, vd);
|
|
41
|
+
return vd;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// A 4xx means the phone REACHED us and rejected this client (revoked /
|
|
44
|
+
// invalid credentials / failed handshake). Revocation must be effective:
|
|
45
|
+
// purge the at-rest cache and fail, so a revoked agent can't keep serving a
|
|
46
|
+
// cached copy. Only a transient/unreachable failure (network, timeout, 5xx)
|
|
47
|
+
// falls back to the cache — the phone-absent design (1b-iii).
|
|
48
|
+
if (err instanceof PhoneError && err.status >= 400 && err.status < 500) {
|
|
49
|
+
await clearCache(stateDir);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
const cached = await readCache(stateDir);
|
|
53
|
+
if (cached) return cached.vault; // phone unreachable → encrypted cache (may be stale)
|
|
54
|
+
throw err; // no phone + no cache → cannot serve
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const path = resolveSourcePath(baseDir);
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = await readFile(path, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall back to the shipped sample so a fresh checkout runs out of the box.
|
|
64
|
+
raw = await readFile(resolve(baseDir, 'sample-fingerprint.json'), 'utf8');
|
|
65
|
+
}
|
|
66
|
+
const vd = JSON.parse(raw);
|
|
67
|
+
if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
|
|
68
|
+
throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
|
|
69
|
+
}
|
|
70
|
+
return vd;
|
|
71
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Claude Code SessionStart hook.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code runs this at the start of every session. It loads your fingerprint
|
|
5
|
+
// via the SAME source the MCP server uses (paired phone → at-rest cache) and
|
|
6
|
+
// emits the projection as `additionalContext`, so Claude Code injects it
|
|
7
|
+
// automatically — no tool call, no slash command. This is true auto-injection
|
|
8
|
+
// for Claude Code (Claude Desktop has no hook surface; use the `/phaedo` prompt
|
|
9
|
+
// or a Project instruction there).
|
|
10
|
+
//
|
|
11
|
+
// Wire it up (or run `npm run setup:code-hook` to do this for you) — in
|
|
12
|
+
// ~/.claude/settings.json:
|
|
13
|
+
// { "hooks": { "SessionStart": [ { "hooks": [
|
|
14
|
+
// { "type": "command", "command": "<node> <abs path to this file>" }
|
|
15
|
+
// ] } ] } }
|
|
16
|
+
//
|
|
17
|
+
// FAIL-SAFE: any error (no pairing, phone unreachable + no cache, render failure)
|
|
18
|
+
// exits 0 with NO output, so a missing fingerprint never blocks or slows your
|
|
19
|
+
// session beyond a short timeout. It never writes anything.
|
|
20
|
+
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { dirname, resolve } from 'path';
|
|
23
|
+
import { loadFingerprint } from '../fingerprint-source.js';
|
|
24
|
+
import { buildInjectionResponse } from '../projection.js';
|
|
25
|
+
|
|
26
|
+
// hooks/ lives one level under mcp/ — the sources resolve against mcp/.
|
|
27
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
// Never hang session start. If the phone pull + cache fallback hasn't resolved
|
|
30
|
+
// within the budget, emit nothing and let the session proceed.
|
|
31
|
+
const BUDGET_MS = 8000;
|
|
32
|
+
|
|
33
|
+
function emitContext(text) {
|
|
34
|
+
process.stdout.write(JSON.stringify({
|
|
35
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text },
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function run() {
|
|
40
|
+
const vd = await loadFingerprint(MCP_DIR);
|
|
41
|
+
const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
|
|
42
|
+
if (response && response.projection) emitContext(response.projection);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
46
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
47
|
+
.then(() => process.exit(0))
|
|
48
|
+
.catch(() => process.exit(0)); // belt-and-suspenders: a hook must never break the session
|