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
package/cache.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// MCP Phase 1b-iii — encrypted at-rest fingerprint cache.
|
|
2
|
+
//
|
|
3
|
+
// So the server can serve injections when the phone is ABSENT (the frictionless
|
|
4
|
+
// default — desktop/CLI use where the phone often isn't reachable). Mirrors the
|
|
5
|
+
// extension's Option C: the fingerprint is cached as CIPHERTEXT under a
|
|
6
|
+
// server-held key. Defense-in-depth for a DERIVED cache only — NOT phone-gated,
|
|
7
|
+
// NOT forensic-grade: the device can decrypt itself, and the cache is always
|
|
8
|
+
// recoverable from the phone (re-pull) on loss.
|
|
9
|
+
//
|
|
10
|
+
// Key storage (v1): a 32-byte AES key in a 0600 file. Weaker than the extension's
|
|
11
|
+
// non-extractable WebCrypto key (the raw bytes are on disk), because Node has no
|
|
12
|
+
// IndexedDB-style non-extractable-key store that survives restarts. OS-keychain
|
|
13
|
+
// storage is the hardening follow-up. The cache file holds only ciphertext.
|
|
14
|
+
|
|
15
|
+
import { mkdir, readFile, writeFile, chmod, rm } from 'fs/promises';
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
20
|
+
|
|
21
|
+
const { Envelope } = Phaedo;
|
|
22
|
+
|
|
23
|
+
export function defaultStateDir() {
|
|
24
|
+
return process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function ensureDir(dir) { await mkdir(dir, { recursive: true, mode: 0o700 }); }
|
|
28
|
+
|
|
29
|
+
export async function getOrCreateCacheKey(stateDir) {
|
|
30
|
+
await ensureDir(stateDir);
|
|
31
|
+
const keyPath = join(stateDir, 'cache.key');
|
|
32
|
+
if (existsSync(keyPath)) return b64uDec((await readFile(keyPath, 'utf8')).trim());
|
|
33
|
+
const key = crypto.getRandomValues(new Uint8Array(32));
|
|
34
|
+
await writeFile(keyPath, b64uEnc(key), { mode: 0o600 });
|
|
35
|
+
await chmod(keyPath, 0o600);
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Encrypt + persist the vault payload. `now` injectable for tests.
|
|
40
|
+
export async function writeCache(stateDir, vaultPayload, now = Date.now()) {
|
|
41
|
+
const key = await getOrCreateCacheKey(stateDir);
|
|
42
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(vaultPayload));
|
|
43
|
+
const envelope = await Envelope.wrap(plaintext, key, {
|
|
44
|
+
artifact_kind: 'fingerprint',
|
|
45
|
+
fingerprint_id: vaultPayload?.phaedo_fingerprint?.fingerprint_id || null,
|
|
46
|
+
created_at: new Date(now).toISOString(),
|
|
47
|
+
});
|
|
48
|
+
plaintext.fill(0);
|
|
49
|
+
const cachePath = join(stateDir, 'cache.enc');
|
|
50
|
+
await writeFile(cachePath, JSON.stringify({ envelope, cached_at: now }), { mode: 0o600 });
|
|
51
|
+
await chmod(cachePath, 0o600);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove the at-rest cache. Used on revocation: a cached profile must not
|
|
55
|
+
// outlive authorization, so once the phone rejects this client the ciphertext
|
|
56
|
+
// copy is deleted (the cache is always re-pullable after a fresh pairing).
|
|
57
|
+
export async function clearCache(stateDir) {
|
|
58
|
+
try { await rm(join(stateDir, 'cache.enc'), { force: true }); } catch { /* nothing to clear */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Decrypt the cache → { vault, cachedAt } or null (absent/corrupt/undecryptable
|
|
62
|
+
// → treated as no cache, since it's recoverable from the phone).
|
|
63
|
+
export async function readCache(stateDir) {
|
|
64
|
+
const cachePath = join(stateDir, 'cache.enc');
|
|
65
|
+
if (!existsSync(cachePath)) return null;
|
|
66
|
+
try {
|
|
67
|
+
const key = await getOrCreateCacheKey(stateDir);
|
|
68
|
+
const { envelope, cached_at } = JSON.parse(await readFile(cachePath, 'utf8'));
|
|
69
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
70
|
+
const vault = JSON.parse(new TextDecoder().decode(plaintext));
|
|
71
|
+
plaintext.fill(0);
|
|
72
|
+
return { vault, cachedAt: cached_at };
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/consult-core.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// Phaedo §10 Agent Consultation — the PURE resolver core.
|
|
2
|
+
//
|
|
3
|
+
// resolveConsultationCore(vd, request[, opts]) -> §10.3 response. PURE: no browser
|
|
4
|
+
// globals, no chrome, no fs, no network, no model, no process, no side effects —
|
|
5
|
+
// the same discipline that makes context-block.js reusable across runtimes. The
|
|
6
|
+
// OPTIONAL local model judge and all transport/config live in consult.js (the MCP
|
|
7
|
+
// wrapper). Keeping the deterministic shapes here and the model there makes the
|
|
8
|
+
// open-core line a file boundary (brief Invariant 7): this file is the publishable
|
|
9
|
+
// reference resolver; the judge is the proprietary-side upgrade that wraps it.
|
|
10
|
+
//
|
|
11
|
+
// Resolution order (deterministic → authored): (A) inferred Decision/Risk signal,
|
|
12
|
+
// confidence-gated; (B) abstain floor — if NO relevant cue was found, return
|
|
13
|
+
// `insufficient_signal` rather than a confidently-wrong guess (§10.3); (C) authored
|
|
14
|
+
// standing policies (§10.5) override either, so a hard guardrail wins even over an
|
|
15
|
+
// abstain. Signals out, never structure out (§10.4).
|
|
16
|
+
|
|
17
|
+
import { PhaedoError } from './errors.js';
|
|
18
|
+
import { loadPolicies, applyPolicies, matchAuthorization } from './consult-policy.js';
|
|
19
|
+
|
|
20
|
+
export const CONSULTATION_TYPES = [
|
|
21
|
+
'action_approval',
|
|
22
|
+
'voice_draft',
|
|
23
|
+
'domain_risk_check',
|
|
24
|
+
'escalation_default',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// §10.3 signal enum. `insufficient_signal` (abstain) is the producer declining to
|
|
28
|
+
// answer for lack of coverage — distinct from `clarify` (gather more) and from an
|
|
29
|
+
// error. Folded into the spec at v0.1.9 (proposal 0001).
|
|
30
|
+
export const SIGNALS = ['proceed', 'proceed_with_note', 'clarify', 'escalate', 'decline', 'insufficient_signal'];
|
|
31
|
+
const MAGNITUDES = ['low', 'medium', 'high'];
|
|
32
|
+
|
|
33
|
+
// NOTE: for UNTAGGED signals (all fingerprints today), coverage is fingerprint-WIDE —
|
|
34
|
+
// a rich fingerprint never abstains on an unfamiliar domain. As domain-TAGGED
|
|
35
|
+
// Decision&Risk signals accrue (extraction/domain-inference.js prototype), coverage
|
|
36
|
+
// becomes per-domain naturally: signals tagged for a different domain are skipped,
|
|
37
|
+
// so a fingerprint whose relevant signals are all wrong-domain abstains here.
|
|
38
|
+
const ABSTAIN_HINT =
|
|
39
|
+
'The fingerprint has no decision/risk coverage to ground a signal, so the oracle ' +
|
|
40
|
+
'abstains — fall back to your own default or escalate to the subject.';
|
|
41
|
+
|
|
42
|
+
// ── vd contract guard (brief M0.4) ────────────────────────────────────────────
|
|
43
|
+
// The consultation boundary's mirror of context-block.js validateVaultData (which
|
|
44
|
+
// cannot be imported here — it is a classic browser script; the two are kept in
|
|
45
|
+
// agreement by scripts/test_context_block_snapshots.mjs's cross-check). A breach
|
|
46
|
+
// throws a structured PhaedoError naming the violating field — a malformed vault
|
|
47
|
+
// payload is a producer bug and must never be resolved silently with partial data.
|
|
48
|
+
// A MISSING fingerprint stays `decryption_failed` (the source had nothing), and
|
|
49
|
+
// weird-but-conforming content (odd signal values, empty layers) still resolves
|
|
50
|
+
// defensively — this guard rejects only type breaches of the documented vd shape
|
|
51
|
+
// (spec/schemas/vault-data.schema.json).
|
|
52
|
+
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
53
|
+
export function validateVaultShape(vd) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
if (!isPlainObject(vd)) return ['vault data must be a plain object'];
|
|
56
|
+
const fp = vd.phaedo_fingerprint;
|
|
57
|
+
if (fp !== undefined && fp !== null) {
|
|
58
|
+
if (!isPlainObject(fp)) errors.push('phaedo_fingerprint must be an object');
|
|
59
|
+
else {
|
|
60
|
+
if (!isPlainObject(fp.layers)) errors.push('phaedo_fingerprint.layers must be an object');
|
|
61
|
+
else for (const [id, L] of Object.entries(fp.layers)) if (!isPlainObject(L)) errors.push(`layer "${id}" must be an object`);
|
|
62
|
+
if (fp.persona_strength !== undefined && (typeof fp.persona_strength !== 'number' || fp.persona_strength < 0 || fp.persona_strength > 1))
|
|
63
|
+
errors.push('persona_strength must be a number in [0,1]');
|
|
64
|
+
if (fp.fingerprint_id !== undefined && fp.fingerprint_id !== null && typeof fp.fingerprint_id !== 'string')
|
|
65
|
+
errors.push('fingerprint_id must be a string');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const k of ['phaedo_behavioral_signals', 'phaedo_linguistic_profile', 'phaedo_session_metrics'])
|
|
69
|
+
if (vd[k] !== undefined && vd[k] !== null && !isPlainObject(vd[k])) errors.push(`${k} must be an object`);
|
|
70
|
+
return errors;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertVaultUsable(vd) {
|
|
74
|
+
if (!vd || !vd.phaedo_fingerprint) {
|
|
75
|
+
throw new PhaedoError('decryption_failed', 'No fingerprint available from the configured source.');
|
|
76
|
+
}
|
|
77
|
+
const errors = validateVaultShape(vd);
|
|
78
|
+
if (errors.length) {
|
|
79
|
+
throw new PhaedoError('internal_error', `vault data malformed: ${errors.join('; ')}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Low-level fingerprint reads (defensive; never throw) ──────────────────────
|
|
84
|
+
function layer(vd, id) { return vd?.phaedo_fingerprint?.layers?.[id]; }
|
|
85
|
+
|
|
86
|
+
// Evidence basis of a signal, derived from its `source` (proposal 0002 vocabulary).
|
|
87
|
+
// Drives how much a cue's per-signal confidence is trusted (below): observed/
|
|
88
|
+
// corroborated learned signals > self-reported questionnaire answers > a bare
|
|
89
|
+
// keyword hit in free text.
|
|
90
|
+
function evidenceBasis(source) {
|
|
91
|
+
const s = String(source || '').toLowerCase();
|
|
92
|
+
if (s.includes('corrobor') || s.includes('reconcil')) return 'corroborated';
|
|
93
|
+
if (s.includes('question') || s.includes('self') || s.includes('interview')) return 'self_reported';
|
|
94
|
+
return 'observed'; // extraction/delta-sourced structured signals
|
|
95
|
+
}
|
|
96
|
+
const BASIS_WEIGHT = { observed: 1.0, corroborated: 1.0, self_reported: 0.9, text_scan: 0.75 };
|
|
97
|
+
|
|
98
|
+
// Does a signal's §4.2.7 domain tag pertain to the action's domain? Same
|
|
99
|
+
// case-insensitive substring convention as authored policies (either direction:
|
|
100
|
+
// signal "financial" matches action "financial services" and vice versa).
|
|
101
|
+
function domainMatches(sigDomain, actionDomain) {
|
|
102
|
+
const a = String(sigDomain).toLowerCase(), b = String(actionDomain).toLowerCase();
|
|
103
|
+
return a.includes(b) || b.includes(a);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve a relevant cue to { text, confidence, basis }, preferring a structured
|
|
107
|
+
// §4.2 signal (which carries a real per-signal confidence + provenance) over a
|
|
108
|
+
// dimensioned questionnaire response. Returns undefined when neither is present
|
|
109
|
+
// (caller falls back to a free-text keyword scan). Positive/neutral polarity only.
|
|
110
|
+
//
|
|
111
|
+
// Domain preference (PROTOTYPE, §4.2.7 optional `domain`): when the action carries
|
|
112
|
+
// a domain, a signal TAGGED with a matching domain wins; an UNTAGGED signal is the
|
|
113
|
+
// fallback (today's fingerprint-wide behavior); a signal tagged with a DIFFERENT
|
|
114
|
+
// domain is skipped — financial caution must not drive a writing decision. When
|
|
115
|
+
// the action has no domain, tags are ignored (any field-matching signal serves).
|
|
116
|
+
function signalCue(vd, layerId, fields, actionDomain) {
|
|
117
|
+
const L = layer(vd, layerId);
|
|
118
|
+
if (!L) return undefined;
|
|
119
|
+
const want = new Set(fields);
|
|
120
|
+
const asCue = (s) => {
|
|
121
|
+
const confidence = typeof s.confidence === 'number' ? Math.max(0, Math.min(1, s.confidence)) : 0.6;
|
|
122
|
+
// Prefer the producer-emitted evidence_basis (proposal 0002); else derive from source.
|
|
123
|
+
const basis = typeof s.evidence_basis === 'string' ? s.evidence_basis : evidenceBasis(s.source);
|
|
124
|
+
return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis };
|
|
125
|
+
};
|
|
126
|
+
let untagged;
|
|
127
|
+
for (const s of (L.signals || [])) {
|
|
128
|
+
if (!(s && want.has(s.field) && (s.polarity ?? 'positive') !== 'negative' && s.value != null)) continue;
|
|
129
|
+
if (typeof s.domain === 'string' && actionDomain) {
|
|
130
|
+
if (domainMatches(s.domain, actionDomain)) return asCue(s); // domain match wins outright
|
|
131
|
+
continue; // wrong domain — skip
|
|
132
|
+
}
|
|
133
|
+
if (typeof s.domain === 'string' && !actionDomain) return asCue(s); // no action domain → tags ignored
|
|
134
|
+
if (!untagged) untagged = asCue(s); // untagged fallback
|
|
135
|
+
}
|
|
136
|
+
if (untagged) return untagged;
|
|
137
|
+
for (const r of (L.responses || L.answers || [])) {
|
|
138
|
+
const dim = r?.dimension || r?.field;
|
|
139
|
+
const v = r?.value ?? r?.answer;
|
|
140
|
+
if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported' };
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All free-text the layer carries (interview answers + learned summary), lowercased
|
|
146
|
+
// — the fallback corpus for the keyword scan when a clean signal isn't present.
|
|
147
|
+
function layerText(vd, layerId) {
|
|
148
|
+
const L = layer(vd, layerId);
|
|
149
|
+
if (!L) return '';
|
|
150
|
+
const parts = [];
|
|
151
|
+
for (const r of (L.responses || [])) if (r?.answer) parts.push(String(r.answer));
|
|
152
|
+
for (const r of (L.answers || [])) { const v = r?.answer ?? r?.value; if (v != null) parts.push(String(v)); }
|
|
153
|
+
if (typeof L.summary === 'string') parts.push(L.summary);
|
|
154
|
+
return parts.join(' \n ').toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const hit = (text, needles) => needles.some(n => text.includes(n));
|
|
158
|
+
|
|
159
|
+
// ── Cue catalog ───────────────────────────────────────────────────────────────
|
|
160
|
+
// Each cue resolves a lean from a signal field if present, else from a keyword
|
|
161
|
+
// scan of its layers' text. lean: +1 = cautious/high-bar, -1 = bold/fast, 0 = unknown.
|
|
162
|
+
// Keywords are DIRECTIONAL phrases, not bare words — "quality over speed" must
|
|
163
|
+
// read as cautious even though it contains "speed".
|
|
164
|
+
const CUES = {
|
|
165
|
+
irreversibleCaution: {
|
|
166
|
+
layers: ['decision_and_risk'],
|
|
167
|
+
fields: ['reversible_vs_irreversible', 'irreversible_style'],
|
|
168
|
+
cautious: ['more_cautious', 'more cautious', 'seek more input', 'careful_still', 'think longer', 'longer_think'],
|
|
169
|
+
bold: ['consistent_style', 'consistent regardless', 'style is consistent', 'fast_adjust', 'decide fast'],
|
|
170
|
+
},
|
|
171
|
+
decisionThreshold: {
|
|
172
|
+
layers: ['behavioral', 'decision_and_risk'],
|
|
173
|
+
fields: ['decision_threshold'],
|
|
174
|
+
cautious: ['cover_risks', 'covered the main risks', 'cover the main risks', 'main risks before', 'depends_reversibility'],
|
|
175
|
+
bold: ['act_adjust', 'act on the best', 'adjust as i learn', 'best signal available'],
|
|
176
|
+
},
|
|
177
|
+
evidenceBar: {
|
|
178
|
+
layers: ['decision_and_risk'],
|
|
179
|
+
fields: ['evidence_bar', 'value_tradeoffs', 'evidence_threshold'],
|
|
180
|
+
cautious: ['accuracy over', 'thoroughness over', 'thoroughness', 'high evidence', 'evidence bar: high', 'consistency over'],
|
|
181
|
+
bold: ['speed over', 'simplicity over', 'adaptability over', 'good enough'],
|
|
182
|
+
},
|
|
183
|
+
speedQuality: {
|
|
184
|
+
layers: ['decision_and_risk'],
|
|
185
|
+
fields: ['speed_vs_quality'],
|
|
186
|
+
cautious: ['quality over', 'accuracy over'],
|
|
187
|
+
bold: ['speed over', 'fast over'],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Ambiguity has a distinct axis (ask-first vs proceed), resolved separately.
|
|
192
|
+
const AMBIGUITY = {
|
|
193
|
+
layers: ['behavioral', 'decision_and_risk'],
|
|
194
|
+
fields: ['handling_ambiguity', 'ambiguity_response', 'ambiguity_posture'],
|
|
195
|
+
askFirst: ['ask_first', 'clarifying question', 'ask a clarifying', 'ask before doing'],
|
|
196
|
+
proceed: ['assume_silent', 'assume_state', 'reasonable assumption', 'offer_two', 'offer two interpretations'],
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function readCue(vd, spec, actionDomain) {
|
|
200
|
+
let cue;
|
|
201
|
+
for (const lid of spec.layers) { cue = signalCue(vd, lid, spec.fields, actionDomain); if (cue) break; } // a structured §4.2 signal wins
|
|
202
|
+
const text = cue ? cue.text : spec.layers.map(lid => layerText(vd, lid)).join(' '); // else scan answers/summary
|
|
203
|
+
if (!text) return { found: false, lean: 0, confidence: 0, basis: null };
|
|
204
|
+
const c = hit(text, spec.cautious), b = hit(text, spec.bold);
|
|
205
|
+
const found = c || b;
|
|
206
|
+
const lean = (c && !b) ? 1 : (b && !c) ? -1 : 0; // both/neither → present but neutral
|
|
207
|
+
// per-cue confidence + basis: a structured signal carries its own; a keyword hit
|
|
208
|
+
// in free text is the weakest evidence (text_scan).
|
|
209
|
+
return { found, lean, confidence: found ? (cue ? cue.confidence : 0.6) : 0, basis: found ? (cue ? cue.basis : 'text_scan') : null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readAmbiguity(vd, actionDomain) {
|
|
213
|
+
let cue;
|
|
214
|
+
for (const lid of AMBIGUITY.layers) { cue = signalCue(vd, lid, AMBIGUITY.fields, actionDomain); if (cue) break; }
|
|
215
|
+
const text = cue ? cue.text : AMBIGUITY.layers.map(lid => layerText(vd, lid)).join(' ');
|
|
216
|
+
const confidence = cue ? cue.confidence : 0.6;
|
|
217
|
+
const basis = cue ? cue.basis : 'text_scan';
|
|
218
|
+
if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis };
|
|
219
|
+
if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis };
|
|
220
|
+
return { found: false, value: null, confidence: 0, basis: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function personaStrength(vd) {
|
|
224
|
+
const v = vd?.phaedo_fingerprint?.persona_strength;
|
|
225
|
+
return typeof v === 'number' ? Math.max(0, Math.min(1, v)) : 0.4;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build a scoped caution profile from ONLY the cues relevant to this consultation
|
|
229
|
+
// type (§10 relevance scoping). Returns the caution score (0..1), the coverage
|
|
230
|
+
// (found / relevant), the raw `found` count (0 = no relevant cue → abstain), and
|
|
231
|
+
// the ambiguity verdict.
|
|
232
|
+
function profile(vd, relevantCues, includeAmbiguity, actionDomain) {
|
|
233
|
+
let score = 0.5, found = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
|
|
234
|
+
const effConfs = []; // per-cue confidence × evidence-basis weight, for the cues actually used
|
|
235
|
+
for (const key of relevantCues) {
|
|
236
|
+
const r = readCue(vd, CUES[key], actionDomain);
|
|
237
|
+
if (r.found) { found++; score += r.lean * 0.13; effConfs.push(r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75)); }
|
|
238
|
+
}
|
|
239
|
+
let ambiguity = { found: false, value: null };
|
|
240
|
+
if (includeAmbiguity) {
|
|
241
|
+
ambiguity = readAmbiguity(vd, actionDomain);
|
|
242
|
+
if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(ambiguity.confidence * (BASIS_WEIGHT[ambiguity.basis] ?? 0.75)); }
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
score: Math.max(0, Math.min(1, score)),
|
|
246
|
+
coverage: total ? found / total : 0,
|
|
247
|
+
found,
|
|
248
|
+
signalConfidence: effConfs.length ? effConfs.reduce((a, b) => a + b, 0) / effConfs.length : 0,
|
|
249
|
+
ambiguity,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Confidence + deference for a resolved consultation.
|
|
254
|
+
// deference_level — breadth: RELEVANT cue coverage × persona strength (unchanged).
|
|
255
|
+
// confidence — now blends that breadth with `signalConfidence`: the mean per-signal
|
|
256
|
+
// confidence (evidence-basis weighted) of the cues actually used. So confidence
|
|
257
|
+
// VARIES with how strong/observed the underlying Decision&Risk signals are, rather
|
|
258
|
+
// than sitting flat across every action on one fingerprint (the fix for the pinned-
|
|
259
|
+
// confidence critique). A fingerprint of strong observed signals reads confident; a
|
|
260
|
+
// thin/keyword-only one reads low. `signalConfidence` 0 (no usable cues — e.g. the
|
|
261
|
+
// abstain floor's calibrate(vd,0)) → the breadth model alone, so abstain is unchanged.
|
|
262
|
+
function calibrate(vd, coverage, signalConfidence = 0) {
|
|
263
|
+
const ps = personaStrength(vd);
|
|
264
|
+
const breadth = 0.3 + 0.4 * coverage * ps + 0.2 * ps;
|
|
265
|
+
const confidence = signalConfidence > 0
|
|
266
|
+
? 0.45 * breadth + 0.55 * signalConfidence * (0.6 + 0.4 * ps)
|
|
267
|
+
: breadth;
|
|
268
|
+
const deference_level = (coverage >= 0.66 && ps >= 0.5) ? 'high'
|
|
269
|
+
: (coverage >= 0.33 && ps >= 0.3) ? 'medium'
|
|
270
|
+
: 'low';
|
|
271
|
+
return { confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 100) / 100, deference_level };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
|
|
275
|
+
|
|
276
|
+
// ── Request validation/normalization (§10.2) ──────────────────────────────────
|
|
277
|
+
export function normalizeRequest(request) {
|
|
278
|
+
const type = request.consultation_type;
|
|
279
|
+
if (!CONSULTATION_TYPES.includes(type)) {
|
|
280
|
+
throw new PhaedoError('request_malformed', `Unknown consultation_type: ${type}. One of: ${CONSULTATION_TYPES.join(', ')}.`);
|
|
281
|
+
}
|
|
282
|
+
const ad = (request.action_descriptor && typeof request.action_descriptor === 'object') ? request.action_descriptor : {};
|
|
283
|
+
let magnitude = typeof ad.magnitude === 'string' ? ad.magnitude.toLowerCase() : undefined;
|
|
284
|
+
if (magnitude && !MAGNITUDES.includes(magnitude)) {
|
|
285
|
+
throw new PhaedoError('request_malformed', `magnitude must be one of: ${MAGNITUDES.join(', ')}.`);
|
|
286
|
+
}
|
|
287
|
+
const ctx = (request.context && typeof request.context === 'object') ? request.context : {};
|
|
288
|
+
const ev = ctx.evidence_provided;
|
|
289
|
+
return {
|
|
290
|
+
type,
|
|
291
|
+
domain: typeof ad.domain === 'string' ? ad.domain : undefined,
|
|
292
|
+
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
|
|
293
|
+
magnitude: magnitude || 'medium',
|
|
294
|
+
amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
|
|
295
|
+
summary: typeof ad.summary === 'string' ? ad.summary : undefined,
|
|
296
|
+
evidenceProvided: Array.isArray(ev) ? ev : (ev != null ? [ev] : []),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Per-type resolvers → { signal, hint, coverage, found } ────────────────────
|
|
301
|
+
function resolveActionApproval(vd, n) {
|
|
302
|
+
const p = profile(vd, ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], true, n.domain);
|
|
303
|
+
const irreversible = n.reversible === false;
|
|
304
|
+
const mag = magnitudeRank(n.magnitude);
|
|
305
|
+
const thinEvidence = n.evidenceProvided.length === 0;
|
|
306
|
+
|
|
307
|
+
let signal;
|
|
308
|
+
if (irreversible && mag >= 2) {
|
|
309
|
+
signal = p.score >= 0.55 ? (thinEvidence ? 'clarify' : 'escalate')
|
|
310
|
+
: p.score >= 0.4 ? 'escalate'
|
|
311
|
+
: 'proceed_with_note';
|
|
312
|
+
} else if (irreversible || mag >= 2) {
|
|
313
|
+
signal = p.score >= 0.6 ? 'escalate'
|
|
314
|
+
: (thinEvidence && p.score >= 0.45) ? 'clarify'
|
|
315
|
+
: 'proceed_with_note';
|
|
316
|
+
} else {
|
|
317
|
+
signal = p.score >= 0.7 ? 'proceed_with_note' : 'proceed';
|
|
318
|
+
}
|
|
319
|
+
const domain = n.domain ? ` in the ${n.domain} domain` : '';
|
|
320
|
+
const posture = p.score >= 0.55 ? 'favors caution and human review'
|
|
321
|
+
: p.score <= 0.4 ? 'favors acting and adjusting'
|
|
322
|
+
: 'weighs the main risks before committing';
|
|
323
|
+
const hint = irreversible
|
|
324
|
+
? `On irreversible${mag >= 2 ? ', high-magnitude' : ''} actions${domain}, the subject ${posture}.`
|
|
325
|
+
: `On reversible actions${domain}, the subject ${posture}.`;
|
|
326
|
+
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function resolveDomainRiskCheck(vd, n) {
|
|
330
|
+
const p = profile(vd, ['evidenceBar', 'speedQuality', 'decisionThreshold'], false, n.domain);
|
|
331
|
+
const high = p.score >= 0.55;
|
|
332
|
+
const signal = high ? 'clarify' : p.score <= 0.4 ? 'proceed' : 'proceed_with_note';
|
|
333
|
+
const where = n.domain ? ` in ${n.domain}` : '';
|
|
334
|
+
const posture = high ? 'holds a high evidence bar and prefers thoroughness'
|
|
335
|
+
: p.score <= 0.4 ? 'is comfortable moving on the best available signal'
|
|
336
|
+
: 'balances evidence against speed';
|
|
337
|
+
return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveEscalationDefault(vd, n) {
|
|
341
|
+
const p = profile(vd, ['decisionThreshold', 'irreversibleCaution'], true, n.domain);
|
|
342
|
+
let signal;
|
|
343
|
+
if (p.ambiguity.value === 'ask_first') signal = 'clarify';
|
|
344
|
+
else if (p.ambiguity.value === 'proceed') signal = 'proceed_with_note';
|
|
345
|
+
else signal = p.score >= 0.55 ? 'escalate' : 'clarify';
|
|
346
|
+
const hint = signal === 'clarify'
|
|
347
|
+
? 'When uncertain, the subject defaults to asking a clarifying question before acting.'
|
|
348
|
+
: signal === 'escalate'
|
|
349
|
+
? 'When uncertain on consequential calls, the subject defaults to surfacing for a live decision.'
|
|
350
|
+
: 'When uncertain on low-stakes calls, the subject defaults to a reasonable assumption, stated openly.';
|
|
351
|
+
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// voice_draft: v0 does not generate text. Voice == style == the injection
|
|
355
|
+
// projection, so point the agent there (§10.4: content goes through injection).
|
|
356
|
+
function resolveVoiceDraft() {
|
|
357
|
+
return {
|
|
358
|
+
signal: 'clarify',
|
|
359
|
+
hint: 'Drafting in the subject’s voice is served by the injection projection — call phaedo_request_injection and write in that style. (Generation is out of scope for consultation v0.)',
|
|
360
|
+
coverage: null,
|
|
361
|
+
found: null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// §10.3 response shape — the ONLY fields that cross the boundary (§10.4).
|
|
366
|
+
export function shape(type, signal, confidence, rationale_hint, deference_level) {
|
|
367
|
+
return {
|
|
368
|
+
phaedo_response_version: '0.1',
|
|
369
|
+
request_type: 'consultation',
|
|
370
|
+
consultation_type: type,
|
|
371
|
+
signal: SIGNALS.includes(signal) ? signal : 'clarify',
|
|
372
|
+
confidence: Math.max(0, Math.min(1, Number(confidence) || 0)),
|
|
373
|
+
rationale_hint: String(rationale_hint || '').replace(/\s+/g, ' ').trim().slice(0, 280),
|
|
374
|
+
deference_level: ['high', 'medium', 'low'].includes(deference_level) ? deference_level : 'medium',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Rule resolver (deterministic, local, always available — the floor) ─────────
|
|
379
|
+
export function ruleResponse(vd, n) {
|
|
380
|
+
let resolved;
|
|
381
|
+
switch (n.type) {
|
|
382
|
+
case 'action_approval': resolved = resolveActionApproval(vd, n); break;
|
|
383
|
+
case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n); break;
|
|
384
|
+
case 'escalation_default': resolved = resolveEscalationDefault(vd, n); break;
|
|
385
|
+
case 'voice_draft': resolved = resolveVoiceDraft(); break;
|
|
386
|
+
}
|
|
387
|
+
// Abstain floor (§10.3 `insufficient_signal`): a real decision type with ZERO
|
|
388
|
+
// relevant cues has no pattern basis. Say so rather than emit a signal derived
|
|
389
|
+
// only from the action's structure — a confidently-wrong answer is the worst
|
|
390
|
+
// output (brief principle 2). Structural safety belongs in authored policy (step
|
|
391
|
+
// C below), which still overrides this abstain. voice_draft never abstains (it's
|
|
392
|
+
// a redirect). An authored policy can still force a decision on top of this.
|
|
393
|
+
if (n.type !== 'voice_draft' && resolved.found === 0) {
|
|
394
|
+
return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, ABSTAIN_HINT, 'low');
|
|
395
|
+
}
|
|
396
|
+
const cal = n.type === 'voice_draft'
|
|
397
|
+
? { confidence: 0.2, deference_level: 'low' }
|
|
398
|
+
: calibrate(vd, resolved.coverage, resolved.signalConfidence);
|
|
399
|
+
return shape(n.type, resolved.signal, cal.confidence, resolved.hint, cal.deference_level);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Public pure entry ─────────────────────────────────────────────────────────
|
|
403
|
+
// (vd, request[, opts]) -> §10.3 response. Deterministic; applies authored §10.5
|
|
404
|
+
// policies last so a standing guardrail wins (even over an abstain). `opts.policies`
|
|
405
|
+
// (a loaded local-config policy doc, passed in by the server) is data, not I/O —
|
|
406
|
+
// the file read happens in the transport layer, so this stays pure.
|
|
407
|
+
export function resolveConsultationCore(vd, request = {}, opts = {}) {
|
|
408
|
+
assertVaultUsable(vd);
|
|
409
|
+
const n = normalizeRequest(request);
|
|
410
|
+
return applyPolicies(ruleResponse(vd, n), n, loadPolicies(vd, opts));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Back-compat alias: the prior public name of the sync rule entry. Kept so existing
|
|
414
|
+
// importers (tests, spec/test-schemas.mjs) are unchanged.
|
|
415
|
+
export const buildConsultationResponse = resolveConsultationCore;
|
|
416
|
+
|
|
417
|
+
// ── Deterministic-only authorization check (brief M3) ─────────────────────────
|
|
418
|
+
// (vd, request[, opts]) -> a small, fixed-shape result. Checks ONLY standing
|
|
419
|
+
// authorizations (§10.5 force rules) — it does NOT run inferred evaluation, so it is
|
|
420
|
+
// a fast pre-flight an agent can call before a full consultation: "is this already
|
|
421
|
+
// decided by one of my standing rules?". A matched authorization is the subject's
|
|
422
|
+
// deterministic pre-decision (confidence 1.0, deference high, by definition). Honors
|
|
423
|
+
// §10.4 (no layer content crosses — only the rule's decision + provenance note).
|
|
424
|
+
export function checkAuthorization(vd, request = {}, opts = {}) {
|
|
425
|
+
assertVaultUsable(vd);
|
|
426
|
+
const n = normalizeRequest(request);
|
|
427
|
+
const auth = matchAuthorization(n, loadPolicies(vd, opts));
|
|
428
|
+
return {
|
|
429
|
+
phaedo_response_version: '0.1',
|
|
430
|
+
request_type: 'authorization_check',
|
|
431
|
+
consultation_type: n.type,
|
|
432
|
+
authorization_matched: !!auth,
|
|
433
|
+
signal: auth ? auth.signal : null,
|
|
434
|
+
rule_id: auth ? (auth.rule.id || null) : null,
|
|
435
|
+
rationale_hint: auth
|
|
436
|
+
? `Per your standing rule: ${auth.note}.`.replace(/\s+/g, ' ').trim().slice(0, 280)
|
|
437
|
+
: 'No standing authorization matches this action; run phaedo_consult for a full signal, or apply your own default.',
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1",
|
|
3
|
+
"_comment": "Standing AUTHORIZATIONS for phaedo_consult (§10.5): subject-authored rules that OVERRIDE the inferred signal. Force effects (escalate | require_human | decline | autoproceed) resolve the decision deterministically at confidence:1.0, deference:high; bias_cautious | bias_bold nudge one step. match: domains[] (substring, case-insensitive) · magnitude_min/max (low|medium|high) · reversible (true/false) · amount_gt (number; needs action_descriptor.amount). Portable: copy into the vault as phaedo_fingerprint.consult_policies, or point PHAEDO_CONSULT_POLICIES at this file to try them.",
|
|
4
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"id": "irreversible-spend-over-5k-escalate",
|
|
7
|
+
"match": { "domains": ["financial", "payment", "spend", "purchase"], "reversible": false, "amount_gt": 5000 },
|
|
8
|
+
"effect": "escalate",
|
|
9
|
+
"note": "Any irreversible spend over $5,000 goes to me for a live decision"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "irreversible-legal-escalate",
|
|
13
|
+
"match": { "domains": ["legal", "contract"], "reversible": false },
|
|
14
|
+
"effect": "escalate",
|
|
15
|
+
"note": "Irreversible legal or contract actions always go to a human"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "autonomous-external-email-decline",
|
|
19
|
+
"match": { "domains": ["external email", "outbound email", "cold outreach"] },
|
|
20
|
+
"effect": "decline",
|
|
21
|
+
"note": "Do not send autonomous external email on my behalf"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "low-stakes-writing-autoproceed",
|
|
25
|
+
"match": { "domains": ["writing", "draft", "internal note", "content"], "magnitude_max": "medium", "reversible": true },
|
|
26
|
+
"effect": "autoproceed",
|
|
27
|
+
"note": "Low-stakes, reversible writing is pre-approved"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "people-matters-bias-cautious",
|
|
31
|
+
"match": { "domains": ["immigration", "hr", "personnel", "visa"] },
|
|
32
|
+
"effect": "bias_cautious",
|
|
33
|
+
"note": "Lean cautious on people / immigration matters"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|