phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/capture-a11y.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Track B a11y capture core (the Node side of the OS-accessibility helper).
|
|
3
|
+
//
|
|
4
|
+
// The native macOS helper (capture/spike-a11y-macos → the productized reader) observes a
|
|
5
|
+
// chat app's accessibility tree, reconstructs the visible transcript into {role, text}
|
|
6
|
+
// turns, and — on a response-complete SETTLE — pipes a JSON payload to THIS process on
|
|
7
|
+
// stdin. We convert the turns to the shared exchange_batch (capture/exchanges.js), fold
|
|
8
|
+
// them through the SAME spine the Claude Code hook uses (capture/capture-core.js →
|
|
9
|
+
// detectors → per-session state → delta lift), and deposit the signal delta to the
|
|
10
|
+
// relay's SUB-KEYED capture lane (mcp/capture-sync.js), encrypted under fp_sync_key.
|
|
11
|
+
//
|
|
12
|
+
// The native front-end does OS work; this owns the pairing + deposit, so the two tiers
|
|
13
|
+
// (a11y, later OCR) share one Node pipeline and differ only in how turns are obtained.
|
|
14
|
+
//
|
|
15
|
+
// DISCARD-RAW: the turns' text is counted and dropped — nothing but signal names +
|
|
16
|
+
// integers is persisted or transmitted (architecture/persona-vs-conversation-memory.md).
|
|
17
|
+
// FAIL-SAFE: any error exits 0 with no output; unpaired machines still accumulate local
|
|
18
|
+
// state (the mcp-local owner tier reads it), the deposit just no-ops.
|
|
19
|
+
//
|
|
20
|
+
// Payload (one settle = one invocation), stdin JSON:
|
|
21
|
+
// { conversation_id: string, // STABLE per chat → per-session REPLACE (idempotent)
|
|
22
|
+
// source_surface?: string, // default 'a11y-macos'
|
|
23
|
+
// captured_at?: string, // ISO; default now
|
|
24
|
+
// turns: [ { role:'user'|'assistant', text:string, id?, timestamp? }, ... ] }
|
|
25
|
+
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { loadCaptureModule } from './lib/capture-home.js';
|
|
29
|
+
import { isMainModule } from './lib/is-main.js';
|
|
30
|
+
import { loadPairingRecord } from './phone-source.js';
|
|
31
|
+
import { loadCapturePairing, CapturePairingError } from './hooks/session-end.mjs';
|
|
32
|
+
import { buildCaptureDelta, depositCaptureDelta } from './capture-sync.js';
|
|
33
|
+
|
|
34
|
+
const [
|
|
35
|
+
{ turnsToBatch },
|
|
36
|
+
{ foldBatchToSignals },
|
|
37
|
+
{ deriveSourceDeviceId },
|
|
38
|
+
] = await Promise.all([
|
|
39
|
+
'capture/exchanges.js',
|
|
40
|
+
'capture/capture-core.js',
|
|
41
|
+
'capture/source-id.js',
|
|
42
|
+
].map(loadCaptureModule));
|
|
43
|
+
|
|
44
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
45
|
+
const DEFAULT_SURFACE = 'a11y-macos';
|
|
46
|
+
const BUDGET_MS = 10000;
|
|
47
|
+
|
|
48
|
+
// Run one a11y capture payload → deposit. Dependency-injectable for tests.
|
|
49
|
+
// payload — the native helper's stdin JSON (see header).
|
|
50
|
+
// deps — { pairing, statePath, fetch, relayBase, now, mcpDir, env, captureModel }.
|
|
51
|
+
// Returns a result record; never throws.
|
|
52
|
+
export async function runA11yCapture(payload, deps = {}) {
|
|
53
|
+
const nowIso = deps.now || (payload && payload.captured_at) || new Date().toISOString();
|
|
54
|
+
const surface = (payload && payload.source_surface) || DEFAULT_SURFACE;
|
|
55
|
+
|
|
56
|
+
const turns = payload && Array.isArray(payload.turns) ? payload.turns : null;
|
|
57
|
+
if (!turns) return { deposited: false, reason: 'no-turns' };
|
|
58
|
+
const conversationId = (payload && (payload.conversation_id || payload.session_id)) || null;
|
|
59
|
+
|
|
60
|
+
const batch = turnsToBatch(turns, { sessionId: conversationId, platform: surface });
|
|
61
|
+
if (!batch.user_prompts.length) return { deposited: false, reason: 'no-exchanges' };
|
|
62
|
+
|
|
63
|
+
// Resolve the pairing whose key the draining owner can decrypt (same order as the hook:
|
|
64
|
+
// explicit override / legacy capture-pairing file / the MCP's own pairing.json). A
|
|
65
|
+
// PRESENT-but-broken capture-pairing file must not silently fall back to a different key
|
|
66
|
+
// — accumulate state locally and skip the deposit instead.
|
|
67
|
+
let pairing = deps.pairing;
|
|
68
|
+
if (pairing === undefined) {
|
|
69
|
+
try {
|
|
70
|
+
pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (!(e instanceof CapturePairingError)) throw e;
|
|
73
|
+
process.stderr.write(`[phaedo capture:a11y] ${e.message}\n`);
|
|
74
|
+
const sid0 = deriveSourceDeviceId(null, surface);
|
|
75
|
+
await foldBatchToSignals(batch, { sourceId: sid0, surface, statePath: deps.statePath, sessionLocalId: conversationId, now: nowIso, captureModel: deps.captureModel, env: deps.env });
|
|
76
|
+
return { deposited: false, reason: 'capture-pairing-invalid', counted: batch.user_prompts.length };
|
|
77
|
+
}
|
|
78
|
+
if (!pairing) {
|
|
79
|
+
try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const sourceId = deriveSourceDeviceId(pairing, surface);
|
|
83
|
+
|
|
84
|
+
const { signals } = await foldBatchToSignals(batch, {
|
|
85
|
+
sourceId, surface, statePath: deps.statePath, sessionLocalId: conversationId,
|
|
86
|
+
now: nowIso, captureModel: deps.captureModel, env: deps.env,
|
|
87
|
+
});
|
|
88
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal', counted: batch.user_prompts.length };
|
|
89
|
+
|
|
90
|
+
const delta = buildCaptureDelta(signals, { sourceSurface: surface, sourceDeviceId: sourceId, capturedAt: nowIso });
|
|
91
|
+
const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
|
|
92
|
+
if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
|
|
93
|
+
process.stderr.write(`[phaedo capture:a11y] deposit rejected (${res.status || res.reason}) — re-pair so capture can sync.\n`);
|
|
94
|
+
}
|
|
95
|
+
return { ...res, counted: batch.user_prompts.length, signals: signals.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── script entry (the native helper spawns `node capture-a11y.js` and pipes JSON) ──────
|
|
99
|
+
const isMain = isMainModule(import.meta.url); // symlink-safe — see lib/is-main.js
|
|
100
|
+
if (isMain) {
|
|
101
|
+
const readStdin = () => new Promise((res) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
104
|
+
process.stdin.on('end', () => res(data));
|
|
105
|
+
process.stdin.on('error', () => res(''));
|
|
106
|
+
});
|
|
107
|
+
const run = async () => {
|
|
108
|
+
let payload = {};
|
|
109
|
+
try { payload = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
|
|
110
|
+
await runA11yCapture(payload);
|
|
111
|
+
};
|
|
112
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
113
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
114
|
+
.then(() => process.exit(0))
|
|
115
|
+
.catch(() => process.exit(0)); // capture must never break the helper
|
|
116
|
+
}
|
package/capture-local.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The mcp-local merge-owner tier (desktop-capture-deposit.md §4 tier 3).
|
|
2
|
+
//
|
|
3
|
+
// The phone-less, browser-less desktop/CLI user — the Claude Code developer, the
|
|
4
|
+
// audience desktop capture was built FOR — has nobody to drain what their capture
|
|
5
|
+
// hook accumulates: no phone to own the capture_to_owner lane, no extension to
|
|
6
|
+
// materialize it. But the hook and this server share a machine AND a state dir:
|
|
7
|
+
// the hook accumulates cumulative Path-A counters in capture/state.js
|
|
8
|
+
// (~/.phaedo-mcp/capture-state.json) even when unpaired. This module folds that
|
|
9
|
+
// local state into the fingerprint the server SERVES, at read time:
|
|
10
|
+
//
|
|
11
|
+
// - READ-TIME OVERLAY, no persistence. The lift is a cumulative snapshot
|
|
12
|
+
// (delta-lift's idempotency contract), so re-folding on every load converges
|
|
13
|
+
// to the same result — there is nothing to store and no race to manage.
|
|
14
|
+
// The local fingerprint.json is never rewritten; discard-raw holds.
|
|
15
|
+
// - OWNER-GATED. Only when this server is the merge owner per the presence
|
|
16
|
+
// precedence (capture/owner.js): a configured pairing record means a phone
|
|
17
|
+
// owns the lane (its merged fingerprint arrives through the normal pull),
|
|
18
|
+
// so folding again here would double-count the same signal. The caller
|
|
19
|
+
// (fingerprint-source.js) applies this tier only in local, unpaired mode.
|
|
20
|
+
// - WORKS IN THE PUBLISHED PACKAGE. The capture/extraction modules load via
|
|
21
|
+
// lib/capture-home.js (repo root in dev, mcp/vendor/ in the tarball —
|
|
22
|
+
// build-package.mjs vendors the closure). Loading stays dynamic and any
|
|
23
|
+
// import/IO failure still returns the payload unchanged — the fold is an
|
|
24
|
+
// enrichment, never a gate on serving.
|
|
25
|
+
//
|
|
26
|
+
// Re-broadcast (§5): none needed here. If a phone is paired later, the hook's
|
|
27
|
+
// normal capture_to_owner deposits carry the CUMULATIVE counts, so the phone
|
|
28
|
+
// inherits this machine's full history on its first drain — and this tier stops
|
|
29
|
+
// applying (owner-gated), so nothing forks.
|
|
30
|
+
|
|
31
|
+
import { loadCaptureModule } from './lib/capture-home.js';
|
|
32
|
+
|
|
33
|
+
// Build per-source snapshots from the hook's local capture state. Loading stays
|
|
34
|
+
// try-wrapped so a broken layout (neither repo root nor vendor/) degrades to
|
|
35
|
+
// "no snapshots" instead of failing the serve.
|
|
36
|
+
async function localSnapshots(now) {
|
|
37
|
+
let stateMod, liftMod;
|
|
38
|
+
try {
|
|
39
|
+
stateMod = await loadCaptureModule('capture/state.js');
|
|
40
|
+
liftMod = await loadCaptureModule('extraction/delta-lift.js');
|
|
41
|
+
} catch {
|
|
42
|
+
return []; // no capture home in this layout
|
|
43
|
+
}
|
|
44
|
+
const state = stateMod.loadState(stateMod.defaultStatePath());
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const sourceId of Object.keys(state.surfaces || {})) {
|
|
47
|
+
const counts = stateMod.cumulativeCounts(state, sourceId);
|
|
48
|
+
const signals = liftMod.liftDeltaSignals(counts.behavioral_signals, now);
|
|
49
|
+
if (!signals.length) continue;
|
|
50
|
+
const updatedAt = state.surfaces[sourceId] && state.surfaces[sourceId].updated_at;
|
|
51
|
+
out.push({ surface: sourceId, captured_at: updatedAt || now, signals });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fold the local capture state into a vault payload's fingerprint and return the
|
|
57
|
+
// folded payload (or the input unchanged when there is nothing to fold / the
|
|
58
|
+
// modules are unavailable). Pure with respect to the input object.
|
|
59
|
+
export async function foldLocalCapture(vaultPayload, { now = new Date().toISOString() } = {}) {
|
|
60
|
+
if (!vaultPayload || typeof vaultPayload !== 'object' || !vaultPayload.phaedo_fingerprint) return vaultPayload;
|
|
61
|
+
try {
|
|
62
|
+
const sources = await localSnapshots(now);
|
|
63
|
+
if (!sources.length) return vaultPayload;
|
|
64
|
+
const { mergeExtractionInto } = await loadCaptureModule('extraction/persist.js');
|
|
65
|
+
// Empty own-update: the server has no counters of its own; the combine builds
|
|
66
|
+
// the delta update entirely from the source snapshots, then the standard
|
|
67
|
+
// reconcile folds it into the fingerprint (creating layers as needed).
|
|
68
|
+
const fp = mergeExtractionInto(vaultPayload.phaedo_fingerprint, {}, now, sources);
|
|
69
|
+
return { ...vaultPayload, phaedo_fingerprint: fp };
|
|
70
|
+
} catch {
|
|
71
|
+
return vaultPayload; // enrichment only — never break serving
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Is this server the capture merge owner? Thin wrapper over capture/owner.js so
|
|
76
|
+
// the precedence lives in exactly one place; an unloadable module (broken
|
|
77
|
+
// layout) → not owner (the fold simply doesn't apply).
|
|
78
|
+
export async function isLocalCaptureOwner(phonePaired) {
|
|
79
|
+
try {
|
|
80
|
+
const { isOwner } = await loadCaptureModule('capture/owner.js');
|
|
81
|
+
return isOwner('mcp-local', { phonePaired: !!phonePaired, extensionPresent: false });
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/capture-sync.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Capture-delta transport (capture surface → relay → merge owner).
|
|
2
|
+
//
|
|
3
|
+
// Track A of the desktop-capture plan. The Claude Code / Cowork capture hook reads the
|
|
4
|
+
// local session transcript, runs the surface-agnostic extraction engine, and deposits
|
|
5
|
+
// the resulting SIGNAL DELTA (never a whole fingerprint) into the relay's
|
|
6
|
+
// `capture_to_owner` mailbox lane, encrypted under fp_sync_key. Exactly one merge owner
|
|
7
|
+
// (phone → extension → mcp-local, capture/owner.js) drains and reconciles it per-source
|
|
8
|
+
// (capture/source-id.js), so concurrent capture on multiple surfaces accumulates instead
|
|
9
|
+
// of clobbering — docs/roadmap/desktop-capture-deposit.md.
|
|
10
|
+
//
|
|
11
|
+
// Mirrors delegation-sync.js in trust posture, but NOT in key sensitivity: this lane is
|
|
12
|
+
// drained by the PHONE, which tries each verified client's key until one decrypts, so an
|
|
13
|
+
// independently-paired MCP works fine here (unlike agent_to_ext — see delegation-sync.js).
|
|
14
|
+
// The relay only ever holds ciphertext; and the deposit is best-effort and
|
|
15
|
+
// isolated — a transport failure never breaks the session that produced the capture.
|
|
16
|
+
//
|
|
17
|
+
// The lane carries a DELTA, so a deposit can never overwrite signal the owner captured
|
|
18
|
+
// on its own; and per-source idempotency (source_device_id) means a re-deposit or a
|
|
19
|
+
// transient two-owner overlap is a no-op, not a double-count.
|
|
20
|
+
|
|
21
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
22
|
+
|
|
23
|
+
const { Kdf, Envelope } = Phaedo;
|
|
24
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
25
|
+
|
|
26
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
27
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build the canonical capture-delta payload from lifted signals (Q1: lift on the
|
|
31
|
+
// surface — the hook runs extraction and ships §5.4 signals, not raw counters).
|
|
32
|
+
// signals — the extraction engine's signal list (source-tagged; 'delta' snapshots
|
|
33
|
+
// are idempotent on re-lift).
|
|
34
|
+
export function buildCaptureDelta(signals, { sourceSurface, sourceDeviceId, capturedAt } = {}) {
|
|
35
|
+
return {
|
|
36
|
+
capture_delta_version: 'v0.1',
|
|
37
|
+
source_surface: sourceSurface || 'unknown',
|
|
38
|
+
source_device_id: sourceDeviceId || null,
|
|
39
|
+
captured_at: capturedAt || null,
|
|
40
|
+
delta: { signals: Array.isArray(signals) ? signals : [] },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Wrap a capture-delta into the same envelope shape the owner's fp-mailbox drain
|
|
45
|
+
// expects: ciphertext over `{ kind:'capture_delta', data: <captureDelta> }`. Returns the
|
|
46
|
+
// deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
|
|
47
|
+
export async function buildCaptureDeposit(pairKeyB64u, captureDelta, { now = () => new Date().toISOString() } = {}) {
|
|
48
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
49
|
+
let fpSyncKey;
|
|
50
|
+
try {
|
|
51
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
52
|
+
} finally {
|
|
53
|
+
pairKey.fill(0);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const inner = { kind: 'capture_delta', data: captureDelta };
|
|
57
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
58
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'capture_delta', created_at: now() });
|
|
59
|
+
plaintext.fill(0);
|
|
60
|
+
return { envelope };
|
|
61
|
+
} finally {
|
|
62
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deposit a capture-delta to the relay's capture_to_owner slot. Best-effort: returns
|
|
67
|
+
// { deposited, ... } and never throws. `deps.fetch` is injectable for tests.
|
|
68
|
+
export async function depositCaptureDelta(pairing, captureDelta, deps = {}) {
|
|
69
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
70
|
+
const signals = captureDelta && captureDelta.delta && Array.isArray(captureDelta.delta.signals)
|
|
71
|
+
? captureDelta.delta.signals : [];
|
|
72
|
+
// Precise reasons so the caller can log WHY a deposit didn't happen.
|
|
73
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal' };
|
|
74
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
75
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
76
|
+
try {
|
|
77
|
+
const body = await buildCaptureDeposit(pairing.pair_key, captureDelta, deps);
|
|
78
|
+
// Sub-key the lane per source so a second surface on this device doesn't clobber the
|
|
79
|
+
// hook's deposit before the owner drains (desktop-capture-deposit.md). Falls back to
|
|
80
|
+
// the flat lane only if the delta somehow lacks a source id (pre-source-id builds).
|
|
81
|
+
const src = captureDelta && captureDelta.source_device_id;
|
|
82
|
+
const lane = src ? `capture_to_owner/${encodeURIComponent(src)}` : 'capture_to_owner';
|
|
83
|
+
const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/${lane}`;
|
|
84
|
+
// Per-device auth header (relay/auth.js). Without it the relay 401s → deposit no-ops
|
|
85
|
+
// (correct failure mode: the user re-pairs to capture the relay_device_secret).
|
|
86
|
+
const authHeader = pairing.relay_device_secret
|
|
87
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
88
|
+
: {};
|
|
89
|
+
const res = await fetchFn(base, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
94
|
+
});
|
|
95
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: signals.length };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
98
|
+
}
|
|
99
|
+
}
|
package/consult-core.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { PhaedoError } from './errors.js';
|
|
18
18
|
import { loadPolicies, applyPolicies, matchAuthorization } from './consult-policy.js';
|
|
19
|
+
import { normalizeActionBinding, isBound } from './action-binding.js';
|
|
20
|
+
import { normalizeDomain, canonicalDomain } from './lib/domain.js';
|
|
19
21
|
|
|
20
22
|
export const CONSULTATION_TYPES = [
|
|
21
23
|
'action_approval',
|
|
@@ -354,6 +356,68 @@ function calibrate(vd, coverage, signalConfidence = 0) {
|
|
|
354
356
|
|
|
355
357
|
const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
|
|
356
358
|
|
|
359
|
+
// ── Amount bands (precedent track G8 B1) ──────────────────────────────────────
|
|
360
|
+
// Until this, the inferred path never read `amount`: $12 and $12,000 answered alike
|
|
361
|
+
// unless the agent ALSO labelled the magnitude, and only authored §10.5 rules saw money.
|
|
362
|
+
// Now an amount sets a FLOOR under the magnitude the inferred path reasons about.
|
|
363
|
+
//
|
|
364
|
+
// Band edges are the subject's own numbers wherever they exist: every amount bound on an
|
|
365
|
+
// authored rule (amount_gt / amount_lt / amount_lte — a grant's ceiling says what is small
|
|
366
|
+
// to them as surely as a prohibition's floor says what is large). Rules scoped to the
|
|
367
|
+
// action's domain (or unscoped) win; failing those, any authored amount anywhere; failing
|
|
368
|
+
// that, DEFAULT_AMOUNT_EDGES — a stated default, recorded as such on the receipt, replaced
|
|
369
|
+
// by the subject's first money rule.
|
|
370
|
+
//
|
|
371
|
+
// CAUTION-ONLY, by construction: the floor is max()'d with the declared magnitude, so it
|
|
372
|
+
// can raise the stakes and never lower them. And it feeds the INFERRED path only — §10.5
|
|
373
|
+
// matching keeps reading the declared magnitude, so an authored grant's `magnitude_max`
|
|
374
|
+
// means exactly what the subject wrote.
|
|
375
|
+
export const DEFAULT_AMOUNT_EDGES = [100, 1000];
|
|
376
|
+
|
|
377
|
+
// ── Resolver revision (precedent track Phase 1b) ──────────────────────────────
|
|
378
|
+
// Which version of the inferred path answered. Stamped on every inferred receipt (as a
|
|
379
|
+
// `resolverRevision` driver — local provenance, never in the response) so agreement can be
|
|
380
|
+
// split before/after a behaviour change instead of blurring into one number.
|
|
381
|
+
//
|
|
382
|
+
// BUMP THIS WHENEVER THE INFERRED PATH'S ANSWERS CHANGE. You will not be able to forget:
|
|
383
|
+
// test-resolver-revision.mjs fingerprints the answers over a fixed grid and fails, naming
|
|
384
|
+
// this constant, when they move under an unchanged revision. Format: <date>.<what changed>.
|
|
385
|
+
// (receipts before 2026-10-20 carry none — the pre-B1 rules and the first days of B1)
|
|
386
|
+
export const RESOLVER_REVISION = '2026-10-19.amount-bands';
|
|
387
|
+
|
|
388
|
+
function ruleAmounts(r) {
|
|
389
|
+
return [r?.amountGt, r?.amountLt, r?.amountLte].filter((x) => typeof x === 'number' && Number.isFinite(x) && x > 0);
|
|
390
|
+
}
|
|
391
|
+
function ruleCoversDomain(r, domain) {
|
|
392
|
+
const ds = Array.isArray(r?.domains) ? r.domains : [];
|
|
393
|
+
if (!ds.length) return true; // unscoped → speaks for every domain
|
|
394
|
+
if (!domain) return false;
|
|
395
|
+
const canon = canonicalDomain(domain);
|
|
396
|
+
return ds.some((d) => domain.includes(d) || (canon && canonicalDomain(d) === canon));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The band edges for this action: { edges: [low, high] (ascending, low may equal high), basis }. */
|
|
400
|
+
export function amountEdges(policies, domain) {
|
|
401
|
+
const list = Array.isArray(policies) ? policies : [];
|
|
402
|
+
const scoped = list.filter((r) => ruleCoversDomain(r, domain)).flatMap(ruleAmounts);
|
|
403
|
+
const any = scoped.length ? scoped : list.flatMap(ruleAmounts);
|
|
404
|
+
if (!any.length) return { edges: DEFAULT_AMOUNT_EDGES.slice(), basis: null };
|
|
405
|
+
return { edges: [Math.min(...any), Math.max(...any)], basis: 'self_reported' };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The magnitude floor an amount implies, or null when it implies none.
|
|
410
|
+
* Above the high edge → `high`; above the low edge → `medium`. With a single authored
|
|
411
|
+
* number ("escalate above $1,000") both edges are that number: above it is `high`.
|
|
412
|
+
*/
|
|
413
|
+
export function amountFloor(n, policies) {
|
|
414
|
+
const a = n?.amount;
|
|
415
|
+
if (typeof a !== 'number' || !Number.isFinite(a) || a <= 0) return null;
|
|
416
|
+
const { edges: [lo, hi], basis } = amountEdges(policies, n.domain);
|
|
417
|
+
const band = a > hi ? 'high' : a > lo ? 'medium' : null;
|
|
418
|
+
return band ? { band, rank: magnitudeRank(band), basis } : null;
|
|
419
|
+
}
|
|
420
|
+
|
|
357
421
|
// ── Request validation/normalization (§10.2) ──────────────────────────────────
|
|
358
422
|
export function normalizeRequest(request) {
|
|
359
423
|
const type = request.consultation_type;
|
|
@@ -367,14 +431,31 @@ export function normalizeRequest(request) {
|
|
|
367
431
|
}
|
|
368
432
|
const ctx = (request.context && typeof request.context === 'object') ? request.context : {};
|
|
369
433
|
const ev = ctx.evidence_provided;
|
|
434
|
+
// §10.2 action_binding: the concrete call this descriptor claims to describe.
|
|
435
|
+
// Optional — an unbound request resolves exactly as before — but an authored
|
|
436
|
+
// authorization may refuse to RELAX anything without it (§10.5 require_binding).
|
|
437
|
+
const binding = normalizeActionBinding(request.action_binding);
|
|
370
438
|
return {
|
|
371
439
|
type,
|
|
372
|
-
|
|
440
|
+
// NORMALIZED only (case/whitespace) — deliberately NOT vocabulary-mapped. The
|
|
441
|
+
// request domain feeds §10.5 matching, and a relaxing rule must keep matching on
|
|
442
|
+
// the literal the agent sent; `domainCovers` applies the vocabulary itself, and
|
|
443
|
+
// only for restrictive rules. Mapping here instead would let an alias widen a
|
|
444
|
+
// grant. The RECEIPT stores the canonical form (that is the evidence axis).
|
|
445
|
+
domain: typeof ad.domain === 'string' ? (normalizeDomain(ad.domain) || undefined) : undefined,
|
|
373
446
|
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
|
|
374
|
-
|
|
447
|
+
// UNSTATED STAYS UNSTATED (v0.1.68). This defaulted to 'medium', which meant the resolver could
|
|
448
|
+
// never tell "the agent said medium" from "the agent said nothing" — and §10.5 then let a
|
|
449
|
+
// medium-capped grant fire on the nothing. The receipt now records what was actually sent
|
|
450
|
+
// (null), which is also what situational precedent (OQ24) needs to band on. The inferred
|
|
451
|
+
// oracle path still reads an unknown as the middle of the scale (`magnitudeRank`); that is a
|
|
452
|
+
// disposition heuristic, not a grant, and it is unchanged.
|
|
453
|
+
magnitude,
|
|
375
454
|
amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
|
|
376
455
|
summary: typeof ad.summary === 'string' ? ad.summary : undefined,
|
|
377
456
|
evidenceProvided: Array.isArray(ev) ? ev : (ev != null ? [ev] : []),
|
|
457
|
+
binding,
|
|
458
|
+
bound: isBound(binding),
|
|
378
459
|
};
|
|
379
460
|
}
|
|
380
461
|
|
|
@@ -392,7 +473,8 @@ const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
|
|
|
392
473
|
function resolveActionApproval(vd, n, suppressed) {
|
|
393
474
|
const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
|
|
394
475
|
const irreversible = n.reversible === false;
|
|
395
|
-
|
|
476
|
+
// Declared stakes, raised (never lowered) by what the amount means to this subject.
|
|
477
|
+
const mag = Math.max(magnitudeRank(n.magnitude), n.amountBand ? n.amountBand.rank : 0);
|
|
396
478
|
const thinEvidence = n.evidenceProvided.length === 0;
|
|
397
479
|
|
|
398
480
|
let signal;
|
|
@@ -462,7 +544,7 @@ function resolveVoiceDraft() {
|
|
|
462
544
|
// sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
|
|
463
545
|
// Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
|
|
464
546
|
// internally so callers pass exactly what they passed to the consult.
|
|
465
|
-
export function consultDrivers(vd, request) {
|
|
547
|
+
export function consultDrivers(vd, request, opts = {}) {
|
|
466
548
|
let n;
|
|
467
549
|
try { n = normalizeRequest(request || {}); } catch { return []; }
|
|
468
550
|
const keys = RELEVANT_CUES[n.type];
|
|
@@ -480,6 +562,13 @@ export function consultDrivers(vd, request) {
|
|
|
480
562
|
const a = readAmbiguity(vd, n.domain, suppressed);
|
|
481
563
|
if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
|
|
482
564
|
}
|
|
565
|
+
// The amount band, when it raised the stakes. basis `self_reported` = the subject's own
|
|
566
|
+
// thresholds; null = the stated default (no subject evidence behind it).
|
|
567
|
+
if (n.type === 'action_approval') {
|
|
568
|
+
let band = null;
|
|
569
|
+
try { band = amountFloor(n, loadPolicies(vd, opts)); } catch { /* provenance is non-critical */ }
|
|
570
|
+
if (band) drivers.push({ cue: 'amountBand', value: band.band, confidence: 1, basis: band.basis });
|
|
571
|
+
}
|
|
483
572
|
return drivers;
|
|
484
573
|
}
|
|
485
574
|
|
|
@@ -530,7 +619,15 @@ export function deriveDelegationSignals(receipts, { now = new Date().toISOString
|
|
|
530
619
|
const sig = r?.response?.signal;
|
|
531
620
|
const contribution = delegationCorrection(sig, r && r.user_action); // signed; 0 = agreement/no lesson
|
|
532
621
|
if (!contribution) continue;
|
|
533
|
-
|
|
622
|
+
// Canonicalize the domain BEFORE bucketing, or one concept spelled several ways
|
|
623
|
+
// (finance / financial / Finance) fragments into several near-identical buckets — the
|
|
624
|
+
// drift §10.2's published vocabulary exists to end. Both the live overlay
|
|
625
|
+
// (deriveDelegationSignals) and the promotion cards (buildDelegationPromotions) key on
|
|
626
|
+
// this, so both must canonicalize or a card floods / an overlay splits. Free text still
|
|
627
|
+
// survives — an unrecognized domain canonicalizes to itself — so only known synonyms
|
|
628
|
+
// merge, never distinct classes.
|
|
629
|
+
const domain = canonicalDomain(
|
|
630
|
+
(typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '');
|
|
534
631
|
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
535
632
|
if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later)
|
|
536
633
|
const k = `${d.cue}|${domain}`;
|
|
@@ -627,7 +724,15 @@ export function buildDelegationPromotions(receipts, fp, { now = new Date().toISO
|
|
|
627
724
|
const sig = r?.response?.signal;
|
|
628
725
|
const contribution = delegationCorrection(sig, r && r.user_action);
|
|
629
726
|
if (!contribution) continue;
|
|
630
|
-
|
|
727
|
+
// Canonicalize the domain BEFORE bucketing, or one concept spelled several ways
|
|
728
|
+
// (finance / financial / Finance) fragments into several near-identical buckets — the
|
|
729
|
+
// drift §10.2's published vocabulary exists to end. Both the live overlay
|
|
730
|
+
// (deriveDelegationSignals) and the promotion cards (buildDelegationPromotions) key on
|
|
731
|
+
// this, so both must canonicalize or a card floods / an overlay splits. Free text still
|
|
732
|
+
// survives — an unrecognized domain canonicalizes to itself — so only known synonyms
|
|
733
|
+
// merge, never distinct classes.
|
|
734
|
+
const domain = canonicalDomain(
|
|
735
|
+
(typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '');
|
|
631
736
|
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
632
737
|
const dim = CUE_DIMENSION[d && d.cue];
|
|
633
738
|
if (!dim) continue;
|
|
@@ -744,6 +849,15 @@ export function ruleResponse(vd, n) {
|
|
|
744
849
|
return shape(n.type, resolved.signal, cal.confidence, resolved.hint, cal.deference_level);
|
|
745
850
|
}
|
|
746
851
|
|
|
852
|
+
// The §10.5 evaluation context, lifted out of `opts` once so both public entries
|
|
853
|
+
// gate identically. `clientId` is the SERVING paired client's own `client_id` (from
|
|
854
|
+
// the server's `pairing.json`, supplied by the transport layer) — the identity
|
|
855
|
+
// `agents` scoping matches on; never anything the caller asserted. Absent here means
|
|
856
|
+
// unpaired, which makes every `agents`-scoped relaxing rule inert. Passing `now`
|
|
857
|
+
// through as well keeps rule expiry on the same clock `loadPolicies` already gives
|
|
858
|
+
// §5.7 commitments; both fall back to `Date.now()` when a caller omits them.
|
|
859
|
+
const policyCtx = (opts = {}) => ({ now: opts.now, clientId: opts.clientId ?? null });
|
|
860
|
+
|
|
747
861
|
// ── Public pure entry ─────────────────────────────────────────────────────────
|
|
748
862
|
// (vd, request[, opts]) -> §10.3 response. Deterministic; applies authored §10.5
|
|
749
863
|
// policies last so a standing guardrail wins (even over an abstain). `opts.policies`
|
|
@@ -752,7 +866,11 @@ export function ruleResponse(vd, n) {
|
|
|
752
866
|
export function resolveConsultationCore(vd, request = {}, opts = {}) {
|
|
753
867
|
assertVaultUsable(vd);
|
|
754
868
|
const n = normalizeRequest(request);
|
|
755
|
-
|
|
869
|
+
const policies = loadPolicies(vd, opts);
|
|
870
|
+
// G8 B1: the amount's band, read against the subject's own thresholds. On `n` so the
|
|
871
|
+
// resolver sees it; applyPolicies does not read it (§10.5 matches the declared magnitude).
|
|
872
|
+
n.amountBand = amountFloor(n, policies);
|
|
873
|
+
return applyPolicies(ruleResponse(vd, n), n, policies, policyCtx(opts));
|
|
756
874
|
}
|
|
757
875
|
|
|
758
876
|
// Back-compat alias: the prior public name of the sync rule entry. Kept so existing
|
|
@@ -769,7 +887,7 @@ export const buildConsultationResponse = resolveConsultationCore;
|
|
|
769
887
|
export function checkAuthorization(vd, request = {}, opts = {}) {
|
|
770
888
|
assertVaultUsable(vd);
|
|
771
889
|
const n = normalizeRequest(request);
|
|
772
|
-
const auth = matchAuthorization(n, loadPolicies(vd, opts));
|
|
890
|
+
const auth = matchAuthorization(n, loadPolicies(vd, opts), policyCtx(opts));
|
|
773
891
|
return {
|
|
774
892
|
phaedo_response_version: '0.1',
|
|
775
893
|
request_type: 'authorization_check',
|