phaedo-mcp 0.2.1 → 0.3.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/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- 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 +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- 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 +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- 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 +153 -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 +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
package/health.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Projection health report (P2) — `npm run health`, run during dogfooding.
|
|
3
|
+
//
|
|
4
|
+
// Loads the LIVE decrypted fingerprint (phone pairing / at-rest cache / local
|
|
5
|
+
// file, via the normal source resolution) and runs the §4.6/§4.7 + projection-
|
|
6
|
+
// hygiene checks over it, printing a MEASURED report: conformance + summary-
|
|
7
|
+
// hygiene errors (Proposal P0) and §4.7 unresolved dimensions (Proposal 0006).
|
|
8
|
+
// This is the "projection cleanliness" number for the end-of-window eval
|
|
9
|
+
// (docs/roadmap/dogfooding-eval-2026-06.md). Read-only — no network writes.
|
|
10
|
+
//
|
|
11
|
+
// Exit: 0 = clean · 1 = hygiene errors · 2 = could not load a fingerprint.
|
|
12
|
+
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { loadFingerprint } from './fingerprint-source.js';
|
|
16
|
+
import { validateFingerprint, resolveLayer } from '../spec/validate-fingerprint.mjs';
|
|
17
|
+
|
|
18
|
+
const baseDir = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
let vd;
|
|
21
|
+
try { vd = await loadFingerprint(baseDir); }
|
|
22
|
+
catch (e) { console.error(`Could not load a fingerprint: ${e.message}`); process.exit(2); }
|
|
23
|
+
|
|
24
|
+
const fp = vd && vd.phaedo_fingerprint;
|
|
25
|
+
if (!fp) { console.log('No fingerprint yet (empty vault) — nothing to check.'); process.exit(0); }
|
|
26
|
+
|
|
27
|
+
const layers = fp.layers || {};
|
|
28
|
+
const errs = validateFingerprint(fp); // structural + §4.6 + summary hygiene (P0)
|
|
29
|
+
|
|
30
|
+
// §4.7 (Proposal 0006): count dimensions and flag the ones a thin/conflicting
|
|
31
|
+
// evidence set leaves unresolved (which a consumer must treat conservatively).
|
|
32
|
+
let dimCount = 0;
|
|
33
|
+
const unresolved = [];
|
|
34
|
+
for (const [lid, L] of Object.entries(layers)) {
|
|
35
|
+
const res = resolveLayer(L.signals || []);
|
|
36
|
+
for (const [field, r] of Object.entries(res)) {
|
|
37
|
+
if (!r) continue;
|
|
38
|
+
dimCount++;
|
|
39
|
+
if (r.status === 'unresolved') unresolved.push(`${lid}.${field}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const sigCount = Object.values(layers).reduce((n, L) => n + ((L.signals || []).length), 0);
|
|
43
|
+
|
|
44
|
+
console.log(`\nPhaedo projection health — fingerprint ${fp.fingerprint_id || '(no id)'}`);
|
|
45
|
+
console.log(` layers ${Object.keys(layers).length} · signals ${sigCount} · resolved dimensions ${dimCount}`);
|
|
46
|
+
console.log(` conformance + projection-hygiene errors: ${errs.length}`);
|
|
47
|
+
for (const e of errs) console.log(` ✗ ${e}`);
|
|
48
|
+
console.log(` §4.7 unresolved dimensions (conflicting + thin evidence): ${unresolved.length}`);
|
|
49
|
+
for (const u of unresolved) console.log(` ⚠ ${u}`);
|
|
50
|
+
|
|
51
|
+
const clean = errs.length === 0;
|
|
52
|
+
console.log(`\n${clean ? '✓ projection clean' : `✗ ${errs.length} hygiene issue(s)`}` +
|
|
53
|
+
`${unresolved.length ? ` · ${unresolved.length} unresolved dimension(s)` : ''}\n`);
|
|
54
|
+
process.exit(clean ? 0 : 1);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Claude Code SessionEnd capture hook (Track A, option (a)).
|
|
3
|
+
//
|
|
4
|
+
// Claude Code runs this when a session ends, passing hook input on stdin
|
|
5
|
+
// ({ session_id, transcript_path, ... }). The hook:
|
|
6
|
+
// 1. reads the session transcript JSONL (local file, never leaves the machine),
|
|
7
|
+
// 2. parses it to an exchange_batch (capture/transcript.js),
|
|
8
|
+
// 3. runs the deterministic Path-A detectors over the user messages
|
|
9
|
+
// (capture/detectors.js — the parity-tested mirror of the extension's tables),
|
|
10
|
+
// optionally enriched by a LOCAL model classifying the regex-miss messages
|
|
11
|
+
// against the same taxonomy (capture/model-detect.js, option (b) — opt-in
|
|
12
|
+
// via PHAEDO_CAPTURE_MODEL, e.g. `llama3.1:8b` on Ollama; fail-safe),
|
|
13
|
+
// 4. folds this session's counts into the durable per-surface state, REPLACING
|
|
14
|
+
// any prior contribution from the same session (capture/state.js — resume-safe),
|
|
15
|
+
// 5. lifts the CUMULATIVE counters into source='delta' §5.1 signals
|
|
16
|
+
// (extraction/delta-lift.js — idempotent snapshot semantics), and
|
|
17
|
+
// 6. deposits the signal delta to the relay's capture_to_owner lane
|
|
18
|
+
// (mcp/capture-sync.js — ciphertext under fp_sync_key, best-effort).
|
|
19
|
+
//
|
|
20
|
+
// The raw transcript is read, counted, and DISCARDED — nothing derived from it
|
|
21
|
+
// but signal names + integers is ever persisted or transmitted (the discard-raw
|
|
22
|
+
// contract). Wire it up with `npm run setup:code-hook` (installs SessionStart
|
|
23
|
+
// injection + this SessionEnd capture together), or by hand in
|
|
24
|
+
// ~/.claude/settings.json:
|
|
25
|
+
// { "hooks": { "SessionEnd": [ { "hooks": [
|
|
26
|
+
// { "type": "command", "command": "<node> <abs path to this file>" }
|
|
27
|
+
// ] } ] } }
|
|
28
|
+
//
|
|
29
|
+
// FAIL-SAFE: any error (no transcript, unpaired, relay down, corrupt state)
|
|
30
|
+
// exits 0 with NO output — capture must never break or slow a session's end.
|
|
31
|
+
// Unpaired machines still accumulate local state; deposits begin once paired.
|
|
32
|
+
//
|
|
33
|
+
// Packaging note: the capture/extraction modules load via lib/capture-home.js —
|
|
34
|
+
// repo root in the dev clone, mcp/vendor/ in the published npm tarball
|
|
35
|
+
// (build-package.mjs vendors the whole closure; test-vendored-capture.mjs proves
|
|
36
|
+
// the rootless layout works).
|
|
37
|
+
|
|
38
|
+
import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
39
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
|
+
import { dirname, resolve } from 'node:path';
|
|
41
|
+
import { loadCaptureModule } from '../lib/capture-home.js';
|
|
42
|
+
import { isMainModule } from '../lib/is-main.js';
|
|
43
|
+
import { loadPairingRecord } from '../phone-source.js';
|
|
44
|
+
import { buildCaptureDelta, depositCaptureDelta } from '../capture-sync.js';
|
|
45
|
+
|
|
46
|
+
// The capture stack loads through lib/capture-home.js so this hook runs BOTH
|
|
47
|
+
// from the dev clone (repo-root sources) and from the published npm tarball
|
|
48
|
+
// (build-package.mjs vendors them under mcp/vendor/). Top-level await: the
|
|
49
|
+
// install gate (install.mjs captureHookAvailable) guarantees these resolve
|
|
50
|
+
// wherever the hook is actually wired.
|
|
51
|
+
const [
|
|
52
|
+
{ transcriptToBatch },
|
|
53
|
+
{ accumulateBatchCounts },
|
|
54
|
+
{ captureModelOpts, enrichCountsWithModel },
|
|
55
|
+
{ deriveSourceDeviceId },
|
|
56
|
+
{ defaultStatePath, loadState, saveState, applySession, cumulativeCounts },
|
|
57
|
+
{ liftDeltaSignals },
|
|
58
|
+
] = await Promise.all([
|
|
59
|
+
'capture/transcript.js',
|
|
60
|
+
'capture/detectors.js',
|
|
61
|
+
'capture/model-detect.js',
|
|
62
|
+
'capture/source-id.js',
|
|
63
|
+
'capture/state.js',
|
|
64
|
+
'extraction/delta-lift.js',
|
|
65
|
+
].map(loadCaptureModule));
|
|
66
|
+
|
|
67
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
68
|
+
const SURFACE = 'claude-code';
|
|
69
|
+
// Option (b): with a local capture model configured — env var OR the
|
|
70
|
+
// capture-model.json the installer writes — allow headroom for one
|
|
71
|
+
// classification call (its own 15s timeout) on top of the deterministic pass.
|
|
72
|
+
// Without one, the original tight budget stands.
|
|
73
|
+
const BUDGET_MS = captureModelOpts(process.env).model ? 30000 : 10000;
|
|
74
|
+
// Cap the transcript bytes we read+parse. The budget is a setTimeout race, which a
|
|
75
|
+
// setTimeout CANNOT preempt while the synchronous read/parse runs — so a huge transcript
|
|
76
|
+
// (long agentic sessions accumulate tool_result blobs) would block session-end past
|
|
77
|
+
// BUDGET_MS regardless. Recent behavior lives at the END of an append-only log, so on an
|
|
78
|
+
// oversized file we read only the last MAX_READ_BYTES and start parsing at the first
|
|
79
|
+
// whole line. 24 MB ≈ well under 100ms to parse — comfortably inside the budget.
|
|
80
|
+
const MAX_READ_BYTES = 24 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
// Read the whole file, or just its tail when it exceeds MAX_READ_BYTES (dropping the
|
|
83
|
+
// leading partial line). Returns '' on any IO error (caller treats as unreadable).
|
|
84
|
+
function readTranscriptCapped(path) {
|
|
85
|
+
let size;
|
|
86
|
+
try { size = statSync(path).size; } catch { return readFileSync(path, 'utf8'); }
|
|
87
|
+
if (size <= MAX_READ_BYTES) return readFileSync(path, 'utf8');
|
|
88
|
+
const fd = openSync(path, 'r');
|
|
89
|
+
try {
|
|
90
|
+
const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
|
|
91
|
+
readSync(fd, buf, 0, MAX_READ_BYTES, size - MAX_READ_BYTES);
|
|
92
|
+
const text = buf.toString('utf8');
|
|
93
|
+
const nl = text.indexOf('\n');
|
|
94
|
+
return nl >= 0 ? text.slice(nl + 1) : text; // drop the partial first line
|
|
95
|
+
} finally { closeSync(fd); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Capture pairing (whose key encrypts the deposit) ─────────────────────────
|
|
99
|
+
// The capture_to_owner envelope must be decryptable by the DRAINING owner. The
|
|
100
|
+
// PHONE is the tier-1 owner and its drain is BUILT (fpSync.drainCapture): it
|
|
101
|
+
// holds every client's pair_key and tries each until one decrypts, so the plain
|
|
102
|
+
// <mcp>/pairing.json — whatever pairing this machine rides — is the right
|
|
103
|
+
// default. The pairing.capture.json export (the extension's phaedo_local_pairing,
|
|
104
|
+
// copied by hand so the EXTENSION could decrypt) is RETIRED as a requirement:
|
|
105
|
+
// it remains a supported override for a phone app that predates the drain, or
|
|
106
|
+
// to pin the key explicitly. Resolution order:
|
|
107
|
+
// 1. PHAEDO_CAPTURE_PAIRING (explicit path)
|
|
108
|
+
// 2. <mcp>/pairing.capture.json — legacy/override (gitignored); delete it once
|
|
109
|
+
// the phone build with drainCapture is installed
|
|
110
|
+
// 3. <mcp>/pairing.json — the default
|
|
111
|
+
// Needs pair_key + relay_device_id (+ relay_device_secret for relay auth).
|
|
112
|
+
export class CapturePairingError extends Error {}
|
|
113
|
+
|
|
114
|
+
// Returns a valid capture pairing record, or null when NO capture pairing is
|
|
115
|
+
// configured (caller may fall back to mcp/pairing.json). THROWS CapturePairingError
|
|
116
|
+
// when a capture-pairing file is PRESENT but broken (unparseable, or missing
|
|
117
|
+
// pair_key/relay_device_id): silently falling back to a DIFFERENT pairing would
|
|
118
|
+
// encrypt every deposit under the wrong key to the wrong relay slot — the exact
|
|
119
|
+
// live-test failure the override exists to prevent, reinstated by a typo. The caller
|
|
120
|
+
// must skip the deposit instead. A record lacking relay_device_secret is returned but
|
|
121
|
+
// warned about (its deposits will 401 until the user re-pairs).
|
|
122
|
+
export function loadCapturePairing(mcpDir, env = process.env, warn = (m) => process.stderr.write(`[phaedo capture] ${m}\n`)) {
|
|
123
|
+
const candidates = [
|
|
124
|
+
{ path: env.PHAEDO_CAPTURE_PAIRING && resolve(process.cwd(), env.PHAEDO_CAPTURE_PAIRING), explicit: true },
|
|
125
|
+
{ path: resolve(mcpDir, 'pairing.capture.json'), explicit: false },
|
|
126
|
+
].filter((c) => c.path);
|
|
127
|
+
for (const { path, explicit } of candidates) {
|
|
128
|
+
if (!existsSync(path)) {
|
|
129
|
+
if (explicit) throw new CapturePairingError(`PHAEDO_CAPTURE_PAIRING points at a missing file: ${path} — not depositing (would use the wrong key).`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let rec;
|
|
133
|
+
try { rec = JSON.parse(readFileSync(path, 'utf8')); }
|
|
134
|
+
catch (e) { throw new CapturePairingError(`capture pairing ${path} is not valid JSON (${e.message}) — not depositing (would use the wrong key). Re-export the extension's pairing.`); }
|
|
135
|
+
if (!rec || !rec.pair_key || !rec.relay_device_id) {
|
|
136
|
+
throw new CapturePairingError(`capture pairing ${path} is missing pair_key/relay_device_id — not depositing (would use the wrong key). Re-export the extension's pairing.`);
|
|
137
|
+
}
|
|
138
|
+
if (!rec.relay_device_secret) warn(`capture pairing ${path} has no relay_device_secret — deposits will 401 until you re-pair the extension.`);
|
|
139
|
+
return rec;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The full capture pass, dependency-injectable for tests.
|
|
145
|
+
// input — the hook's stdin JSON ({ session_id, transcript_path, ... }).
|
|
146
|
+
// deps — { readFile, statePath, pairing, fetch, relayBase, now, mcpDir }.
|
|
147
|
+
// Returns a result record (never throws).
|
|
148
|
+
export async function runCaptureHook(input, deps = {}) {
|
|
149
|
+
const read = deps.readFile || readTranscriptCapped;
|
|
150
|
+
const statePath = deps.statePath || defaultStatePath();
|
|
151
|
+
const nowIso = deps.now || new Date().toISOString();
|
|
152
|
+
|
|
153
|
+
const transcriptPath = input && input.transcript_path;
|
|
154
|
+
if (!transcriptPath) return { deposited: false, reason: 'no-transcript-path' };
|
|
155
|
+
let text;
|
|
156
|
+
try { text = read(transcriptPath); } catch { return { deposited: false, reason: 'transcript-unreadable' }; }
|
|
157
|
+
|
|
158
|
+
const batch = transcriptToBatch(text, { sessionId: input.session_id });
|
|
159
|
+
if (!batch.exchanges.length) return { deposited: false, reason: 'no-exchanges' };
|
|
160
|
+
let counts = accumulateBatchCounts(batch);
|
|
161
|
+
|
|
162
|
+
// Option (b) — model-backed detector enrichment (capture/model-detect.js):
|
|
163
|
+
// classify the regex-MISS messages against the same correction taxonomy and
|
|
164
|
+
// fold the hits into this session's counts BEFORE the per-session-replace
|
|
165
|
+
// state apply, so re-fires stay resume-safe and the deposit stays pure delta.
|
|
166
|
+
// Opt-in (PHAEDO_CAPTURE_MODEL env, or the installer-written
|
|
167
|
+
// capture-model.json); any failure leaves the regex counts intact.
|
|
168
|
+
let modelAdded = 0;
|
|
169
|
+
const modelOpts = deps.captureModel !== undefined ? deps.captureModel : captureModelOpts(deps.env);
|
|
170
|
+
if (modelOpts && modelOpts.model) {
|
|
171
|
+
const enriched = await enrichCountsWithModel(batch, counts, modelOpts);
|
|
172
|
+
counts = enriched.counts;
|
|
173
|
+
modelAdded = enriched.added;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pairing is optional: without one we still accumulate state locally (the
|
|
177
|
+
// mcp-local owner tier can read it); the deposit just no-ops as 'unpaired'.
|
|
178
|
+
// A capture-specific pairing (the extension's, exported) wins over the MCP's
|
|
179
|
+
// own pairing.json so the draining owner can actually decrypt the deposit.
|
|
180
|
+
let pairing = deps.pairing;
|
|
181
|
+
if (pairing === undefined) {
|
|
182
|
+
try {
|
|
183
|
+
pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
// A capture-pairing file exists but is broken: do NOT fall back to a different
|
|
186
|
+
// key (that silently deposits to the wrong slot). Accumulate state, skip deposit.
|
|
187
|
+
if (!(e instanceof CapturePairingError)) throw e;
|
|
188
|
+
process.stderr.write(`[phaedo capture] ${e.message}\n`);
|
|
189
|
+
const sourceId0 = deriveSourceDeviceId(null, SURFACE);
|
|
190
|
+
const st = applySession(loadState(statePath), sourceId0, batch.session_local_id || input.session_id, counts, { now: nowIso });
|
|
191
|
+
try { saveState(statePath, st); } catch { /* non-fatal */ }
|
|
192
|
+
return { deposited: false, reason: 'capture-pairing-invalid', counted: counts.message_count };
|
|
193
|
+
}
|
|
194
|
+
if (!pairing) {
|
|
195
|
+
try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const sourceId = deriveSourceDeviceId(pairing, SURFACE);
|
|
199
|
+
|
|
200
|
+
const state = applySession(loadState(statePath), sourceId, batch.session_local_id || input.session_id, counts, { now: nowIso });
|
|
201
|
+
try { saveState(statePath, state); } catch { /* state loss just restarts counters — never fatal */ }
|
|
202
|
+
|
|
203
|
+
const signals = liftDeltaSignals(cumulativeCounts(state, sourceId).behavioral_signals, nowIso);
|
|
204
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal', counted: counts.message_count };
|
|
205
|
+
|
|
206
|
+
const delta = buildCaptureDelta(signals, { sourceSurface: SURFACE, sourceDeviceId: sourceId, capturedAt: nowIso });
|
|
207
|
+
// capture-sync's `now` dep is a FUNCTION (envelope created_at); ours is an ISO string.
|
|
208
|
+
const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
|
|
209
|
+
// Surface a deposit that FAILED for a fixable reason (a 401 = the pairing lost its
|
|
210
|
+
// relay_device_secret; re-pair). 'unpaired'/'no-relay-device-id' are expected
|
|
211
|
+
// local-only states, not worth a warning. Goes to stderr — Claude Code parses only
|
|
212
|
+
// stdout, so this never disturbs the session, but it appears in the hook's error log.
|
|
213
|
+
if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
|
|
214
|
+
process.stderr.write(`[phaedo capture] deposit rejected (${res.status || res.reason}) — re-pair the extension so capture can sync.\n`);
|
|
215
|
+
}
|
|
216
|
+
return { ...res, counted: counts.message_count, signals: signals.length, ...(modelAdded ? { model_added: modelAdded } : {}) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── script entry (Claude Code invokes this file directly) ────────────────────
|
|
220
|
+
// Symlink-safe (lib/is-main.js): the naive URL comparison silently returns false
|
|
221
|
+
// whenever the hook is reached through a link, turning this hook into a no-op that
|
|
222
|
+
// exits 0 and loses the session.
|
|
223
|
+
const isMain = isMainModule(import.meta.url);
|
|
224
|
+
if (isMain) {
|
|
225
|
+
const readStdin = () => new Promise((res) => {
|
|
226
|
+
let data = '';
|
|
227
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
228
|
+
process.stdin.on('end', () => res(data));
|
|
229
|
+
process.stdin.on('error', () => res(''));
|
|
230
|
+
});
|
|
231
|
+
const run = async () => {
|
|
232
|
+
let input = {};
|
|
233
|
+
try { input = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
|
|
234
|
+
await runCaptureHook(input);
|
|
235
|
+
};
|
|
236
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
237
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
238
|
+
.then(() => process.exit(0))
|
|
239
|
+
.catch(() => process.exit(0)); // a hook must never break the session
|
|
240
|
+
}
|
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,10 @@ 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
|
|
36
38
|
|
|
37
39
|
// ── Client registry ──────────────────────────────────────────────────────────
|
|
38
40
|
// Every supported client stores MCP servers as a top-level `mcpServers` object,
|
|
@@ -147,32 +149,50 @@ export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH
|
|
|
147
149
|
return `"${nodePath}" "${hookPath}"`;
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
// Match the marker path-separator-agnostically: the marker uses forward slashes
|
|
153
|
+
// ('hooks/session-end.mjs') but the command is built with path.join(), which on Windows
|
|
154
|
+
// yields backslashes ('...\\hooks\\session-end.mjs'). Without normalizing, Windows never
|
|
155
|
+
// recognizes its own hook entries — re-installs duplicate them and uninstall can't remove
|
|
156
|
+
// them. Normalize both sides to forward slashes before the substring test.
|
|
157
|
+
const norm = (s) => String(s).replace(/\\/g, '/');
|
|
158
|
+
const isPhaedoHookEntryFor = (marker) => (e) =>
|
|
151
159
|
e && Array.isArray(e.hooks) &&
|
|
152
|
-
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(
|
|
160
|
+
e.hooks.some((h) => typeof h.command === 'string' && norm(h.command).includes(norm(marker)));
|
|
153
161
|
|
|
154
|
-
|
|
162
|
+
// Generic per-event merge/remove — the SessionStart (injection) and SessionEnd
|
|
163
|
+
// (capture) entries use the same shape, distinguished by their script-path marker.
|
|
164
|
+
export function mergeHookEntry(settings, event, command, marker) {
|
|
165
|
+
const isOurs = isPhaedoHookEntryFor(marker);
|
|
155
166
|
const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
|
|
156
|
-
const prior = Array.isArray(next.hooks
|
|
157
|
-
const others = prior.filter((e) => !
|
|
158
|
-
const existing = prior.find(
|
|
167
|
+
const prior = Array.isArray(next.hooks[event]) ? next.hooks[event] : [];
|
|
168
|
+
const others = prior.filter((e) => !isOurs(e)); // drop a stale phaedo entry (path refresh)
|
|
169
|
+
const existing = prior.find(isOurs);
|
|
159
170
|
const already = !!existing && existing.hooks.some((h) => h.command === command);
|
|
160
|
-
next.hooks
|
|
171
|
+
next.hooks[event] = [...others, { hooks: [{ type: 'command', command }] }];
|
|
161
172
|
return { settings: next, changed: !already };
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
export function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
175
|
+
export function removeHookEntry(settings, event, marker) {
|
|
176
|
+
const isOurs = isPhaedoHookEntryFor(marker);
|
|
177
|
+
if (!settings.hooks || !Array.isArray(settings.hooks[event])) return { settings, changed: false };
|
|
178
|
+
const prior = settings.hooks[event];
|
|
179
|
+
const others = prior.filter((e) => !isOurs(e));
|
|
168
180
|
if (others.length === prior.length) return { settings, changed: false };
|
|
169
181
|
const hooks = { ...settings.hooks };
|
|
170
|
-
if (others.length) hooks
|
|
182
|
+
if (others.length) hooks[event] = others; else delete hooks[event];
|
|
171
183
|
const next = { ...settings, hooks };
|
|
172
184
|
if (Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
173
185
|
return { settings: next, changed: true };
|
|
174
186
|
}
|
|
175
187
|
|
|
188
|
+
// Back-compat wrappers (SessionStart injection hook).
|
|
189
|
+
export function mergeSessionStartHook(settings, command) {
|
|
190
|
+
return mergeHookEntry(settings, 'SessionStart', command, HOOK_MARKER);
|
|
191
|
+
}
|
|
192
|
+
export function removeSessionStartHook(settings) {
|
|
193
|
+
return removeHookEntry(settings, 'SessionStart', HOOK_MARKER);
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
// action: 'install' | 'uninstall'. Returns a result record. settings.json is a
|
|
177
197
|
// small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
|
|
178
198
|
export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
|
|
@@ -186,15 +206,46 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
186
206
|
}
|
|
187
207
|
if (action === 'uninstall') {
|
|
188
208
|
if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
if (!
|
|
209
|
+
const start = removeSessionStartHook(read.config);
|
|
210
|
+
const both = removeHookEntry(start.settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
211
|
+
if (!start.changed && !both.changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
|
|
212
|
+
if (!dryRun) { backup(path); writeConfig(path, both.settings); }
|
|
192
213
|
return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
|
|
193
214
|
}
|
|
194
|
-
|
|
195
|
-
|
|
215
|
+
// Always install SessionStart injection. Only install the SessionEnd CAPTURE hook
|
|
216
|
+
// when its dependencies actually resolve from THIS install location: the hook loads
|
|
217
|
+
// capture/* and extraction/delta-lift.js via lib/capture-home.js — repo root in a
|
|
218
|
+
// clone, mcp/vendor/ in the published tarball (build-package.mjs vendors the
|
|
219
|
+
// closure). captureHookAvailable() gates the wiring so a broken layout (e.g. a
|
|
220
|
+
// hand-copied hooks/ dir with neither home) degrades to injection-only instead of
|
|
221
|
+
// failing every session end with ERR_MODULE_NOT_FOUND.
|
|
222
|
+
const start = mergeSessionStartHook(read.config, hookCommand());
|
|
223
|
+
let settings = start.settings;
|
|
224
|
+
let captureChanged = false;
|
|
225
|
+
let captureNote = '';
|
|
226
|
+
if (captureHookAvailable()) {
|
|
227
|
+
const both = mergeHookEntry(settings, 'SessionEnd', hookCommand({ hookPath: CAPTURE_HOOK_PATH }), CAPTURE_HOOK_MARKER);
|
|
228
|
+
settings = both.settings; captureChanged = both.changed;
|
|
229
|
+
} else {
|
|
230
|
+
// Never leave a broken capture hook wired if we can't run it here.
|
|
231
|
+
const rm = removeHookEntry(settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
232
|
+
settings = rm.settings; captureChanged = rm.changed;
|
|
233
|
+
captureNote = ' (capture hook skipped — not available in this install; injection only)';
|
|
234
|
+
}
|
|
235
|
+
const changed = start.changed || captureChanged;
|
|
236
|
+
if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
|
|
196
237
|
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
|
197
|
-
return { name, status: dryRun ? 'would-write' : 'configured', detail: path };
|
|
238
|
+
return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The SessionEnd capture hook can only run where its imported modules resolve —
|
|
242
|
+
// the repo root (dev clone) or mcp/vendor/ (published tarball), mirroring
|
|
243
|
+
// lib/capture-home.js's resolution exactly.
|
|
244
|
+
export function captureHookAvailable(hookPath = CAPTURE_HOOK_PATH) {
|
|
245
|
+
const hookDir = dirname(hookPath);
|
|
246
|
+
const homes = [join(hookDir, '..', '..'), join(hookDir, '..', 'vendor')];
|
|
247
|
+
return homes.some((h) => existsSync(join(h, 'capture', 'transcript.js')) &&
|
|
248
|
+
existsSync(join(h, 'extraction', 'delta-lift.js')));
|
|
198
249
|
}
|
|
199
250
|
|
|
200
251
|
function backup(path) {
|
|
@@ -240,18 +291,87 @@ export function applyToClient(client, { action, entry, dryRun = false } = {}) {
|
|
|
240
291
|
return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
|
|
241
292
|
}
|
|
242
293
|
|
|
294
|
+
// Default on-disk locations for everything the server persists. The state dir
|
|
295
|
+
// (cache.key + cache.enc + receipts.enc) mirrors mcp/cache.js; the pairing/identity
|
|
296
|
+
// records sit next to the server.
|
|
297
|
+
const STATE_DIR = process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
298
|
+
|
|
299
|
+
// Delete all local Phaedo state the server wrote — the "leave nothing behind" half
|
|
300
|
+
// of uninstall. Client-config removal only unwires the server; without this the
|
|
301
|
+
// derived key material (cache.key), the ciphertext at-rest cache (cache.enc), the
|
|
302
|
+
// §10.6 receipts audit store (receipts.enc), and the pairing/identity records all
|
|
303
|
+
// persist on disk after "uninstall". Parameterized (stateDir/baseDir) so it's
|
|
304
|
+
// node-testable against temp dirs, not the real home.
|
|
305
|
+
export function purgeLocalData({ stateDir = STATE_DIR, baseDir = BASE_DIR, dryRun = false } = {}) {
|
|
306
|
+
const targets = [
|
|
307
|
+
stateDir, // cache.key + cache.enc + receipts.enc
|
|
308
|
+
join(baseDir, 'pairing.json'),
|
|
309
|
+
join(baseDir, 'identity.json'),
|
|
310
|
+
];
|
|
311
|
+
const removed = [];
|
|
312
|
+
for (const t of targets) {
|
|
313
|
+
if (!existsSync(t)) continue;
|
|
314
|
+
if (!dryRun) {
|
|
315
|
+
try { rmSync(t, { recursive: true, force: true }); }
|
|
316
|
+
catch (e) { removed.push(`${t} (error: ${e.message})`); continue; }
|
|
317
|
+
}
|
|
318
|
+
removed.push(t);
|
|
319
|
+
}
|
|
320
|
+
return removed;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Capture-model config (--capture-model) ────────────────────────────────────
|
|
324
|
+
// The durable on-switch for the SessionEnd capture hook's option-(b) model
|
|
325
|
+
// detector: <state dir>/capture-model.json, read by capture/model-detect.js
|
|
326
|
+
// captureModelOpts (env vars still win as a per-session override). A config
|
|
327
|
+
// FILE — not env in the MCP client entries — because the hook is spawned by
|
|
328
|
+
// Claude Code / Cowork, not by the MCP server, and Dock-launched apps never
|
|
329
|
+
// read shell-profile exports. `off` removes the file. Exported for tests.
|
|
330
|
+
export async function applyCaptureModelConfig(model, { url, stateDir = STATE_DIR, dryRun = false, fetchImpl } = {}) {
|
|
331
|
+
const path = join(stateDir, 'capture-model.json');
|
|
332
|
+
if (model === 'off') {
|
|
333
|
+
if (!existsSync(path)) return { status: 'unchanged', detail: 'capture model already off', path };
|
|
334
|
+
if (!dryRun) rmSync(path);
|
|
335
|
+
return { status: dryRun ? 'would-remove' : 'removed', detail: `capture model OFF (${path})`, path };
|
|
336
|
+
}
|
|
337
|
+
if (!model || typeof model !== 'string') {
|
|
338
|
+
return { status: 'error', detail: '--capture-model needs a model name (e.g. llama3.1:8b) or "off"', path };
|
|
339
|
+
}
|
|
340
|
+
if (!dryRun) {
|
|
341
|
+
mkdirSync(stateDir, { recursive: true });
|
|
342
|
+
writeFileSync(path, JSON.stringify({ model, ...(url ? { url } : {}) }, null, 2) + '\n', 'utf8');
|
|
343
|
+
}
|
|
344
|
+
// Advisory reachability/presence probe (same endpoint shape as the consult
|
|
345
|
+
// judge's) — config is written regardless; the hook fails safe to regex-only.
|
|
346
|
+
let note = '';
|
|
347
|
+
try {
|
|
348
|
+
const probeUrl = (url || 'http://localhost:11434').replace(/\/$/, '');
|
|
349
|
+
const res = await (fetchImpl || globalThis.fetch)(`${probeUrl}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
350
|
+
const names = res.ok ? ((await res.json()).models || []).map((m) => m.name || m.model).filter(Boolean) : [];
|
|
351
|
+
note = names.some((n) => n === model || n.startsWith(`${model}:`))
|
|
352
|
+
? ' (model found in Ollama)'
|
|
353
|
+
: ` (endpoint reachable but "${model}" not pulled — run: ollama pull ${model})`;
|
|
354
|
+
} catch {
|
|
355
|
+
note = ' (endpoint not reachable — hook stays regex-only until it is)';
|
|
356
|
+
}
|
|
357
|
+
return { status: dryRun ? 'would-write' : 'configured', detail: `capture model → ${model}${url ? ` @ ${url}` : ''}${note}`, path };
|
|
358
|
+
}
|
|
359
|
+
|
|
243
360
|
function parseArgs(argv) {
|
|
244
|
-
const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
|
|
361
|
+
const opts = { pair: false, uninstall: false, purge: false, dryRun: false, only: null, codeHook: false, help: false };
|
|
245
362
|
for (let i = 0; i < argv.length; i++) {
|
|
246
363
|
const a = argv[i];
|
|
247
364
|
if (a === '--pair') opts.pair = true;
|
|
248
365
|
else if (a === '--uninstall') opts.uninstall = true;
|
|
366
|
+
else if (a === '--purge') opts.purge = true;
|
|
249
367
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
250
368
|
else if (a === '--only') opts.only = argv[++i];
|
|
251
369
|
else if (a === '--code-hook') opts.codeHook = true;
|
|
252
370
|
else if (a === '--consult-model') opts.consultModel = argv[++i];
|
|
253
371
|
else if (a === '--consult-url') opts.consultUrl = argv[++i];
|
|
254
372
|
else if (a === '--no-model-judge') opts.noModelJudge = true;
|
|
373
|
+
else if (a === '--capture-model') opts.captureModel = argv[++i];
|
|
374
|
+
else if (a === '--capture-url') opts.captureUrl = argv[++i];
|
|
255
375
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
256
376
|
else throw new Error(`unknown option: ${a}`);
|
|
257
377
|
}
|
|
@@ -264,7 +384,15 @@ const HELP = `Phaedo MCP installer
|
|
|
264
384
|
node install.mjs --pair configure, then run the one-time pairing
|
|
265
385
|
node install.mjs --code-hook also add the Claude Code SessionStart hook
|
|
266
386
|
(true auto-injection; ~/.claude/settings.json)
|
|
387
|
+
node install.mjs --capture-model <name|off> turn the capture hook's local
|
|
388
|
+
model detector on (e.g. llama3.1:8b) or off.
|
|
389
|
+
Writes <state dir>/capture-model.json, which
|
|
390
|
+
reaches EVERY launch path (Dock apps never
|
|
391
|
+
read shell-profile exports). --capture-url
|
|
392
|
+
overrides the endpoint (default Ollama).
|
|
267
393
|
node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
|
|
394
|
+
node install.mjs --uninstall --purge ALSO delete all local Phaedo data on disk
|
|
395
|
+
(cache, key, receipts, pairing/identity records)
|
|
268
396
|
node install.mjs --dry-run preview changes, write nothing
|
|
269
397
|
node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
|
|
270
398
|
|
|
@@ -332,6 +460,11 @@ async function main(argv) {
|
|
|
332
460
|
if (opts.codeHook || action === 'uninstall') {
|
|
333
461
|
results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
|
|
334
462
|
}
|
|
463
|
+
// Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
|
|
464
|
+
if (action === 'install' && opts.captureModel !== undefined) {
|
|
465
|
+
const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });
|
|
466
|
+
results.push({ name: 'capture-model', ...r });
|
|
467
|
+
}
|
|
335
468
|
const touched = [];
|
|
336
469
|
for (const r of results) {
|
|
337
470
|
const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
|
|
@@ -340,6 +473,16 @@ async function main(argv) {
|
|
|
340
473
|
if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
|
|
341
474
|
}
|
|
342
475
|
|
|
476
|
+
if (opts.purge) {
|
|
477
|
+
const removed = purgeLocalData({ dryRun: opts.dryRun });
|
|
478
|
+
console.log(`\n${opts.dryRun ? 'Would delete' : 'Deleted'} local Phaedo data:`);
|
|
479
|
+
if (removed.length) for (const p of removed) console.log(` ✓ ${p}`);
|
|
480
|
+
else console.log(' (nothing on disk to delete)');
|
|
481
|
+
} else if (action === 'uninstall') {
|
|
482
|
+
console.log(`\nLocal key material, cache, and receipts remain on disk (${STATE_DIR}, plus pairing.json/identity.json).`);
|
|
483
|
+
console.log('Run `node install.mjs --uninstall --purge` to delete those too.');
|
|
484
|
+
}
|
|
485
|
+
|
|
343
486
|
if (action === 'install') {
|
|
344
487
|
if (!touched.length) {
|
|
345
488
|
console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
|
|
@@ -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
|
+
}
|