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/install.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// node install.mjs --only cursor restrict to one client id
|
|
23
23
|
// node install.mjs --help
|
|
24
24
|
|
|
25
|
-
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, rmSync } from 'fs';
|
|
26
26
|
import { dirname, join, resolve } from 'path';
|
|
27
27
|
import { homedir } from 'os';
|
|
28
28
|
import { fileURLToPath } from 'url';
|
|
@@ -31,8 +31,22 @@ import { spawnSync } from 'child_process';
|
|
|
31
31
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
32
|
const SERVER_PATH = join(BASE_DIR, 'server.js');
|
|
33
33
|
const HOOK_PATH = join(BASE_DIR, 'hooks', 'session-start.mjs');
|
|
34
|
+
const CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'session-end.mjs');
|
|
34
35
|
const ENTRY_KEY = 'phaedo';
|
|
35
|
-
const HOOK_MARKER = 'hooks/session-start.mjs';
|
|
36
|
+
const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
|
|
37
|
+
const CAPTURE_HOOK_MARKER = 'hooks/session-end.mjs'; // identifies our SessionEnd capture entry
|
|
38
|
+
// The same capture, run on Stop (end of each turn) for sessions that stay open for days
|
|
39
|
+
// (the desktop app's) — throttled per session inside the hook, so most Stops cost one read.
|
|
40
|
+
const STOP_CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'stop-capture.mjs');
|
|
41
|
+
const STOP_CAPTURE_HOOK_MARKER = 'hooks/stop-capture.mjs';
|
|
42
|
+
const SHIP_HOOK_PATH = join(BASE_DIR, 'hooks', 'pre-ship.mjs');
|
|
43
|
+
const SHIP_HOOK_MARKER = 'hooks/pre-ship.mjs'; // identifies our ship-consult entries
|
|
44
|
+
// The ship hook (consult before a merge / deploy / phone build) runs on four Bash events:
|
|
45
|
+
// before the command, when a prompt is shown (the evidence a person was asked), and after
|
|
46
|
+
// it ran or failed (to record that person's yes). Its own budget is 12s; the timeout is
|
|
47
|
+
// the backstop.
|
|
48
|
+
export const SHIP_HOOK_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure'];
|
|
49
|
+
const SHIP_HOOK_ENTRY = { matcher: 'Bash', timeout: 15 };
|
|
36
50
|
|
|
37
51
|
// ── Client registry ──────────────────────────────────────────────────────────
|
|
38
52
|
// Every supported client stores MCP servers as a top-level `mcpServers` object,
|
|
@@ -72,7 +86,13 @@ export function serverEntry({ nodePath = process.execPath, serverPath = SERVER_P
|
|
|
72
86
|
return entry;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
// ── Consult model judge config (
|
|
89
|
+
// ── Consult model judge config (OFF by default — opt in) ──────────────────────
|
|
90
|
+
// Was on by default. Measured 2026-10-18 on the dev Mac against the same cached profile:
|
|
91
|
+
// llama3.1:8b answered a $12k IRREVERSIBLE payment `clarify 0.9` on one run and
|
|
92
|
+
// `proceed_with_note 0.9` on the next — flipping between runs, toward proceeding, with
|
|
93
|
+
// high confidence both times — where the rule engine says `clarify` every time. A judge
|
|
94
|
+
// that can talk itself into a payment does not get to be the default. Opt in with
|
|
95
|
+
// --model-judge (auto-detect) or --consult-model <id> (pin).
|
|
76
96
|
// The MCP server runs the consult model judge when PHAEDO_CONSULT_MODEL is set;
|
|
77
97
|
// it falls back to the local rule engine if the model/Ollama isn't there, so
|
|
78
98
|
// defaulting it ON is safe. We write a LOCAL endpoint only (privacy-clean); a
|
|
@@ -86,15 +106,21 @@ export function buildConsultEnv({ model = DEFAULT_CONSULT_MODEL, url = DEFAULT_C
|
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
// Probe local Ollama so "on by default" works with whatever model the user has.
|
|
89
|
-
//
|
|
90
|
-
//
|
|
109
|
+
// Prefers the default when it is installed, else the first installed CHAT model —
|
|
110
|
+
// skipping the phaedo extraction fine-tunes and embedding models. An embedding model
|
|
111
|
+
// cannot answer a chat prompt: `nomic-embed-text` was picked on the dev Mac (it sorts
|
|
112
|
+
// before llama3.1:8b), every judge call failed in ~15 ms and fell back to the rules,
|
|
113
|
+
// and the installer kept printing "model judge ON" for a judge that had never run.
|
|
114
|
+
// Falls back to the default string + reachable:false on any failure.
|
|
115
|
+
const NOT_A_JUDGE = /phaedo|extract|embed|nomic|bge|minilm|mxbai|arctic|e5-|gte-|rerank/i;
|
|
91
116
|
export async function detectConsultModel({ url = DEFAULT_CONSULT_URL, fallback = DEFAULT_CONSULT_MODEL, fetchImpl = globalThis.fetch } = {}) {
|
|
92
117
|
try {
|
|
93
118
|
const res = await fetchImpl(`${url.replace(/\/$/, '')}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
94
119
|
if (!res.ok) return { model: fallback, reachable: false, hadModels: false };
|
|
95
120
|
const data = await res.json();
|
|
96
121
|
const names = (data.models || []).map((m) => m.name || m.model).filter(Boolean);
|
|
97
|
-
const
|
|
122
|
+
const bare = (n) => String(n).replace(/:latest$/, '');
|
|
123
|
+
const general = names.find((n) => bare(n) === bare(fallback)) || names.find((n) => !NOT_A_JUDGE.test(n));
|
|
98
124
|
return { model: general || fallback, reachable: true, hadModels: names.length > 0, picked: !!general };
|
|
99
125
|
} catch {
|
|
100
126
|
return { model: fallback, reachable: false, hadModels: false };
|
|
@@ -147,32 +173,54 @@ export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH
|
|
|
147
173
|
return `"${nodePath}" "${hookPath}"`;
|
|
148
174
|
}
|
|
149
175
|
|
|
150
|
-
|
|
176
|
+
// Match the marker path-separator-agnostically: the marker uses forward slashes
|
|
177
|
+
// ('hooks/session-end.mjs') but the command is built with path.join(), which on Windows
|
|
178
|
+
// yields backslashes ('...\\hooks\\session-end.mjs'). Without normalizing, Windows never
|
|
179
|
+
// recognizes its own hook entries — re-installs duplicate them and uninstall can't remove
|
|
180
|
+
// them. Normalize both sides to forward slashes before the substring test.
|
|
181
|
+
const norm = (s) => String(s).replace(/\\/g, '/');
|
|
182
|
+
const isPhaedoHookEntryFor = (marker) => (e) =>
|
|
151
183
|
e && Array.isArray(e.hooks) &&
|
|
152
|
-
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(
|
|
184
|
+
e.hooks.some((h) => typeof h.command === 'string' && norm(h.command).includes(norm(marker)));
|
|
153
185
|
|
|
154
|
-
|
|
186
|
+
// Generic per-event merge/remove — the SessionStart (injection) and SessionEnd
|
|
187
|
+
// (capture) entries use the same shape, distinguished by their script-path marker.
|
|
188
|
+
// `matcher` / `timeout` (optional) are for tool events: the ship hook filters to Bash.
|
|
189
|
+
export function mergeHookEntry(settings, event, command, marker, { matcher, timeout } = {}) {
|
|
190
|
+
const isOurs = isPhaedoHookEntryFor(marker);
|
|
155
191
|
const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
|
|
156
|
-
const prior = Array.isArray(next.hooks
|
|
157
|
-
const others = prior.filter((e) => !
|
|
158
|
-
const existing = prior.find(
|
|
159
|
-
const
|
|
160
|
-
|
|
192
|
+
const prior = Array.isArray(next.hooks[event]) ? next.hooks[event] : [];
|
|
193
|
+
const others = prior.filter((e) => !isOurs(e)); // drop a stale phaedo entry (path refresh)
|
|
194
|
+
const existing = prior.find(isOurs);
|
|
195
|
+
const handler = { type: 'command', command, ...(timeout ? { timeout } : {}) };
|
|
196
|
+
const entry = { ...(matcher ? { matcher } : {}), hooks: [handler] };
|
|
197
|
+
const already = !!existing && existing.matcher === entry.matcher &&
|
|
198
|
+
existing.hooks.some((h) => h.command === command && h.timeout === handler.timeout);
|
|
199
|
+
next.hooks[event] = [...others, entry];
|
|
161
200
|
return { settings: next, changed: !already };
|
|
162
201
|
}
|
|
163
202
|
|
|
164
|
-
export function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
203
|
+
export function removeHookEntry(settings, event, marker) {
|
|
204
|
+
const isOurs = isPhaedoHookEntryFor(marker);
|
|
205
|
+
if (!settings.hooks || !Array.isArray(settings.hooks[event])) return { settings, changed: false };
|
|
206
|
+
const prior = settings.hooks[event];
|
|
207
|
+
const others = prior.filter((e) => !isOurs(e));
|
|
168
208
|
if (others.length === prior.length) return { settings, changed: false };
|
|
169
209
|
const hooks = { ...settings.hooks };
|
|
170
|
-
if (others.length) hooks
|
|
210
|
+
if (others.length) hooks[event] = others; else delete hooks[event];
|
|
171
211
|
const next = { ...settings, hooks };
|
|
172
212
|
if (Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
173
213
|
return { settings: next, changed: true };
|
|
174
214
|
}
|
|
175
215
|
|
|
216
|
+
// Back-compat wrappers (SessionStart injection hook).
|
|
217
|
+
export function mergeSessionStartHook(settings, command) {
|
|
218
|
+
return mergeHookEntry(settings, 'SessionStart', command, HOOK_MARKER);
|
|
219
|
+
}
|
|
220
|
+
export function removeSessionStartHook(settings) {
|
|
221
|
+
return removeHookEntry(settings, 'SessionStart', HOOK_MARKER);
|
|
222
|
+
}
|
|
223
|
+
|
|
176
224
|
// action: 'install' | 'uninstall'. Returns a result record. settings.json is a
|
|
177
225
|
// small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
|
|
178
226
|
export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
|
|
@@ -186,15 +234,63 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
186
234
|
}
|
|
187
235
|
if (action === 'uninstall') {
|
|
188
236
|
if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
237
|
+
const start = removeSessionStartHook(read.config);
|
|
238
|
+
const both = removeHookEntry(start.settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
239
|
+
const stop = removeHookEntry(both.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
240
|
+
let s = stop.settings;
|
|
241
|
+
let shipChanged = false;
|
|
242
|
+
for (const ev of SHIP_HOOK_EVENTS) {
|
|
243
|
+
const r = removeHookEntry(s, ev, SHIP_HOOK_MARKER);
|
|
244
|
+
s = r.settings; shipChanged = shipChanged || r.changed;
|
|
245
|
+
}
|
|
246
|
+
if (!start.changed && !both.changed && !stop.changed && !shipChanged) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
|
|
247
|
+
if (!dryRun) { backup(path); writeConfig(path, s); }
|
|
192
248
|
return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
|
|
193
249
|
}
|
|
194
|
-
|
|
195
|
-
|
|
250
|
+
// Always install SessionStart injection. Only install the SessionEnd + Stop CAPTURE hooks
|
|
251
|
+
// when its dependencies actually resolve from THIS install location: the hook loads
|
|
252
|
+
// capture/* and extraction/delta-lift.js via lib/capture-home.js — repo root in a
|
|
253
|
+
// clone, mcp/vendor/ in the published tarball (build-package.mjs vendors the
|
|
254
|
+
// closure). captureHookAvailable() gates the wiring so a broken layout (e.g. a
|
|
255
|
+
// hand-copied hooks/ dir with neither home) degrades to injection-only instead of
|
|
256
|
+
// failing every session end with ERR_MODULE_NOT_FOUND.
|
|
257
|
+
const start = mergeSessionStartHook(read.config, hookCommand());
|
|
258
|
+
let settings = start.settings;
|
|
259
|
+
let captureChanged = false;
|
|
260
|
+
let captureNote = '';
|
|
261
|
+
if (captureHookAvailable()) {
|
|
262
|
+
const both = mergeHookEntry(settings, 'SessionEnd', hookCommand({ hookPath: CAPTURE_HOOK_PATH }), CAPTURE_HOOK_MARKER);
|
|
263
|
+
const stop = mergeHookEntry(both.settings, 'Stop', hookCommand({ hookPath: STOP_CAPTURE_HOOK_PATH }), STOP_CAPTURE_HOOK_MARKER);
|
|
264
|
+
settings = stop.settings; captureChanged = both.changed || stop.changed;
|
|
265
|
+
} else {
|
|
266
|
+
// Never leave a broken capture hook wired if we can't run it here.
|
|
267
|
+
const rm = removeHookEntry(settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
268
|
+
const rmStop = removeHookEntry(rm.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
269
|
+
settings = rmStop.settings; captureChanged = rm.changed || rmStop.changed;
|
|
270
|
+
captureNote = ' (capture hook skipped — not available in this install; injection only)';
|
|
271
|
+
}
|
|
272
|
+
// Ship hook: consult before a merge / deploy / phone build. Its imports are all inside
|
|
273
|
+
// mcp/, so it runs wherever the server does — no availability gate needed.
|
|
274
|
+
let shipChanged = false;
|
|
275
|
+
for (const ev of SHIP_HOOK_EVENTS) {
|
|
276
|
+
const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, SHIP_HOOK_ENTRY);
|
|
277
|
+
settings = r.settings; shipChanged = shipChanged || r.changed;
|
|
278
|
+
}
|
|
279
|
+
const changed = start.changed || captureChanged || shipChanged;
|
|
280
|
+
if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
|
|
196
281
|
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
|
197
|
-
return { name, status: dryRun ? 'would-write' : 'configured', detail: path
|
|
282
|
+
return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote,
|
|
283
|
+
restartHint: 'Start a new Claude Code session — hooks load when a session starts.' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The SessionEnd capture hook can only run where its imported modules resolve —
|
|
287
|
+
// the repo root (dev clone) or mcp/vendor/ (published tarball), mirroring
|
|
288
|
+
// lib/capture-home.js's resolution exactly.
|
|
289
|
+
export function captureHookAvailable(hookPath = CAPTURE_HOOK_PATH) {
|
|
290
|
+
const hookDir = dirname(hookPath);
|
|
291
|
+
const homes = [join(hookDir, '..', '..'), join(hookDir, '..', 'vendor')];
|
|
292
|
+
return homes.some((h) => existsSync(join(h, 'capture', 'transcript.js')) &&
|
|
293
|
+
existsSync(join(h, 'extraction', 'delta-lift.js')));
|
|
198
294
|
}
|
|
199
295
|
|
|
200
296
|
function backup(path) {
|
|
@@ -240,18 +336,88 @@ export function applyToClient(client, { action, entry, dryRun = false } = {}) {
|
|
|
240
336
|
return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
|
|
241
337
|
}
|
|
242
338
|
|
|
339
|
+
// Default on-disk locations for everything the server persists. The state dir
|
|
340
|
+
// (cache.key + cache.enc + receipts.enc) mirrors mcp/cache.js; the pairing/identity
|
|
341
|
+
// records sit next to the server.
|
|
342
|
+
const STATE_DIR = process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
343
|
+
|
|
344
|
+
// Delete all local Phaedo state the server wrote — the "leave nothing behind" half
|
|
345
|
+
// of uninstall. Client-config removal only unwires the server; without this the
|
|
346
|
+
// derived key material (cache.key), the ciphertext at-rest cache (cache.enc), the
|
|
347
|
+
// §10.6 receipts audit store (receipts.enc), and the pairing/identity records all
|
|
348
|
+
// persist on disk after "uninstall". Parameterized (stateDir/baseDir) so it's
|
|
349
|
+
// node-testable against temp dirs, not the real home.
|
|
350
|
+
export function purgeLocalData({ stateDir = STATE_DIR, baseDir = BASE_DIR, dryRun = false } = {}) {
|
|
351
|
+
const targets = [
|
|
352
|
+
stateDir, // cache.key + cache.enc + receipts.enc
|
|
353
|
+
join(baseDir, 'pairing.json'),
|
|
354
|
+
join(baseDir, 'identity.json'),
|
|
355
|
+
];
|
|
356
|
+
const removed = [];
|
|
357
|
+
for (const t of targets) {
|
|
358
|
+
if (!existsSync(t)) continue;
|
|
359
|
+
if (!dryRun) {
|
|
360
|
+
try { rmSync(t, { recursive: true, force: true }); }
|
|
361
|
+
catch (e) { removed.push(`${t} (error: ${e.message})`); continue; }
|
|
362
|
+
}
|
|
363
|
+
removed.push(t);
|
|
364
|
+
}
|
|
365
|
+
return removed;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Capture-model config (--capture-model) ────────────────────────────────────
|
|
369
|
+
// The durable on-switch for the SessionEnd capture hook's option-(b) model
|
|
370
|
+
// detector: <state dir>/capture-model.json, read by capture/model-detect.js
|
|
371
|
+
// captureModelOpts (env vars still win as a per-session override). A config
|
|
372
|
+
// FILE — not env in the MCP client entries — because the hook is spawned by
|
|
373
|
+
// Claude Code / Cowork, not by the MCP server, and Dock-launched apps never
|
|
374
|
+
// read shell-profile exports. `off` removes the file. Exported for tests.
|
|
375
|
+
export async function applyCaptureModelConfig(model, { url, stateDir = STATE_DIR, dryRun = false, fetchImpl } = {}) {
|
|
376
|
+
const path = join(stateDir, 'capture-model.json');
|
|
377
|
+
if (model === 'off') {
|
|
378
|
+
if (!existsSync(path)) return { status: 'unchanged', detail: 'capture model already off', path };
|
|
379
|
+
if (!dryRun) rmSync(path);
|
|
380
|
+
return { status: dryRun ? 'would-remove' : 'removed', detail: `capture model OFF (${path})`, path };
|
|
381
|
+
}
|
|
382
|
+
if (!model || typeof model !== 'string') {
|
|
383
|
+
return { status: 'error', detail: '--capture-model needs a model name (e.g. llama3.1:8b) or "off"', path };
|
|
384
|
+
}
|
|
385
|
+
if (!dryRun) {
|
|
386
|
+
mkdirSync(stateDir, { recursive: true });
|
|
387
|
+
writeFileSync(path, JSON.stringify({ model, ...(url ? { url } : {}) }, null, 2) + '\n', 'utf8');
|
|
388
|
+
}
|
|
389
|
+
// Advisory reachability/presence probe (same endpoint shape as the consult
|
|
390
|
+
// judge's) — config is written regardless; the hook fails safe to regex-only.
|
|
391
|
+
let note = '';
|
|
392
|
+
try {
|
|
393
|
+
const probeUrl = (url || 'http://localhost:11434').replace(/\/$/, '');
|
|
394
|
+
const res = await (fetchImpl || globalThis.fetch)(`${probeUrl}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
395
|
+
const names = res.ok ? ((await res.json()).models || []).map((m) => m.name || m.model).filter(Boolean) : [];
|
|
396
|
+
note = names.some((n) => n === model || n.startsWith(`${model}:`))
|
|
397
|
+
? ' (model found in Ollama)'
|
|
398
|
+
: ` (endpoint reachable but "${model}" not pulled — run: ollama pull ${model})`;
|
|
399
|
+
} catch {
|
|
400
|
+
note = ' (endpoint not reachable — hook stays regex-only until it is)';
|
|
401
|
+
}
|
|
402
|
+
return { status: dryRun ? 'would-write' : 'configured', detail: `capture model → ${model}${url ? ` @ ${url}` : ''}${note}`, path };
|
|
403
|
+
}
|
|
404
|
+
|
|
243
405
|
function parseArgs(argv) {
|
|
244
|
-
const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
|
|
406
|
+
const opts = { pair: false, uninstall: false, purge: false, dryRun: false, only: null, codeHook: false, help: false };
|
|
245
407
|
for (let i = 0; i < argv.length; i++) {
|
|
246
408
|
const a = argv[i];
|
|
247
409
|
if (a === '--pair') opts.pair = true;
|
|
248
410
|
else if (a === '--uninstall') opts.uninstall = true;
|
|
411
|
+
else if (a === '--purge') opts.purge = true;
|
|
249
412
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
250
413
|
else if (a === '--only') opts.only = argv[++i];
|
|
251
414
|
else if (a === '--code-hook') opts.codeHook = true;
|
|
252
415
|
else if (a === '--consult-model') opts.consultModel = argv[++i];
|
|
253
416
|
else if (a === '--consult-url') opts.consultUrl = argv[++i];
|
|
254
417
|
else if (a === '--no-model-judge') opts.noModelJudge = true;
|
|
418
|
+
else if (a === '--model-judge') opts.modelJudge = true;
|
|
419
|
+
else if (a === '--capture-model') opts.captureModel = argv[++i];
|
|
420
|
+
else if (a === '--capture-url') opts.captureUrl = argv[++i];
|
|
255
421
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
256
422
|
else throw new Error(`unknown option: ${a}`);
|
|
257
423
|
}
|
|
@@ -264,14 +430,23 @@ const HELP = `Phaedo MCP installer
|
|
|
264
430
|
node install.mjs --pair configure, then run the one-time pairing
|
|
265
431
|
node install.mjs --code-hook also add the Claude Code SessionStart hook
|
|
266
432
|
(true auto-injection; ~/.claude/settings.json)
|
|
433
|
+
node install.mjs --capture-model <name|off> turn the capture hook's local
|
|
434
|
+
model detector on (e.g. llama3.1:8b) or off.
|
|
435
|
+
Writes <state dir>/capture-model.json, which
|
|
436
|
+
reaches EVERY launch path (Dock apps never
|
|
437
|
+
read shell-profile exports). --capture-url
|
|
438
|
+
overrides the endpoint (default Ollama).
|
|
267
439
|
node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
|
|
440
|
+
node install.mjs --uninstall --purge ALSO delete all local Phaedo data on disk
|
|
441
|
+
(cache, key, receipts, pairing/identity records)
|
|
268
442
|
node install.mjs --dry-run preview changes, write nothing
|
|
269
443
|
node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
|
|
270
444
|
|
|
271
|
-
Consult model judge (
|
|
272
|
-
node install.mjs --
|
|
445
|
+
Consult model judge (OFF by default — the rule engine answers; opt in below):
|
|
446
|
+
node install.mjs --model-judge turn it on, auto-detecting a local Ollama chat model
|
|
447
|
+
node install.mjs --consult-model <id> turn it on with a pinned model (e.g. llama3.1:8b)
|
|
273
448
|
node install.mjs --consult-url <url> endpoint (default http://localhost:11434)
|
|
274
|
-
node install.mjs --no-model-judge rule engine only (
|
|
449
|
+
node install.mjs --no-model-judge rule engine only (the default; kept for scripts)
|
|
275
450
|
`;
|
|
276
451
|
|
|
277
452
|
function runPairing() {
|
|
@@ -299,12 +474,12 @@ async function main(argv) {
|
|
|
299
474
|
|
|
300
475
|
const action = opts.uninstall ? 'uninstall' : 'install';
|
|
301
476
|
|
|
302
|
-
// Consult model judge:
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
477
|
+
// Consult model judge: OFF unless asked for (see the measurement above buildConsultEnv).
|
|
478
|
+
// --consult-model pins one; --model-judge probes local Ollama for an installed chat model.
|
|
479
|
+
// Always falls back to the rule engine at runtime, so a missing model never breaks
|
|
480
|
+
// consultation. A plain install REMOVES a judge env an earlier default-on install wrote.
|
|
306
481
|
let env = {}, consultNote = '';
|
|
307
|
-
if (action === 'install' && !opts.noModelJudge) {
|
|
482
|
+
if (action === 'install' && !opts.noModelJudge && (opts.consultModel || opts.modelJudge)) {
|
|
308
483
|
const url = opts.consultUrl || DEFAULT_CONSULT_URL;
|
|
309
484
|
if (opts.consultModel) {
|
|
310
485
|
env = buildConsultEnv({ model: opts.consultModel, url });
|
|
@@ -317,7 +492,7 @@ async function main(argv) {
|
|
|
317
492
|
: `model judge ON → ${d.model} @ ${url} (Ollama not detected — falls back to the rule engine until it's running)`;
|
|
318
493
|
}
|
|
319
494
|
} else if (action === 'install') {
|
|
320
|
-
consultNote = 'model judge OFF
|
|
495
|
+
consultNote = 'model judge OFF — the rule engine answers (opt in: --model-judge)';
|
|
321
496
|
}
|
|
322
497
|
const entry = serverEntry({ env });
|
|
323
498
|
console.log(`Phaedo MCP — ${action}${opts.dryRun ? ' (dry run)' : ''}`);
|
|
@@ -332,6 +507,11 @@ async function main(argv) {
|
|
|
332
507
|
if (opts.codeHook || action === 'uninstall') {
|
|
333
508
|
results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
|
|
334
509
|
}
|
|
510
|
+
// Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
|
|
511
|
+
if (action === 'install' && opts.captureModel !== undefined) {
|
|
512
|
+
const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });
|
|
513
|
+
results.push({ name: 'capture-model', ...r });
|
|
514
|
+
}
|
|
335
515
|
const touched = [];
|
|
336
516
|
for (const r of results) {
|
|
337
517
|
const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
|
|
@@ -340,6 +520,16 @@ async function main(argv) {
|
|
|
340
520
|
if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
|
|
341
521
|
}
|
|
342
522
|
|
|
523
|
+
if (opts.purge) {
|
|
524
|
+
const removed = purgeLocalData({ dryRun: opts.dryRun });
|
|
525
|
+
console.log(`\n${opts.dryRun ? 'Would delete' : 'Deleted'} local Phaedo data:`);
|
|
526
|
+
if (removed.length) for (const p of removed) console.log(` ✓ ${p}`);
|
|
527
|
+
else console.log(' (nothing on disk to delete)');
|
|
528
|
+
} else if (action === 'uninstall') {
|
|
529
|
+
console.log(`\nLocal key material, cache, and receipts remain on disk (${STATE_DIR}, plus pairing.json/identity.json).`);
|
|
530
|
+
console.log('Run `node install.mjs --uninstall --purge` to delete those too.');
|
|
531
|
+
}
|
|
532
|
+
|
|
343
533
|
if (action === 'install') {
|
|
344
534
|
if (!touched.length) {
|
|
345
535
|
console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
|
|
@@ -347,7 +537,7 @@ async function main(argv) {
|
|
|
347
537
|
console.log('\nNext:');
|
|
348
538
|
console.log(' 1. Pair with your phone: npm run pair (or re-run with --pair)');
|
|
349
539
|
console.log(' 2. Restart any client that was already open:');
|
|
350
|
-
for (const r of touched) console.log(` • ${r.name}: ${r.restartHint}`);
|
|
540
|
+
for (const r of touched) if (r.restartHint) console.log(` • ${r.name}: ${r.restartHint}`);
|
|
351
541
|
console.log(' 3. Inject your fingerprint at session start:');
|
|
352
542
|
console.log(' • any client: run the `/phaedo` prompt (one click), or call');
|
|
353
543
|
console.log(' the `phaedo_request_injection` tool.');
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Where the capture stack lives — repo root (dev) or mcp/vendor/ (published).
|
|
2
|
+
//
|
|
3
|
+
// The SessionEnd hook and the mcp-local tier-3 fold import capture/* and a
|
|
4
|
+
// slice of extraction/* . In the dev clone those live at the repo root (the
|
|
5
|
+
// single source of truth, shared with the extension and parity-tested); in the
|
|
6
|
+
// published npm tarball build-package.mjs vendors them under mcp/vendor/.
|
|
7
|
+
//
|
|
8
|
+
// Unlike lib/phaedo-crypto.js (which prefers the vendored copies), this
|
|
9
|
+
// resolver PREFERS THE REPO ROOT when it exists: the hook runs headless on
|
|
10
|
+
// every session end, and a stale vendor/ left behind by an earlier `npm test`
|
|
11
|
+
// build on a dev clone would silently shadow freshly-pulled, parity-tested
|
|
12
|
+
// sources. A published install has no repo root two levels up, so it always
|
|
13
|
+
// lands on vendor/ there; the vendored copies are exercised by
|
|
14
|
+
// test-vendored-capture.mjs (a rootless layout), so they can't rot unnoticed.
|
|
15
|
+
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { dirname, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url)); // mcp/lib
|
|
21
|
+
const ROOT = resolve(HERE, '..', '..'); // repo root — dev
|
|
22
|
+
const VENDOR = resolve(HERE, '..', 'vendor'); // mcp/vendor — bundled (npm package)
|
|
23
|
+
|
|
24
|
+
// Absolute path for a capture-stack module, e.g. 'capture/state.js' or
|
|
25
|
+
// 'extraction/delta-lift.js' — or null when it exists in neither home.
|
|
26
|
+
export function captureModulePath(subpath) {
|
|
27
|
+
const dev = resolve(ROOT, subpath);
|
|
28
|
+
if (existsSync(dev)) return dev;
|
|
29
|
+
const vendored = resolve(VENDOR, subpath);
|
|
30
|
+
return existsSync(vendored) ? vendored : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// True when the whole capture stack is loadable from this install location
|
|
34
|
+
// (the install.mjs gate for wiring the SessionEnd hook).
|
|
35
|
+
export function captureStackAvailable() {
|
|
36
|
+
return !!(captureModulePath('capture/transcript.js') && captureModulePath('extraction/delta-lift.js'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Dynamic-import a capture-stack module from whichever home resolves. Throws
|
|
40
|
+
// ERR_MODULE_NOT_FOUND-equivalent when neither does (callers that must degrade
|
|
41
|
+
// gracefully — capture-local.js — catch it).
|
|
42
|
+
export function loadCaptureModule(subpath) {
|
|
43
|
+
const path = captureModulePath(subpath);
|
|
44
|
+
if (!path) throw new Error(`capture module not found in repo root or vendor/: ${subpath}`);
|
|
45
|
+
return import(pathToFileURL(path).href);
|
|
46
|
+
}
|
package/lib/domain.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// §10.2 `action_descriptor.domain` — normalization and the published vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// WHY ANY OF THIS EXISTS. `domain` is a bare `{"type":"string"}` the AGENT picks, and
|
|
4
|
+
// everything downstream keys on it verbatim: §10.5 rule matching, the §10.6 receipt,
|
|
5
|
+
// the delegation tally, §10.7's per-class approval counts. Observed live on the dev
|
|
6
|
+
// machine: `financial` ×16, `finance` ×26, `finance.crypto_trading` ×1 — one concept,
|
|
7
|
+
// three keys, and 7 approvals split 3/4 so neither class reached §10.7's
|
|
8
|
+
// `MIN_APPROVALS = 5`. Analysis: `docs/roadmap/domain-identity-2026-07-22.md`.
|
|
9
|
+
//
|
|
10
|
+
// TWO FUNCTIONS, DELIBERATELY DISTINCT. Conflating them is how an alias table starts
|
|
11
|
+
// granting authority nobody authored.
|
|
12
|
+
//
|
|
13
|
+
// normalizeDomain() lowercase + trim + collapse whitespace. Pure presentation;
|
|
14
|
+
// `"Finance"` and `" finance"` cannot mean different things, so
|
|
15
|
+
// merging them can lose no distinction anyone intended.
|
|
16
|
+
//
|
|
17
|
+
// canonicalDomain() normalizeDomain, then map a published ALIAS onto its canonical
|
|
18
|
+
// term (`financial` → `finance`). This is a SEMANTIC claim, and
|
|
19
|
+
// it is only safe where a wrong merge cannot expand authority.
|
|
20
|
+
//
|
|
21
|
+
// THE SAFETY PROPERTY, stated once and enforced at every call site: a vocabulary alias
|
|
22
|
+
// may widen a GUARDRAIL's reach, never a GRANT's. `canonicalDomain` is therefore used
|
|
23
|
+
// for evidence classification and for matching RESTRICTIVE rules; relaxing rules
|
|
24
|
+
// (`autoproceed`, `bias_bold`) keep matching on `normalizeDomain` alone, exactly as
|
|
25
|
+
// before. So adopting this vocabulary cannot hand any client more autonomy than it
|
|
26
|
+
// already had — it can only make an existing guardrail cover more. If the table below
|
|
27
|
+
// is wrong, the failure is "an agent asks when it needn't", never the reverse.
|
|
28
|
+
//
|
|
29
|
+
// ALIASES ARE SYNONYMS, NEVER GENERALIZATIONS. `financial` → `finance` is a synonym.
|
|
30
|
+
// `crypto_trading` → `finance` would be a child→parent generalization, and it is
|
|
31
|
+
// forbidden: a grant authored for the general class would silently cover a narrower,
|
|
32
|
+
// riskier one the subject never demonstrated. Same defect that made dotted-suffix
|
|
33
|
+
// stripping unsafe (v0.1.55), which is why `investing` is its own term here rather
|
|
34
|
+
// than a `finance` alias, and why `crypto_trading` is absent entirely — an
|
|
35
|
+
// unrecognized domain still works, it just does not merge.
|
|
36
|
+
//
|
|
37
|
+
// PUBLISHED, not private. The vocabulary below is a copy of `spec/domains.json` so the
|
|
38
|
+
// npm package (whose `files` whitelist excludes `spec/`) is self-contained;
|
|
39
|
+
// `mcp/test-domain.mjs` asserts the two cannot drift. A private synonym table would
|
|
40
|
+
// put decision semantics somewhere the subject cannot read and a third-party
|
|
41
|
+
// implementer cannot reproduce.
|
|
42
|
+
|
|
43
|
+
/** Canonical terms → their aliases. Mirror of spec/domains.json `domains`. */
|
|
44
|
+
const VOCABULARY = {
|
|
45
|
+
finance: ['financial', 'finances', 'money', 'billing', 'invoicing', 'expenses', 'accounting'],
|
|
46
|
+
investing: ['investment', 'investments', 'trading', 'brokerage', 'portfolio'],
|
|
47
|
+
legal: ['contracts', 'compliance', 'regulatory'],
|
|
48
|
+
health: ['medical', 'healthcare', 'wellness', 'clinical'],
|
|
49
|
+
scheduling: ['calendar', 'meetings', 'appointments', 'availability', 'booking'],
|
|
50
|
+
communication: ['email', 'outbound email', 'messaging', 'messages', 'comms', 'correspondence'],
|
|
51
|
+
writing: ['drafting', 'editing', 'content', 'copywriting', 'documentation'],
|
|
52
|
+
research: ['analysis', 'reading', 'summarization', 'search'],
|
|
53
|
+
shopping: ['purchasing', 'purchase', 'procurement', 'orders', 'commerce', 'ecommerce'],
|
|
54
|
+
travel: ['trips', 'flights', 'lodging', 'itinerary', 'transportation'],
|
|
55
|
+
engineering: ['code', 'coding', 'software', 'development', 'dev', 'devops', 'infrastructure', 'deployment'],
|
|
56
|
+
data: ['files', 'records', 'storage', 'database', 'export'],
|
|
57
|
+
security: ['access', 'permissions', 'credentials', 'authentication', 'authorization'],
|
|
58
|
+
people: ['hr', 'human resources', 'hiring', 'recruiting', 'personnel', 'staffing'],
|
|
59
|
+
social: ['social media', 'posting', 'publishing', 'community'],
|
|
60
|
+
support: ['customer support', 'customer service', 'helpdesk', 'tickets'],
|
|
61
|
+
admin: ['administration', 'settings', 'accounts', 'subscriptions', 'operations', 'ops'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** alias → canonical, plus every canonical term mapping to itself. */
|
|
65
|
+
const ALIAS_TO_CANONICAL = (() => {
|
|
66
|
+
const m = new Map();
|
|
67
|
+
for (const [term, aliases] of Object.entries(VOCABULARY)) {
|
|
68
|
+
m.set(term, term);
|
|
69
|
+
for (const a of aliases) m.set(a, term);
|
|
70
|
+
}
|
|
71
|
+
return m;
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
export const VOCABULARY_TERMS = Object.freeze(Object.keys(VOCABULARY).sort());
|
|
75
|
+
export const VOCABULARY_MAP = VOCABULARY;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Presentation-only normalization. Returns '' for anything unusable, which every
|
|
79
|
+
* caller already treats as "no domain".
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeDomain(raw) {
|
|
82
|
+
if (typeof raw !== 'string') return '';
|
|
83
|
+
return raw.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize, then resolve a published alias to its canonical term. An unrecognized
|
|
88
|
+
* domain is returned normalized but otherwise untouched — free text stays legal and
|
|
89
|
+
* behaves exactly as it does today. Never throws.
|
|
90
|
+
*/
|
|
91
|
+
export function canonicalDomain(raw) {
|
|
92
|
+
const n = normalizeDomain(raw);
|
|
93
|
+
if (!n) return '';
|
|
94
|
+
return ALIAS_TO_CANONICAL.get(n) || n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True iff this domain resolves to a published vocabulary term. */
|
|
98
|
+
export function isVocabularyTerm(raw) {
|
|
99
|
+
const n = normalizeDomain(raw);
|
|
100
|
+
return !!n && ALIAS_TO_CANONICAL.has(n);
|
|
101
|
+
}
|
package/lib/is-main.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// "Was this module executed directly?" — symlink-safe.
|
|
2
|
+
//
|
|
3
|
+
// The obvious ESM idiom
|
|
4
|
+
//
|
|
5
|
+
// import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
6
|
+
//
|
|
7
|
+
// is WRONG whenever any component of the path is a symlink. Node resolves
|
|
8
|
+
// `import.meta.url` through symlinks (it is the module's real URL), while
|
|
9
|
+
// `resolve(process.argv[1])` normalizes `.`/`..` but does NOT follow links — so
|
|
10
|
+
// the two disagree and the check silently returns false.
|
|
11
|
+
//
|
|
12
|
+
// That is not a hypothetical. `os.tmpdir()` on macOS is `/var/folders/…`, a
|
|
13
|
+
// symlink to `/private/var/folders/…`, which made the SessionEnd capture hook a
|
|
14
|
+
// silent no-op for every macOS spawn from a temp path (caught by
|
|
15
|
+
// test-vendored-capture.mjs, which spawns the hook exactly as Claude Code does).
|
|
16
|
+
// The same trap is live in production for any install reached through a link — a
|
|
17
|
+
// symlinked home directory, a linked global npm root, `/tmp` on macOS.
|
|
18
|
+
//
|
|
19
|
+
// A capture hook that quietly does nothing is the worst failure this codebase
|
|
20
|
+
// has: it exits 0, prints nothing, and loses the session. So compare REAL paths,
|
|
21
|
+
// and fall back to the plain URL comparison only if realpath throws (a deleted or
|
|
22
|
+
// unreadable entry point — in which case "not main" is the safe answer anyway).
|
|
23
|
+
|
|
24
|
+
import { realpathSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
// (import.meta.url of the caller, [argv]) -> boolean
|
|
29
|
+
export function isMainModule(moduleUrl, argv = process.argv) {
|
|
30
|
+
const entry = argv && argv[1];
|
|
31
|
+
if (!entry || !moduleUrl) return false;
|
|
32
|
+
const self = fileURLToPath(moduleUrl);
|
|
33
|
+
const target = resolve(entry);
|
|
34
|
+
if (self === target) return true; // fast path, no IO
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(self) === realpathSync(target);
|
|
37
|
+
} catch {
|
|
38
|
+
return pathToFileURL(target).href === moduleUrl; // best effort
|
|
39
|
+
}
|
|
40
|
+
}
|
package/lib/phaedo-crypto.js
CHANGED
|
@@ -21,7 +21,7 @@ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
|
|
|
21
21
|
|
|
22
22
|
// Prefer the bundled copies (self-contained package) over the repo root (dev).
|
|
23
23
|
// Load order matters: protocol → envelope → kdf → identity.
|
|
24
|
-
for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
|
|
24
|
+
for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js', 'capabilities.js', 'authored-source.js']) {
|
|
25
25
|
const vf = resolve(VENDOR, f);
|
|
26
26
|
require(existsSync(vf) ? vf : resolve(ROOT, f));
|
|
27
27
|
}
|
|
@@ -31,6 +31,22 @@ if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol)
|
|
|
31
31
|
throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The §5 step-3 upgrade gate's vocabulary. The agent READS authored content, so it has to be
|
|
35
|
+
// able to say what it can do — otherwise the gate stays shut on its silence forever, which is
|
|
36
|
+
// correct but useless. Loaded here rather than imported directly so the published package picks
|
|
37
|
+
// up the vendored copy exactly like the crypto modules do.
|
|
38
|
+
export const Capabilities = globalThis.PhaedoCapabilities;
|
|
39
|
+
if (!Capabilities?.advertise) {
|
|
40
|
+
throw new Error('Phaedo capabilities module did not load.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The step-3 read path's §5.4 rule. Used at the vd boundary so every authored-content reader
|
|
44
|
+
// downstream is §5-canonical without threading the configuration through its signature.
|
|
45
|
+
export const AuthoredSource = globalThis.PhaedoAuthoredSource;
|
|
46
|
+
if (!AuthoredSource?.configGoverns) {
|
|
47
|
+
throw new Error('Phaedo authored-source module did not load.');
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
// base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
|
|
35
51
|
export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
|
|
36
52
|
export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
|