+ `;
+}
+
+function buildEffectsSection(allEffects) {
+ if (allEffects.length === 0) return '';
+
+ const fails = allEffects.filter(e => e.type === 'fail');
+ const warns = allEffects.filter(e => e.type === 'warn');
+ const passes = allEffects.filter(e => e.type === 'pass');
+
return html`
`;
}
@@ -79,12 +122,20 @@ function resolvePrescriptionAction(rx) {
const text = String(rx?.text ?? rx).toLowerCase();
if (text.includes('rebroadcast') && text.includes('profile')) return 'rebroadcast';
if (text.includes('delete events') && text.includes('keypackage')) return 'delete-kps';
+ if (text.includes('delete orphaned keypackages')) return 'delete-orphaned-kps';
if (text.includes('unify your k3 and k10002')) return 'unify-relays';
+ if (text.includes('reduce kind 10002')) return 'reduce-relays';
+ if (text.includes('migrate from nip-04') || (text.includes('nip-04') && text.includes('kind 4'))) return 'delete-kind4';
return null;
}
-function buildTreatmentSection(prescriptions, canRebroadcast, canDeleteKps, canUnifyRelays) {
- if (prescriptions.length === 0) return '';
+function buildTreatmentSection(prescriptions, actionFlags, canOperate, auditState) {
+ if (prescriptions.length === 0 && !canOperate) return '';
+
+ const {
+ canRebroadcast, canDeleteKps, canUnifyRelays,
+ canDeleteOrphanedKps, canDeleteKind4,
+ } = actionFlags;
const requiresNip07Attrs = !window.nostr ? ' disabled title="Requires NIP-07 extension"' : '';
const treatmentRows = prescriptions.map((rx) => {
@@ -93,33 +144,70 @@ function buildTreatmentSection(prescriptions, canRebroadcast, canDeleteKps, canU
let actionButton = '';
if (action === 'rebroadcast' && canRebroadcast) {
- actionButton = html`
`;
} else if (action === 'delete-kps' && canDeleteKps) {
actionButton = html`
-
+ `;
+ } else if (action === 'delete-orphaned-kps' && canDeleteOrphanedKps) {
+ actionButton = html`
+
`;
} else if (action === 'unify-relays' && canUnifyRelays) {
actionButton = html`
-
`;
+ } else if (action === 'reduce-relays' && canOperate) {
+ actionButton = html`
+
+ `;
+ } else if (action === 'delete-kind4' && canDeleteKind4) {
+ actionButton = html`
+
+ `;
+ }
+
+ // Generate nak command for this action
+ let nakBlock = '';
+ if (action && action !== 'reduce-relays') {
+ const nakCmd = getNakCommand(action, auditState);
+ if (nakCmd) {
+ nakBlock = html`
+
+ `;
+ }
}
return html`
-
-
${rxText}
- ${actionButton}
+
+
+ ${escapeHtml(rxText)}
+ ${actionButton}
+
+ ${nakBlock}
`;
});
+ const operateBtn = canOperate ? html`
+
+
+ Manage all relay lists in one place. Add, remove, and edit relays across NIP-65, Inbox, KeyPackage, and Contacts.
+
+ ` : '';
+
return html`
TREATMENT PLAN
-
${treatmentRows}
+
+ ${treatmentRows}
+ ${operateBtn}
+
`;
}
@@ -131,11 +219,13 @@ function buildChartSignature(doctorName) {
/**
* @param {Object} params
* @param {Function} getDisplayName
+ * @param {Function} [speakFn] - speak() from personalities.js for doctor notes
* @returns {{ cardHTML: string }}
*/
-export function renderChart(params, getDisplayName) {
+export function renderChart(params, getDisplayName, speakFn) {
const {
findings,
+ findingsWithHints,
prescriptions,
verdictClass,
verdictIcon,
@@ -145,8 +235,12 @@ export function renderChart(params, getDisplayName) {
totalRelays,
canRebroadcast,
canDeleteKps,
+ canDeleteOrphanedKps,
+ canDeleteKind4,
canUnifyRelays,
+ canOperate,
rawNpub,
+ doctorNotes,
} = params;
const hpTotal = findings.pass.length + findings.warn.length + findings.fail.length;
@@ -156,11 +250,25 @@ export function renderChart(params, getDisplayName) {
const chartDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
const shortNpub = rawNpub.slice(0, NPUB_SHORT_LEN) + 'β¦';
- const allEffects = [
- ...findings.fail.map(t => ({ type: 'fail', text: t })),
- ...findings.warn.map(t => ({ type: 'warn', text: t })),
- ...findings.pass.map(t => ({ type: 'pass', text: t })),
- ];
+ // Use findingsWithHints if available, otherwise fallback to raw findings
+ const hinted = findingsWithHints || null;
+ const allEffects = hinted
+ ? [
+ ...hinted.fail.map(f => ({ type: 'fail', text: f.text, hint: f.hint })),
+ ...hinted.warn.map(f => ({ type: 'warn', text: f.text, hint: f.hint })),
+ ...hinted.pass.map(f => ({ type: 'pass', text: f.text, hint: f.hint })),
+ ]
+ : [
+ ...findings.fail.map(t => ({ type: 'fail', text: t })),
+ ...findings.warn.map(t => ({ type: 'warn', text: t })),
+ ...findings.pass.map(t => ({ type: 'pass', text: t })),
+ ];
+
+ // Generate doctor's note text via personality system
+ let doctorNoteText = null;
+ if (doctorNotes && speakFn) {
+ doctorNoteText = speakFn(doctorNotes.noteKey, doctorNotes.noteVars);
+ }
const cardHTML = html`
@@ -175,12 +283,13 @@ export function renderChart(params, getDisplayName) {
hpClass,
totalRelays,
)}
+ ${buildDoctorNotesSection(doctorNoteText)}
${buildEffectsSection(allEffects)}
${buildTreatmentSection(
prescriptions,
- canRebroadcast,
- canDeleteKps,
- canUnifyRelays,
+ { canRebroadcast, canDeleteKps, canUnifyRelays, canDeleteOrphanedKps, canDeleteKind4 },
+ canOperate,
+ params.auditState,
)}
${buildChartSignature(doctorName)}
diff --git a/js/audit/findingsPrescriptions.js b/js/audit/findingsPrescriptions.js
index 66d53aa..bd5f035 100644
--- a/js/audit/findingsPrescriptions.js
+++ b/js/audit/findingsPrescriptions.js
@@ -1,7 +1,140 @@
/**
- * Findings compilation, prescriptions, verdict, and closing-message helpers.
+ * Findings compilation, prescriptions, verdict, closing-message helpers,
+ * and doctor recommendation generation.
*/
+/**
+ * Per-finding recommendation hints.
+ * Maps a finding text pattern (lowercase) to a one-liner explaining
+ * why it matters and what to do.
+ * @type {Array<{pattern: string, hint: string}>}
+ */
+const FINDING_HINTS = [
+ { pattern: 'invalid relay url', hint: 'Broken URLs prevent relay discovery β remove them and use valid wss:// addresses.' },
+ { pattern: 'invalid keypackage relay', hint: 'KeyPackage relay URLs must be valid wss:// β fix or remove them from k10051.' },
+ { pattern: 'profile (kind 0) not found', hint: 'No profile means clients can\'t display your name, picture, or NIP-05. Publish a kind 0.' },
+ { pattern: 'profile (kind 0) in sync', hint: 'All relays have your latest profile β no action needed.' },
+ { pattern: 'profile (kind 0) outdated', hint: 'Some relays have an old version β rebroadcast to push the latest.' },
+ { pattern: 'profile (kind 0) missing', hint: 'Some relays don\'t have your profile at all β rebroadcast to fix.' },
+ { pattern: 'contacts list (kind 3) not found', hint: 'No contacts means clients can\'t build your social graph. Publish a kind 3.' },
+ { pattern: 'contacts (kind 3) in sync', hint: 'All relays have your latest contacts β consistent.' },
+ { pattern: 'contacts (kind 3) outdated', hint: 'Some relays have stale contacts β rebroadcast to sync.' },
+ { pattern: 'contacts (kind 3) missing', hint: 'Some relays are missing your contacts β rebroadcast.' },
+ { pattern: 'no keypackage relay list (kind 10051)', hint: 'Without k10051, nobody can find your KeyPackages. Marmot messaging requires this.' },
+ { pattern: 'keypackage relay list (kind 10051) published', hint: 'KeyPackage advertisement is live β clients can discover your KPs.' },
+ { pattern: 'keypackage relay(s) advertised', hint: 'Relay addresses are present in your k10051. Others can find your KPs there.' },
+ { pattern: 'kind 10051 contains no relay tags', hint: 'Your k10051 exists but has no relays. Add at least one relay URL.' },
+ { pattern: 'no inbox relay list (kind 10050)', hint: 'Without k10050, giftwrapped messages and Marmot invites can\'t reach you.' },
+ { pattern: 'inbox relay list (kind 10050) published', hint: 'Inbox relay advertisement is live β giftwraps can find you.' },
+ { pattern: 'inbox relay(s) advertised', hint: 'Relay addresses present in k10050 for giftwrap delivery.' },
+ { pattern: 'kind 10050 contains no valid relay tags', hint: 'Your k10050 exists but has no valid relays. Add at least one.' },
+ { pattern: 'invalid inbox relay', hint: 'Inbox relay URL is malformed β fix the URL in your k10050.' },
+ { pattern: 'keypackage(s) found on', hint: 'Orphaned KPs are invisible to other users. Add those relays to k10051 or delete the KPs.' },
+ { pattern: 'whitenoise login gate', hint: 'WhiteNoise needs k10002, k10050, and k10051 to log in. Missing any blocks access.' },
+ { pattern: 'nip-65: no read relays', hint: 'Clients need at least one read relay to deliver replies to you.' },
+ { pattern: 'nip-65: no write relays', hint: 'Clients need at least one write relay to know where you publish.' },
+ { pattern: 'nip-65: excessive relay count', hint: 'Too many relays slow down clients. Trim to 5-8 reliable ones.' },
+ { pattern: 'kind 10002 (nip-65) has malformed', hint: 'Tags array is broken. Republish k10002 with proper r-tagged relay URLs.' },
+ { pattern: 'relay lists diverge', hint: 'k3 and k10002 show different relays. Unify them so all clients see the same list.' },
+ { pattern: 'no nip-17 dm inbox', hint: 'Without k10050, NIP-17 encrypted DMs cannot be delivered to you.' },
+ { pattern: 'no blossom media server', hint: 'Clients won\'t know where to upload media for you. Publish a k10063.' },
+ { pattern: 'deprecated nip-04', hint: 'NIP-04 DMs leak metadata. Migrate to NIP-17 or Marmot for privacy.' },
+ { pattern: 'deprecated kind 2', hint: 'Kind 2 is obsolete. Use NIP-65 (k10002) for relay recommendations.' },
+ { pattern: 'all keypackages pass', hint: 'MIP-00/01 fully compliant β your Marmot setup is solid.' },
+ { pattern: 'all tags valid', hint: 'KeyPackage tags pass validation β encoding, ciphersuite, and extensions are correct.' },
+];
+
+/**
+ * Get the recommendation hint for a finding text.
+ * @param {string} findingText
+ * @returns {string|null}
+ */
+export function getHintForFinding(findingText) {
+ const lower = (findingText || '').toLowerCase();
+ for (const { pattern, hint } of FINDING_HINTS) {
+ if (lower.includes(pattern)) return hint;
+ }
+ return null;
+}
+
+/**
+ * Generate a structured Doctor's Notes summary based on findings and verdict.
+ * Returns a key for speak() that the personality system uses to render the note.
+ *
+ * @param {Object} params
+ * @param {Object} params.findings - { pass: [], warn: [], fail: [] }
+ * @param {boolean} params.allOk
+ * @param {boolean} params.hasFailures
+ * @param {boolean} params.hasWarnings
+ * @param {string[]} params.categories - from categorizeFailures()
+ * @param {Object} params.auditCtx
+ * @returns {{ noteKey: string, noteVars: Object }}
+ */
+export function generateDoctorNotes(params) {
+ const { findings, allOk, hasFailures, hasWarnings, categories, auditCtx } = params;
+
+ if (allOk) {
+ return {
+ noteKey: 'doctorNoteClean',
+ noteVars: {
+ passCount: findings.pass.length,
+ relayCount: auditCtx.totalRelays || 0,
+ },
+ };
+ }
+
+ if (!hasFailures && hasWarnings) {
+ const warnCount = findings.warn.length;
+ const isRelayDivergence = categories.includes('sync') || auditCtx.relayDiverges;
+ const isBloated = auditCtx.nip65Bloated;
+ return {
+ noteKey: 'doctorNoteMinor',
+ noteVars: {
+ warnCount,
+ isRelayDivergence,
+ isBloated,
+ passCount: findings.pass.length,
+ },
+ };
+ }
+
+ // Has failures β determine the main category
+ const failCount = findings.fail.length;
+ const warnCount = findings.warn.length;
+
+ const hasRelayConfig = categories.includes('relay-config');
+ const hasSync = categories.includes('sync');
+ const hasMarmot = categories.includes('marmot-foundation');
+ const hasKp = categories.includes('keypackage');
+
+ let noteKey = 'doctorNoteCritical';
+ if (hasRelayConfig && !hasSync && !hasMarmot && !hasKp) {
+ noteKey = 'doctorNoteRelayConfig';
+ } else if (hasSync && !hasRelayConfig && !hasMarmot && !hasKp) {
+ noteKey = 'doctorNoteSync';
+ } else if (hasMarmot && !hasRelayConfig && !hasSync && !hasKp) {
+ noteKey = 'doctorNoteMarmot';
+ } else if (hasKp && !hasRelayConfig && !hasSync && !hasMarmot) {
+ noteKey = 'doctorNoteKeyPackage';
+ } else if (categories.length >= 2) {
+ noteKey = 'doctorNoteMultiple';
+ }
+
+ return {
+ noteKey,
+ noteVars: {
+ failCount,
+ warnCount,
+ passCount: findings.pass.length,
+ hasRelayConfig,
+ hasSync,
+ hasMarmot,
+ hasKp,
+ categoryCount: categories.length,
+ },
+ };
+}
+
const FAILURE_CATEGORIES = {
'relay-config': ['Invalid relay', 'invalid relay', 'kind 10051 relay', 'relay URLs'],
'sync': ['sync', 'outdated', 'missing', 'stale', 'Profile (kind 0)', 'Contacts (kind 3)', 'not found'],
@@ -476,9 +609,11 @@ export function generatePrescriptions(
const uniqueRx = [...new Set(prescriptions)].sort(
(a, b) => prescriptionPriority(a) - prescriptionPriority(b),
);
- const canRebroadcast = (staleRelays > 0 || missingRelays > 0);
- const canDeleteKps = missingITagIds.length > 0;
- return { prescriptions: uniqueRx, canRebroadcast, canDeleteKps };
+ const canRebroadcast = Boolean(staleRelays > 0 || missingRelays > 0);
+ const canDeleteKps = Boolean(missingITagIds.length > 0);
+ const canDeleteOrphanedKps = Boolean(orphanedKpRelays && orphanedKpRelays.length > 0);
+ const canDeleteKind4 = Boolean(auditCtx.hasDeprecatedK4);
+ return { prescriptions: uniqueRx, canRebroadcast, canDeleteKps, canDeleteOrphanedKps, canDeleteKind4 };
}
/**
@@ -566,9 +701,36 @@ export function compileFindingsAndPrescriptions(params) {
orphanedKpRelays,
best10050,
);
- const { prescriptions: uniqueRx, canDeleteKps } = rxResult;
+ const { prescriptions: uniqueRx, canDeleteKps, canDeleteOrphanedKps, canDeleteKind4 } = rxResult;
const canRebroadcast = Boolean(rxResult.canRebroadcast) && !!(bestK0 || bestK3);
+ // Generate doctor's notes and per-finding hints
+ const categories = categorizeFailures(findingsResult.findings.fail.map(f => f));
+ const doctorNotes = generateDoctorNotes({
+ findings: findingsResult.findings,
+ allOk: findingsResult.allOk,
+ hasFailures: findingsResult.hasFailures,
+ hasWarnings: findingsResult.hasWarnings,
+ categories,
+ auditCtx,
+ });
+
+ // Attach hints to findings for per-finding recommendations
+ const findingsWithHints = {
+ pass: findingsResult.findings.pass.map(text => ({
+ text,
+ hint: getHintForFinding(text),
+ })),
+ warn: findingsResult.findings.warn.map(text => ({
+ text,
+ hint: getHintForFinding(text),
+ })),
+ fail: findingsResult.findings.fail.map(text => ({
+ text,
+ hint: getHintForFinding(text),
+ })),
+ };
+
const lastAuditState = {
bestK0,
bestK3,
@@ -591,6 +753,7 @@ export function compileFindingsAndPrescriptions(params) {
return {
findings,
+ findingsWithHints,
prescriptions: uniqueRx,
verdictClass,
verdictIcon,
@@ -601,10 +764,14 @@ export function compileFindingsAndPrescriptions(params) {
lastAuditState,
canRebroadcast,
canDeleteKps,
+ canDeleteOrphanedKps,
+ canDeleteKind4,
missingITagIds,
syncedRelays,
staleRelays,
missingRelays,
totalRelays,
+ doctorNotes,
+ categories,
};
}
diff --git a/js/dialog.js b/js/dialog.js
index de7f683..81ba131 100644
--- a/js/dialog.js
+++ b/js/dialog.js
@@ -1,4 +1,4 @@
-import { dialogText, dialogBox } from './dom.js';
+import { dialogText, dialogBox, dialogArrow, dialogHint } from './dom.js';
import { typeBeep } from './audio.js';
import { isJeff } from './jeff.js';
@@ -8,13 +8,26 @@ let typeTimer = null;
let autoTimer = null;
let onAllDone = null;
-const AUTO_ADVANCE_MS = 400;
+/**
+ * Delay before auto-advancing to next message (ms).
+ * Set to 0 to disable auto-advance (click-to-advance only).
+ */
+let autoAdvanceMs = 0;
+
+/**
+ * Enable or disable auto-advance mode.
+ * @param {boolean} enabled
+ */
+export function setAutoAdvance(enabled) {
+ autoAdvanceMs = enabled ? 400 : 0;
+}
export function clearQueue() {
msgQueue = [];
clearInterval(typeTimer);
clearTimeout(autoTimer);
isTyping = false;
+ _hideAdvanceIndicators();
}
export function setOnAllDone(fn) {
@@ -32,8 +45,26 @@ export function say(text, onDone = null) {
});
}
+/**
+ * Append a message entry to the dialog log.
+ * @param {string} content - Text or pre-sanitized HTML
+ * @param {boolean} isHtml - If true, render as innerHTML (caller must pre-sanitize); otherwise textContent
+ */
+function _appendToLog(content, isHtml = false) {
+ const entry = document.createElement('div');
+ entry.className = 'dialog-msg';
+ if (isHtml) {
+ entry.innerHTML = content;
+ } else {
+ entry.textContent = content;
+ }
+ dialogText.appendChild(entry);
+ dialogText.scrollTop = dialogText.scrollHeight;
+}
+
function _nextMsg() {
clearTimeout(autoTimer);
+ _hideAdvanceIndicators();
if (msgQueue.length === 0) {
dialogText.classList.add('done');
@@ -47,24 +78,31 @@ function _nextMsg() {
const { text, onDone } = msgQueue[0];
if (text.includes('<')) {
- dialogText.innerHTML = text;
+ // Pre-formatted HTML from speak() β callers are responsible for sanitizing
+ // untrusted values via escapeHtml() before they reach this path.
+ _appendToLog(text, true);
_finishMsg(onDone);
return;
}
if (isJeff) {
- dialogText.textContent = text;
+ _appendToLog(text); // plain text β safe textContent path
_finishMsg(onDone);
return;
}
- dialogText.textContent = '';
+ // Typewriter effect: create a new log entry and type into it
+ const entry = document.createElement('div');
+ entry.className = 'dialog-msg';
+ dialogText.appendChild(entry);
+
let i = 0;
clearInterval(typeTimer);
typeTimer = setInterval(() => {
if (i % 3 === 0) typeBeep();
- dialogText.textContent += text.charAt(i);
+ entry.textContent += text.charAt(i);
i++;
+ dialogText.scrollTop = dialogText.scrollHeight;
if (i >= text.length) {
clearInterval(typeTimer);
_finishMsg(onDone);
@@ -77,24 +115,49 @@ function _finishMsg(onDone) {
if (onDone) onDone();
if (msgQueue.length > 1) {
- autoTimer = setTimeout(() => {
- msgQueue.shift();
- _nextMsg();
- }, AUTO_ADVANCE_MS);
+ if (autoAdvanceMs > 0) {
+ autoTimer = setTimeout(() => {
+ msgQueue.shift();
+ _nextMsg();
+ }, autoAdvanceMs);
+ } else {
+ // Click-to-advance: show indicators and wait
+ _showAdvanceIndicators();
+ }
} else {
msgQueue = [];
dialogText.classList.add('done');
+ _hideAdvanceIndicators();
if (onAllDone) { const f = onAllDone; onAllDone = null; f(); }
}
}
+function _showAdvanceIndicators() {
+ if (dialogArrow) dialogArrow.classList.remove('hidden');
+ if (dialogHint) dialogHint.classList.remove('hidden');
+}
+
+function _hideAdvanceIndicators() {
+ if (dialogArrow) dialogArrow.classList.add('hidden');
+ if (dialogHint) dialogHint.classList.add('hidden');
+}
+
function _advance() {
clearTimeout(autoTimer);
+ _hideAdvanceIndicators();
if (isTyping) {
clearInterval(typeTimer);
- const text = msgQueue[0]?.text ?? '';
- if (!text.includes('<')) dialogText.textContent = text;
- _finishMsg(msgQueue[0]?.onDone);
+ const msg = msgQueue[0];
+ const text = msg?.text ?? '';
+ // Complete the typewriter instantly
+ const lastEntry = dialogText.querySelector('.dialog-msg:last-child');
+ if (lastEntry && !text.includes('<')) {
+ lastEntry.textContent = text;
+ } else if (lastEntry) {
+ lastEntry.innerHTML = text;
+ }
+ dialogText.scrollTop = dialogText.scrollHeight;
+ _finishMsg(msg?.onDone);
return;
}
if (msgQueue.length > 1) {
diff --git a/js/dom.js b/js/dom.js
index a79ba0a..2f4035c 100644
--- a/js/dom.js
+++ b/js/dom.js
@@ -5,6 +5,7 @@ export const auditBtn = document.getElementById('audit-btn');
export const dialogText = document.getElementById('dialog-text');
export const dialogArrow = document.getElementById('dialog-arrow');
export const dialogHint = document.getElementById('dialog-hint');
+export const cancelBtn = document.getElementById('cancel-btn');
export const relayList = document.getElementById('relay-list');
export const sprite = document.getElementById('doctor-sprite');
export const spritePanel = document.getElementById('sprite-panel');
@@ -25,13 +26,31 @@ export function getRelayRow(id) { return document.getElementById(id); }
/**
* Returns chart action buttons from a chart container element.
* @param {HTMLElement} chartEl - Chart container (e.g. cardContainer.firstElementChild)
- * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, unifyRelays: Element|null }}
+ * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, deleteOrphanedKps: Element|null, unifyRelays: Element|null, deleteKind4: Element|null, operatingRoom: Element|null }}
*/
export function getChartActionButtons(chartEl) {
- if (!chartEl) return { rebroadcast: null, deleteKps: null, unifyRelays: null };
+ if (!chartEl) return {
+ rebroadcast: null, deleteKps: null, unifyRelays: null,
+ operatingRoom: null, deleteOrphanedKps: null, deleteKind4: null,
+ };
return {
rebroadcast: chartEl.querySelector('[data-action="rebroadcast"]'),
deleteKps: chartEl.querySelector('[data-action="delete-kps"]'),
+ deleteOrphanedKps: chartEl.querySelector('[data-action="delete-orphaned-kps"]'),
unifyRelays: chartEl.querySelector('[data-action="unify-relays"]'),
+ deleteKind4: chartEl.querySelector('[data-action="delete-kind4"]'),
+ operatingRoom: chartEl.querySelector('[data-action="enter-operating-room"]'),
};
}
+
+/** Returns an OR tray element by kind number within a container. */
+export function getTrayByKind(container, kind) {
+ if (!container) return null;
+ return container.querySelector(`.or-tray[data-kind="${kind}"]`);
+}
+
+/** Returns an OR add-relay button by kind within a container. */
+export function getAddBtnByKind(container, kind) {
+ if (!container) return null;
+ return container.querySelector(`.or-add-btn[data-kind="${kind}"]`);
+}
diff --git a/js/main.js b/js/main.js
index 40a5180..f5d45c4 100644
--- a/js/main.js
+++ b/js/main.js
@@ -6,7 +6,7 @@ import { setSprite } from './sprite.js';
import { startAudit, resetAuditState } from './audit.js';
import { getDisplayName, speak } from './personalities.js';
import { removeScanBar } from './scan-bar.js';
-import { npubInput, nip07Btn, nextBtn, auditBtn, spritePanel, dialogSpeaker, spriteLabel, relayList } from './dom.js';
+import { npubInput, nip07Btn, nextBtn, auditBtn, cancelBtn, spritePanel, dialogSpeaker, spriteLabel, relayList } from './dom.js';
import { html } from './html.js';
if (isJeff) document.body.dataset.mode = 'jeff';
@@ -30,15 +30,16 @@ function initSpriteStyle() {
initSpriteStyle();
-function updateNip07Visibility() {
- nip07Btn.classList.toggle('hidden', !window.nostr);
+function updateNip07State() {
+ nip07Btn.disabled = !window.nostr;
+ if (window.nostr) nip07Btn.classList.add('nip07-ready');
}
-updateNip07Visibility();
+updateNip07State();
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => setTimeout(updateNip07Visibility, 500));
+ document.addEventListener('DOMContentLoaded', () => setTimeout(updateNip07State, 500));
} else {
- setTimeout(updateNip07Visibility, 500);
+ setTimeout(updateNip07State, 500);
}
async function signInWithNip07() {
@@ -72,6 +73,7 @@ function resetForNextPatient() {
nip07Btn.addEventListener('click', () => { getAudio(); signInWithNip07(); });
auditBtn.addEventListener('click', () => { getAudio(); startAudit(); });
nextBtn.addEventListener('click', resetForNextPatient);
+cancelBtn.addEventListener('click', () => { getAudio(); resetForNextPatient(); });
npubInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { getAudio(); startAudit(); }
});
diff --git a/js/nak-commands.js b/js/nak-commands.js
new file mode 100644
index 0000000..46d24fd
--- /dev/null
+++ b/js/nak-commands.js
@@ -0,0 +1,194 @@
+/**
+ * Generate copy-paste nak (nostr army knife) commands for each fixable prescription.
+ * These let users without NIP-07 fix their relay setup from the terminal.
+ *
+ * nak CLI reference: https://github.com/fiatjaf/nak
+ * nak event -k
--sec --tag β create+publish
+ * nak req -k -a | nak event β rebroadcast
+ */
+
+import { escapeHtml } from './html.js';
+
+const HEX_RE = /^[0-9a-f]{64}$/i;
+
+/**
+ * Validate that a string is a 64-char hex ID (event ID or pubkey).
+ * @param {string} val
+ * @returns {boolean}
+ */
+function isValidHex64(val) {
+ return typeof val === 'string' && HEX_RE.test(val);
+}
+
+/**
+ * Generate a nak command string for a given prescription action.
+ *
+ * @param {string} action - One of: rebroadcast, delete-kps, delete-orphaned-kps, unify-relays, delete-kind4
+ * @param {Object} state - The audit state from getAuditState()
+ * @returns {{ cmd: string, description: string }|null} The nak command and a human-readable description, or null if not applicable
+ */
+export function getNakCommand(action, state) {
+ if (!state) return null;
+
+ const { pubkey, relaysToInvestigate } = state;
+ if (!pubkey) return null;
+
+ const relayArgs = (relaysToInvestigate || []).map(shellQuote).join(' ');
+ const sourceRelay = shellQuote(relaysToInvestigate?.[0] || 'wss://relay.damus.io');
+
+ switch (action) {
+ case 'rebroadcast':
+ return buildRebroadcastCmd(pubkey, sourceRelay, relayArgs);
+ case 'delete-kps':
+ return buildDeleteKpsCmd(state, relayArgs);
+ case 'delete-orphaned-kps':
+ return buildDeleteOrphanedKpsCmd(state, relayArgs);
+ case 'unify-relays':
+ return buildUnifyCmd(state, relayArgs);
+ case 'delete-kind4':
+ return buildDeleteKind4Cmd(pubkey, relayArgs);
+ default:
+ return null;
+ }
+}
+
+function buildRebroadcastCmd(pubkey, sourceRelay, relayArgs) {
+ if (!isValidHex64(pubkey)) return null;
+ const cmd = '# Rebroadcast profile (k0) and contacts (k3) to all relays\n'
+ + 'nak req -k 0 -k 3 -a ' + shellQuote(pubkey) + ' ' + sourceRelay + ' | \\\n'
+ + ' nak event ' + relayArgs;
+
+ return {
+ cmd,
+ description: 'Fetches your latest kind 0 + kind 3 from the first relay and republishes them to all your relays.',
+ };
+}
+
+function buildDeleteKpsCmd(state, relayArgs) {
+ const { missingITagIds } = state;
+ if (!missingITagIds || missingITagIds.length === 0) return null;
+
+ const validIds = missingITagIds.filter(isValidHex64);
+ if (validIds.length === 0) return null;
+
+ const eTags = validIds.map(id => '-e ' + shellQuote(id)).join(' \\\n ');
+ const cmd = '# Delete orphaned/invalid KeyPackages (kind 5 deletion)\n'
+ + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n'
+ + ' ' + eTags + ' \\\n'
+ + " -c 'KeyPackage cleanup' \\\n"
+ + ' ' + relayArgs;
+
+ return {
+ cmd,
+ description: 'Signs kind 5 deletion events for ' + validIds.length + ' KeyPackage(s) and publishes to all relays. Set NOSTR_SECRET_KEY first.',
+ };
+}
+
+function buildDeleteOrphanedKpsCmd(state, relayArgs) {
+ const { orphanedKpRelays } = state;
+ if (!orphanedKpRelays || orphanedKpRelays.length === 0) return null;
+
+ const orphanRelayArgs = orphanedKpRelays.map(shellQuote).join(' ');
+ const pk = state.pubkey;
+ if (!isValidHex64(pk)) return null;
+ const cmd = '# Step 1: Find KeyPackage IDs on orphaned relays\n'
+ + 'nak req -k 443 -a ' + shellQuote(pk) + ' ' + orphanRelayArgs + '\n\n'
+ + '# Step 2: Delete them (replace with IDs from step 1)\n'
+ + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n'
+ + ' -e \\\n'
+ + " -c 'Orphaned KP cleanup' \\\n"
+ + ' ' + orphanRelayArgs + ' ' + relayArgs;
+
+ return {
+ cmd,
+ description: 'Queries ' + orphanedKpRelays.length + ' orphaned relay(s) for kind 443 events, then deletes them. Two-step: find IDs first, then delete.',
+ };
+}
+
+function buildUnifyCmd(state, relayArgs) {
+ const { k3RelaySet, k10002RelaySet, bestK3 } = state;
+ if (!k3RelaySet && !k10002RelaySet) return null;
+
+ // Merge both sets
+ const merged = [...new Set([...(k3RelaySet || []), ...(k10002RelaySet || [])])];
+ if (merged.length === 0) return null;
+
+ // Build k10002 r-tags (quote the tag value to prevent shell injection)
+ const rTags = merged.map(r => '--tag ' + shellQuote('r=' + r)).join(' \\\n ');
+
+ // Build k3 relay tags + preserve p-tags
+ const relayTags = merged.map(r => '--tag ' + shellQuote('relay=' + r)).join(' \\\n ');
+ const pTags = (bestK3?.tags || [])
+ .filter(t => Array.isArray(t) && t[0] === 'p' && t[1] && isValidHex64(t[1]))
+ .map(t => '-p ' + shellQuote(t[1]))
+ .join(' \\\n ');
+
+ const cmd = '# Publish unified kind 10002 (NIP-65 relay list)\n'
+ + 'nak event -k 10002 --sec $NOSTR_SECRET_KEY \\\n'
+ + ' ' + rTags + ' \\\n'
+ + " -c '' \\\n"
+ + ' ' + relayArgs + '\n\n'
+ + '# Publish unified kind 3 (contacts)\n'
+ + 'nak event -k 3 --sec $NOSTR_SECRET_KEY \\\n'
+ + (pTags ? ' ' + pTags + ' \\\n ' : ' ')
+ + relayTags + ' \\\n'
+ + " -c '" + escapeShellContent(bestK3?.content || '') + "' \\\n"
+ + ' ' + relayArgs;
+
+ return {
+ cmd,
+ description: 'Publishes a new kind 10002 and kind 3 with ' + merged.length + ' unified relay(s). Both events will advertise the same set.',
+ };
+}
+
+function buildDeleteKind4Cmd(pubkey, relayArgs) {
+ if (!isValidHex64(pubkey)) return null;
+ const qpk = shellQuote(pubkey);
+ const cmd = '# Step 1: Find all kind 4 (NIP-04 DM) event IDs\n'
+ + 'nak req -k 4 -a ' + qpk + ' ' + relayArgs + '\n\n'
+ + '# Step 2: Delete them (pipe IDs or list them manually)\n'
+ + 'nak req -k 4 -a ' + qpk + ' ' + relayArgs + ' | \\\n'
+ + " jq -r '.id' | \\\n"
+ + ' xargs -I {} nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n'
+ + ' -e {} \\\n'
+ + " -c 'NIP-04 DM cleanup β migrating to NIP-17/Marmot' \\\n"
+ + ' ' + relayArgs;
+
+ return {
+ cmd,
+ description: 'Scans for kind 4 events and publishes kind 5 deletions for each. Requires jq and xargs.',
+ };
+}
+
+/**
+ * Wrap a string in single quotes for safe shell usage.
+ * Escapes embedded single quotes via the '\'' idiom.
+ * @param {string} str
+ * @returns {string}
+ */
+function shellQuote(str) {
+ return "'" + String(str).replace(/'/g, "'\\''") + "'";
+}
+
+function escapeShellContent(str) {
+ // Escape single quotes for shell: replace ' with '\''
+ return str.replace(/'/g, "'\\''");
+}
+
+/**
+ * Render a nak command block as HTML with a copy button.
+ * @param {{ cmd: string, description: string }} nakCmd
+ * @returns {string} HTML string
+ */
+export function renderNakCommandBlock(nakCmd) {
+ if (!nakCmd) return '';
+
+ return ``
+ + ``
+ + `
${escapeHtml(nakCmd.description)}
`
+ + `
${escapeHtml(nakCmd.cmd)}
`
+ + `
`;
+}
diff --git a/js/operate/autostage.js b/js/operate/autostage.js
new file mode 100644
index 0000000..1245f17
--- /dev/null
+++ b/js/operate/autostage.js
@@ -0,0 +1,256 @@
+/**
+ * Auto-staging logic β maps audit prescriptions to OR staged changes.
+ * Analyzes the compiled audit result and pre-stages actionable fixes
+ * so the user enters the OR with a treatment plan already loaded.
+ */
+
+import { stageAddRelay, stageRemoveRelay, stageToggleReadWrite, getRelayList } from './editor.js';
+import { validateRelayUrl } from '../relay-validation.js';
+
+/**
+ * @typedef {Object} AutoStageResult
+ * @property {number} staged - Number of changes successfully auto-staged
+ * @property {string[]} actions - Human-readable descriptions of what was staged
+ * @property {string[]} suggestions - Things that need manual action (can't be auto-staged)
+ */
+
+/**
+ * Analyze prescriptions and audit state to determine what can be auto-staged.
+ * Call this after initEditorState() in room.js.
+ *
+ * @param {Object} auditState - from getAuditState()
+ * @param {Object} compiledResult - from compileFindingsAndPrescriptions() stored on auditState
+ * @returns {AutoStageResult}
+ */
+export function autoStageFromPrescriptions(auditState, compiledResult) {
+ const actions = [];
+ const suggestions = [];
+ let staged = 0;
+
+ // --- 1. Remove invalid relay URLs ---
+ staged += removeInvalidRelays(auditState, actions);
+
+ // --- 2. Unify k3 and k10002 relay lists ---
+ if (compiledResult?.canUnifyRelays || auditState.canUnifyRelays) {
+ staged += unifyRelayLists(auditState, actions);
+ }
+
+ // --- 3. Add missing inbox relays (k10050) from k10002 if k10050 is empty ---
+ if (compiledResult?.findings?.fail?.some(f => f.includes('kind 10050 contains no valid relay tags'))) {
+ staged += suggestInboxRelaysFromNip65(auditState, actions, suggestions);
+ }
+
+ // --- 4. Handle orphaned KeyPackage relays ---
+ if (auditState.orphanedKpRelays?.length > 0) {
+ staged += stageOrphanedKpRelayFixes(auditState, actions, suggestions);
+ }
+
+ // --- 5. NIP-65 read/write fixes ---
+ staged += fixNip65ReadWrite(auditState, compiledResult, actions);
+
+ // --- 6. Suggest things that need manual/client action ---
+ addManualSuggestions(auditState, compiledResult, suggestions);
+
+ return { staged, actions, suggestions };
+}
+
+/**
+ * Remove invalid relay URLs from all kinds.
+ * @returns {number} Count of staged removals
+ */
+function removeInvalidRelays(auditState, actions) {
+ let count = 0;
+
+ // Check each kind for invalid URLs
+ for (const kind of [10002, 10050, 10051, 3]) {
+ const relays = getRelayList(kind);
+ for (const entry of relays) {
+ if (entry.removed || entry.staged) continue;
+ const { valid } = validateRelayUrl(entry.url);
+ if (!valid) {
+ const result = stageRemoveRelay(kind, entry.url);
+ if (result.ok) {
+ count++;
+ actions.push(`Remove invalid URL from k${kind}: ${entry.url}`);
+ }
+ }
+ }
+ }
+
+ return count;
+}
+
+/**
+ * Unify k3 and k10002 relay lists by adding missing relays to each.
+ * @returns {number} Count of staged additions
+ */
+function unifyRelayLists(auditState, actions) {
+ let count = 0;
+
+ const k3Set = new Set((auditState.k3RelaySet || []).map(u => u.toLowerCase()));
+ const k10002Set = new Set((auditState.k10002RelaySet || []).map(u => u.toLowerCase()));
+
+ // Add k3-only relays to k10002
+ for (const url of auditState.k3RelaySet || []) {
+ if (!k10002Set.has(url.toLowerCase())) {
+ const result = stageAddRelay(10002, url, 'rw');
+ if (result.ok) {
+ count++;
+ actions.push(`Add to k10002 (from k3): ${url}`);
+ }
+ }
+ }
+
+ // Add k10002-only relays to k3
+ for (const url of auditState.k10002RelaySet || []) {
+ if (!k3Set.has(url.toLowerCase())) {
+ const result = stageAddRelay(3, url);
+ if (result.ok) {
+ count++;
+ actions.push(`Add to k3 (from k10002): ${url}`);
+ }
+ }
+ }
+
+ return count;
+}
+
+/**
+ * Suggest inbox relays (k10050) from the user's NIP-65 read relays.
+ * @returns {number} Count of staged additions
+ */
+function suggestInboxRelaysFromNip65(auditState, actions, suggestions) {
+ let count = 0;
+
+ const k10002Relays = getRelayList(10002);
+ const readRelays = k10002Relays.filter(
+ r => !r.removed && (r.readWrite === 'read' || r.readWrite === 'rw'),
+ );
+
+ if (readRelays.length > 0) {
+ // Add the first 3 read relays as inbox relays
+ for (const relay of readRelays.slice(0, 3)) {
+ const result = stageAddRelay(10050, relay.url);
+ if (result.ok) {
+ count++;
+ actions.push(`Add inbox relay (from NIP-65 read): ${relay.url}`);
+ }
+ }
+ } else {
+ suggestions.push('Add inbox relays (k10050) β no read relays found to suggest from');
+ }
+
+ return count;
+}
+
+/**
+ * Handle orphaned KeyPackage relays β suggest adding them to k10051.
+ * @returns {number} Count of staged additions
+ */
+function stageOrphanedKpRelayFixes(auditState, actions, suggestions) {
+ let count = 0;
+
+ for (const url of auditState.orphanedKpRelays) {
+ const result = stageAddRelay(10051, url);
+ if (result.ok) {
+ count++;
+ actions.push(`Add orphaned KP relay to k10051: ${url}`);
+ }
+ }
+
+ if (count === 0 && auditState.orphanedKpRelays.length > 0) {
+ suggestions.push(
+ `${auditState.orphanedKpRelays.length} orphaned KP relay(s) already in k10051 β delete orphaned KPs from those relays instead`,
+ );
+ }
+
+ return count;
+}
+
+/**
+ * Fix NIP-65 read/write issues by toggling relays that are read-only or write-only.
+ * Only acts when ALL relays are the same direction (no read or no write).
+ * @returns {number} Count of staged modifications
+ */
+function fixNip65ReadWrite(auditState, compiledResult, actions) {
+ let count = 0;
+ const findings = compiledResult?.findings;
+ if (!findings) return 0;
+
+ const hasNoRead = findings.fail?.some(f => f.includes('NIP-65: no read relays'));
+ const hasNoWrite = findings.fail?.some(f => f.includes('NIP-65: no write relays'));
+
+ if (!hasNoRead && !hasNoWrite) return 0;
+
+ const k10002Relays = getRelayList(10002);
+ for (const entry of k10002Relays) {
+ if (entry.removed || entry.staged) continue;
+
+ if (hasNoRead && entry.readWrite === 'write') {
+ // Make it rw so there's at least one read relay
+ stageToggleReadWrite(entry.url, 'rw');
+ count++;
+ actions.push(`Set to R/W (was write-only): ${entry.url}`);
+ break; // Only need to fix one
+ }
+
+ if (hasNoWrite && entry.readWrite === 'read') {
+ stageToggleReadWrite(entry.url, 'rw');
+ count++;
+ actions.push(`Set to R/W (was read-only): ${entry.url}`);
+ break;
+ }
+ }
+
+ return count;
+}
+
+/**
+ * Add suggestions for things that require manual or client-side action.
+ */
+function addManualSuggestions(auditState, compiledResult, suggestions) {
+ const findings = compiledResult?.findings;
+ if (!findings) return;
+
+ // k10051 missing entirely
+ if (findings.fail?.some(f => f.includes('No KeyPackage Relay List (kind 10051)'))) {
+ suggestions.push('Publish a KeyPackage Relay List (k10051) β add relays in the KEYPACKAGE RELAYS tray');
+ }
+
+ // k10050 missing entirely
+ if (findings.fail?.some(f => f.includes('No Inbox Relay List (kind 10050)'))) {
+ suggestions.push('Publish an Inbox Relay List (k10050) β add relays in the INBOX RELAYS tray');
+ }
+
+ // NIP-65 bloated
+ if (findings.warn?.some(f => f.includes('excessive relay count'))) {
+ suggestions.push('Consider removing some relays from k10002 β 10 or fewer is recommended');
+ }
+
+ // KeyPackage issues (client-side)
+ if (findings.fail?.some(f => f.includes('KP ') || f.includes('KeyPackage'))) {
+ suggestions.push('KeyPackage issues require your Marmot client to publish corrected KeyPackages');
+ }
+
+ // Stale profile/contacts
+ if (compiledResult?.canRebroadcast) {
+ suggestions.push('Profile/Contacts out of sync β use REBROADCAST from the chart, or re-audit after operating');
+ }
+}
+
+/**
+ * Format the auto-stage results into a briefing summary.
+ * @param {AutoStageResult} result
+ * @returns {{ actionSummary: string, suggestionSummary: string }}
+ */
+export function formatAutoStageSummary(result) {
+ const actionSummary = result.actions.length > 0
+ ? result.actions.map(a => ` + ${a}`).join('\n')
+ : 'No changes auto-staged.';
+
+ const suggestionSummary = result.suggestions.length > 0
+ ? result.suggestions.map(s => ` ? ${s}`).join('\n')
+ : '';
+
+ return { actionSummary, suggestionSummary };
+}
diff --git a/js/operate/editor.js b/js/operate/editor.js
new file mode 100644
index 0000000..453f3f9
--- /dev/null
+++ b/js/operate/editor.js
@@ -0,0 +1,432 @@
+/**
+ * Relay list editor β manages staged changes per event kind.
+ * Tracks add/remove/modify operations without publishing until confirmed.
+ */
+
+import { html, escapeHtml } from '../html.js';
+import { validateRelayUrl, shortUrl } from '../relay-validation.js';
+import { formatLatency, healthScore } from './health.js';
+
+/**
+ * @typedef {'add'|'remove'|'modify'} ChangeType
+ *
+ * @typedef {Object} StagedChange
+ * @property {ChangeType} type
+ * @property {string} url
+ * @property {string} [readWrite] - 'read'|'write'|'rw' (k10002 only)
+ * @property {string} [prevReadWrite] - Previous read/write state (for modify)
+ *
+ * @typedef {Object} RelayEntry
+ * @property {string} url
+ * @property {string} readWrite - 'read'|'write'|'rw'|'' (empty for non-10002 kinds)
+ * @property {boolean} staged - Whether this entry is a staged addition
+ * @property {boolean} removed - Whether this entry is staged for removal
+ */
+
+/** Map of kind β array of staged changes */
+const stagedChanges = new Map();
+
+/** Map of kind β array of current relay entries (from audit state) */
+const currentRelays = new Map();
+
+/**
+ * Initialize the editor state from the audit result.
+ * @param {Object} auditState - from getAuditState()
+ */
+export function initEditorState(auditState) {
+ stagedChanges.clear();
+ currentRelays.clear();
+
+ // Kind 10002 (NIP-65) β with read/write markers
+ const k10002Relays = [];
+ if (auditState.bestK10002?.tags) {
+ for (const t of auditState.bestK10002.tags) {
+ if (t[0] === 'r' && typeof t[1] === 'string' && t[1].trim()) {
+ const marker = t[2] || 'rw'; // 'read', 'write', or default 'rw'
+ k10002Relays.push({ url: t[1].trim(), readWrite: marker, staged: false, removed: false });
+ }
+ }
+ }
+ currentRelays.set(10002, k10002Relays);
+ stagedChanges.set(10002, []);
+
+ // Kind 10050 (Inbox Relays)
+ const k10050Relays = [];
+ if (auditState.best10050?.tags) {
+ for (const t of auditState.best10050.tags) {
+ if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) {
+ k10050Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false });
+ }
+ }
+ }
+ currentRelays.set(10050, k10050Relays);
+ stagedChanges.set(10050, []);
+
+ // Kind 10051 (KeyPackage Relays)
+ const k10051Relays = [];
+ if (auditState.best10051?.tags) {
+ for (const t of auditState.best10051.tags) {
+ if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) {
+ k10051Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false });
+ }
+ }
+ }
+ currentRelays.set(10051, k10051Relays);
+ stagedChanges.set(10051, []);
+
+ // Kind 3 (Contacts relay hints)
+ const k3Relays = [];
+ if (auditState.bestK3?.tags) {
+ for (const t of auditState.bestK3.tags) {
+ if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) {
+ k3Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false });
+ }
+ }
+ }
+ currentRelays.set(3, k3Relays);
+ stagedChanges.set(3, []);
+}
+
+/**
+ * Stage adding a relay to a kind's list.
+ * @param {number} kind
+ * @param {string} url
+ * @param {string} [readWrite='rw'] - For k10002 only
+ * @returns {{ ok: boolean, reason?: string }}
+ */
+export function stageAddRelay(kind, url, readWrite = 'rw') {
+ const trimmed = url.trim();
+ const validation = validateRelayUrl(trimmed);
+ if (!validation.valid) return { ok: false, reason: validation.reason };
+
+ const relays = currentRelays.get(kind) || [];
+ const changes = stagedChanges.get(kind) || [];
+
+ // Check duplicates (including already-staged adds)
+ const exists = relays.some(r => r.url.toLowerCase() === trimmed.toLowerCase() && !r.removed);
+ const alreadyStaged = changes.some(c => c.type === 'add' && c.url.toLowerCase() === trimmed.toLowerCase());
+ if (exists || alreadyStaged) return { ok: false, reason: 'already in list' };
+
+ // If previously removed, un-remove instead of adding
+ const removeIdx = changes.findIndex(c => c.type === 'remove' && c.url.toLowerCase() === trimmed.toLowerCase());
+ if (removeIdx !== -1) {
+ changes.splice(removeIdx, 1);
+ const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase());
+ if (entry) entry.removed = false;
+ return { ok: true };
+ }
+
+ changes.push({ type: 'add', url: trimmed, readWrite: kind === 10002 ? readWrite : '' });
+ relays.push({ url: trimmed, readWrite: kind === 10002 ? readWrite : '', staged: true, removed: false });
+ stagedChanges.set(kind, changes);
+ currentRelays.set(kind, relays);
+ return { ok: true };
+}
+
+/**
+ * Stage removing a relay from a kind's list.
+ * @param {number} kind
+ * @param {string} url
+ * @returns {{ ok: boolean, reason?: string }}
+ */
+export function stageRemoveRelay(kind, url) {
+ const trimmed = url.trim();
+ const relays = currentRelays.get(kind) || [];
+ const changes = stagedChanges.get(kind) || [];
+
+ // If it was a staged add, just remove the staged add
+ const addIdx = changes.findIndex(c => c.type === 'add' && c.url.toLowerCase() === trimmed.toLowerCase());
+ if (addIdx !== -1) {
+ changes.splice(addIdx, 1);
+ const entryIdx = relays.findIndex(r => r.url.toLowerCase() === trimmed.toLowerCase() && r.staged);
+ if (entryIdx !== -1) relays.splice(entryIdx, 1);
+ return { ok: true };
+ }
+
+ const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase());
+ if (!entry) return { ok: false, reason: 'not in list' };
+ if (entry.removed) return { ok: false, reason: 'already staged for removal' };
+
+ entry.removed = true;
+ changes.push({ type: 'remove', url: trimmed });
+ stagedChanges.set(kind, changes);
+ return { ok: true };
+}
+
+/**
+ * Stage toggling read/write marker for a k10002 relay.
+ * @param {string} url
+ * @param {string} newReadWrite - 'read'|'write'|'rw'
+ * @returns {{ ok: boolean, reason?: string }}
+ */
+export function stageToggleReadWrite(url, newReadWrite) {
+ const trimmed = url.trim();
+ const relays = currentRelays.get(10002) || [];
+ const changes = stagedChanges.get(10002) || [];
+
+ const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase() && !r.removed);
+ if (!entry) return { ok: false, reason: 'not in list' };
+ if (entry.readWrite === newReadWrite) return { ok: true }; // no change
+
+ const prevReadWrite = entry.readWrite;
+ entry.readWrite = newReadWrite;
+
+ // Remove any existing modify change for this URL
+ const existingIdx = changes.findIndex(c => c.type === 'modify' && c.url.toLowerCase() === trimmed.toLowerCase());
+ if (existingIdx !== -1) changes.splice(existingIdx, 1);
+
+ changes.push({ type: 'modify', url: trimmed, readWrite: newReadWrite, prevReadWrite });
+ stagedChanges.set(10002, changes);
+ return { ok: true };
+}
+
+/**
+ * Get the current relay list for a kind (including staged changes reflected).
+ * @param {number} kind
+ * @returns {RelayEntry[]}
+ */
+export function getRelayList(kind) {
+ return currentRelays.get(kind) || [];
+}
+
+/**
+ * Get staged changes for a kind.
+ * @param {number} kind
+ * @returns {StagedChange[]}
+ */
+export function getStagedChanges(kind) {
+ return stagedChanges.get(kind) || [];
+}
+
+/**
+ * Check if there are any staged changes across all kinds.
+ * @returns {boolean}
+ */
+export function hasAnyStagedChanges() {
+ for (const changes of stagedChanges.values()) {
+ if (changes.length > 0) return true;
+ }
+ return false;
+}
+
+/**
+ * Get total count of staged changes.
+ * @returns {number}
+ */
+export function totalStagedChanges() {
+ let count = 0;
+ for (const changes of stagedChanges.values()) {
+ count += changes.length;
+ }
+ return count;
+}
+
+/**
+ * Get the final relay list for a kind (after applying staged changes).
+ * Removes entries marked for removal, includes staged additions.
+ * @param {number} kind
+ * @returns {{ url: string, readWrite: string }[]}
+ */
+export function getFinalRelayList(kind) {
+ const relays = currentRelays.get(kind) || [];
+ return relays
+ .filter(r => !r.removed)
+ .map(r => ({ url: r.url, readWrite: r.readWrite }));
+}
+
+/**
+ * Clear all staged changes without applying them.
+ */
+export function clearAllChanges() {
+ // Restore readWrite values from modify changes before clearing
+ for (const [kind, changes] of stagedChanges.entries()) {
+ const relays = currentRelays.get(kind) || [];
+ for (const change of changes) {
+ if (change.type === 'modify' && change.prevReadWrite) {
+ const entry = relays.find(
+ r => r.url.toLowerCase() === change.url.toLowerCase() && !r.staged,
+ );
+ if (entry) entry.readWrite = change.prevReadWrite;
+ }
+ }
+ stagedChanges.set(kind, []);
+ }
+ // Re-init from currentRelays by removing staged entries and un-removing removed entries
+ for (const [kind, relays] of currentRelays.entries()) {
+ const cleaned = relays.filter(r => !r.staged);
+ for (const r of cleaned) r.removed = false;
+ currentRelays.set(kind, cleaned);
+ }
+}
+
+// βββ RENDERING βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const KIND_LABELS = {
+ 10002: { title: 'NIP-65 RELAY LIST', tag: 'k10002', icon: 'π‘' },
+ 10050: { title: 'INBOX RELAYS', tag: 'k10050', icon: 'π¨' },
+ 10051: { title: 'KEYPACKAGE RELAYS', tag: 'k10051', icon: 'π' },
+ 3: { title: 'CONTACTS RELAY HINTS', tag: 'k3', icon: 'π₯' },
+};
+
+/**
+ * Render a relay tray (collapsible section) for a single kind.
+ * @param {number} kind
+ * @param {Map} [healthResults]
+ * @returns {string} HTML string
+ */
+export function renderRelayTray(kind, healthResults) {
+ const label = KIND_LABELS[kind] || { title: `KIND ${kind}`, tag: `k${kind}`, icon: 'β' };
+ const relays = getRelayList(kind);
+ const changes = getStagedChanges(kind);
+ const changeCount = changes.length;
+
+ const relayRows = relays.map((entry) => {
+ const health = healthResults?.get(entry.url);
+ const hp = health ? healthScore(health) : null;
+ const latencyStr = health
+ ? `C:${formatLatency(health.connectMs)} R:${formatLatency(health.readMs)}`
+ : '';
+ const statusCls = entry.removed ? 'or-removed' : entry.staged ? 'or-staged' : '';
+ const healthCls = health
+ ? `or-health-${health.status}`
+ : '';
+ const changeIcon = entry.removed ? 'β' : entry.staged ? '+' : '';
+ const changeCls = entry.removed ? 'or-change-remove' : entry.staged ? 'or-change-add' : '';
+
+ let rwToggles = '';
+ if (kind === 10002 && !entry.removed) {
+ const isRead = entry.readWrite === 'read' || entry.readWrite === 'rw';
+ const isWrite = entry.readWrite === 'write' || entry.readWrite === 'rw';
+ rwToggles = html`
+
+
+
+
+ `;
+ }
+
+ const removeBtn = entry.removed
+ ? html``
+ : html``;
+
+ return html`
+
+ ${changeIcon}
+
+ ${escapeHtml(shortUrl(entry.url))}
+ ${rwToggles}
+ ${latencyStr}
+ ${hp !== null ? html`
+
+
+
+ ` : ''}
+ ${removeBtn}
+
+ `;
+ });
+
+ const stageBadge = changeCount > 0
+ ? html`${changeCount} STAGED`
+ : '';
+
+ return html`
+
+
+
+ ${relayRows.length > 0 ? relayRows : html`
No relays configured
`}
+
+
+
+
+
+
+
+ `;
+}
+
+/**
+ * Render the full Operating Room editor panel.
+ * @param {Map} [healthResults]
+ * @returns {string} HTML string
+ */
+export function renderEditorPanel(healthResults) {
+ const kinds = [10002, 10050, 10051, 3];
+ const trays = kinds.map(k => renderRelayTray(k, healthResults));
+ const totalChanges = totalStagedChanges();
+
+ return html`
+
+
+ ${trays}
+
+ ${totalChanges > 0 ? html`
+
+ ${totalChanges} change(s) staged
+
+
+
+ ` : html`
+
No changes staged β add or remove relays above
+ `}
+
+
+
+ `;
+}
+
+/**
+ * Render the staged changes summary (diff preview).
+ * @returns {string} HTML string
+ */
+export function renderStagedSummary() {
+ const kinds = [10002, 10050, 10051, 3];
+ const sections = [];
+
+ for (const kind of kinds) {
+ const changes = getStagedChanges(kind);
+ if (changes.length === 0) continue;
+
+ const label = KIND_LABELS[kind] || { title: `KIND ${kind}` };
+ const rows = changes.map((c) => {
+ if (c.type === 'add') {
+ return html`+ ${escapeHtml(shortUrl(c.url))}${c.readWrite ? ` [${c.readWrite.toUpperCase()}]` : ''}
`;
+ }
+ if (c.type === 'remove') {
+ return html`β ${escapeHtml(shortUrl(c.url))}
`;
+ }
+ if (c.type === 'modify') {
+ return html`~ ${escapeHtml(shortUrl(c.url))} [${(c.prevReadWrite || '').toUpperCase()} β ${(c.readWrite || '').toUpperCase()}]
`;
+ }
+ return '';
+ });
+
+ sections.push(html`
+
+
${label.title}
+ ${rows}
+
+ `);
+ }
+
+ return sections.length > 0
+ ? html`${sections}
`
+ : '';
+}
diff --git a/js/operate/guide.js b/js/operate/guide.js
new file mode 100644
index 0000000..db89c79
--- /dev/null
+++ b/js/operate/guide.js
@@ -0,0 +1,402 @@
+/**
+ * Guided treatment flow β step-by-step prescription walker.
+ * Presents prescriptions one at a time with doctor recommendations,
+ * highlights the relevant tray, and lets the user advance or skip.
+ */
+
+import { html, escapeHtml } from '../html.js';
+import { speak } from '../personalities.js';
+import { getNakCommand, renderNakCommandBlock } from '../nak-commands.js';
+
+/**
+ * @typedef {Object} GuideStep
+ * @property {number} index - Step number (0-based)
+ * @property {string} prescription - The prescription text
+ * @property {string|null} recommendation - Doctor's recommendation for this step
+ * @property {number|null} targetKind - Which kind tray to highlight (10002, 10050, 10051, 3), or null
+ * @property {boolean} isActionable - Whether the OR can stage a fix for this
+ * @property {boolean} isClientSide - Whether this requires client/external action
+ * @property {string|null} fixAction - data-action value for the FIX IT button, or null
+ */
+
+let steps = [];
+let currentStep = -1;
+let isGuided = false;
+let storedAuditState = null;
+
+/**
+ * Whether guided mode is active.
+ * @returns {boolean}
+ */
+export function isGuidedMode() {
+ return isGuided;
+}
+
+/**
+ * Get the current step index.
+ * @returns {number}
+ */
+export function getCurrentStepIndex() {
+ return currentStep;
+}
+
+/**
+ * Get the current step, or null if not in guided mode.
+ * @returns {GuideStep|null}
+ */
+export function getCurrentStep() {
+ if (!isGuided || currentStep < 0 || currentStep >= steps.length) return null;
+ return steps[currentStep];
+}
+
+/**
+ * Get total step count.
+ * @returns {number}
+ */
+export function getTotalSteps() {
+ return steps.length;
+}
+
+/**
+ * Initialize the guided flow from prescriptions.
+ * @param {string[]} prescriptions - From compileFindingsAndPrescriptions
+ * @param {Object} _findings - The findings object with pass/warn/fail arrays (reserved for future step enrichment)
+ * @param {Object} [auditState] - Audit state for generating nak commands
+ * @returns {number} Total steps
+ */
+export function initGuide(prescriptions, _findings, auditState) {
+ storedAuditState = auditState || null;
+ steps = prescriptions.map((rx, index) => ({
+ index,
+ prescription: rx,
+ recommendation: getRecommendationForPrescription(rx),
+ targetKind: getTargetKindForPrescription(rx),
+ isActionable: isActionablePrescription(rx),
+ isClientSide: isClientSidePrescription(rx),
+ fixAction: getFixActionForPrescription(rx),
+ }));
+ currentStep = steps.length > 0 ? 0 : -1;
+ isGuided = steps.length > 0;
+ return steps.length;
+}
+
+/**
+ * Advance to the next step.
+ * @returns {GuideStep|null} The next step, or null if done
+ */
+export function nextStep() {
+ if (!isGuided) return null;
+ currentStep++;
+ if (currentStep >= steps.length) {
+ isGuided = false;
+ return null;
+ }
+ return steps[currentStep];
+}
+
+/**
+ * Skip the current step and advance.
+ * @returns {GuideStep|null} The next step, or null if done
+ */
+export function skipStep() {
+ return nextStep();
+}
+
+/**
+ * Exit guided mode.
+ */
+export function exitGuide() {
+ isGuided = false;
+ currentStep = -1;
+ steps = [];
+ storedAuditState = null;
+}
+
+/**
+ * Switch to manual (free-form) mode without losing position.
+ */
+export function switchToManual() {
+ isGuided = false;
+}
+
+/**
+ * Resume guided mode from the current position.
+ */
+export function resumeGuide() {
+ if (steps.length > 0) {
+ isGuided = true;
+ if (currentStep < 0) currentStep = 0;
+ }
+}
+
+/**
+ * Render the guided step card UI.
+ * @param {GuideStep} step
+ * @returns {string} HTML string
+ */
+export function renderGuideCard(step) {
+ if (!step) return '';
+
+ const progress = `${step.index + 1} / ${steps.length}`;
+ const kindLabel = step.targetKind ? `k${step.targetKind}` : '';
+ const actionableTag = step.isClientSide
+ ? 'CLIENT'
+ : step.isActionable
+ ? 'FIXABLE'
+ : 'MANUAL';
+
+ const recommendation = step.recommendation
+ ? html`${step.recommendation}
`
+ : '';
+
+ const doctorNote = speak('guideStepNote', { step: step.index + 1, total: steps.length })
+ || '';
+
+ const isNip07Required = !window.nostr;
+ const fixBtn = step.fixAction
+ ? html`
+
+ `
+ : '';
+
+ // Generate nak command block for this step
+ let nakSection = '';
+ if (step.fixAction && storedAuditState) {
+ const nakCmd = getNakCommand(step.fixAction, storedAuditState);
+ if (nakCmd) {
+ nakSection = html`
+
+
+
+ ${renderNakCommandBlock(nakCmd)}
+ `;
+ }
+ }
+
+ return html`
+
+
+
+
+ ${actionableTag}
+ ${escapeHtml(step.prescription)}
+
+ ${recommendation}
+ ${doctorNote ? html`
${doctorNote}
` : ''}
+
+
+ ${fixBtn}
+
+
+
+
+ ${nakSection}
+
+ `;
+}
+
+/**
+ * Render the guide mode toggle (shown in manual mode to resume).
+ * @returns {string} HTML string
+ */
+export function renderGuideResumeBar() {
+ if (steps.length === 0) return '';
+ const remaining = steps.length - Math.max(0, currentStep);
+ return html`
+
+
+
+ `;
+}
+
+// βββ PRESCRIPTION ANALYSIS βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const KIND_PATTERNS = {
+ 10002: ['k10002', 'kind 10002', 'NIP-65', 'nip-65', 'read relay', 'write relay'],
+ 10050: ['k10050', 'kind 10050', 'inbox', 'giftwrap', 'DM inbox'],
+ 10051: ['k10051', 'kind 10051', 'KeyPackage Relay', 'keypackage relay'],
+ 3: ['k3', 'kind 3', 'contacts', 'Contacts'],
+};
+
+/**
+ * Determine which kind tray a prescription targets.
+ * @param {string} rx
+ * @returns {number|null}
+ */
+function getTargetKindForPrescription(rx) {
+ const lower = rx.toLowerCase();
+
+ // Check for multi-kind prescriptions first
+ const isMultiKind = ['k3 / k10002', 'k3/k10002', 'unify'].some(p => lower.includes(p));
+ if (isMultiKind) return 10002; // Primary target is k10002
+
+ for (const [kind, patterns] of Object.entries(KIND_PATTERNS)) {
+ if (patterns.some(p => lower.includes(p.toLowerCase()))) {
+ return Number(kind);
+ }
+ }
+
+ // KeyPackage-related
+ if (lower.includes('keypackage') || lower.includes('kind 443') || lower.includes('mls_')) {
+ return 10051;
+ }
+
+ return null;
+}
+
+/**
+ * Check if a prescription can be addressed by OR staging.
+ * @param {string} rx
+ * @returns {boolean}
+ */
+function isActionablePrescription(rx) {
+ const lower = rx.toLowerCase();
+ const actionablePatterns = [
+ 'fix invalid relay',
+ 'add relay tags',
+ 'unify',
+ 'add at least one read relay',
+ 'add at least one write relay',
+ 'reduce kind 10002',
+ 'publish a keypackage relay list',
+ 'publish an inbox relay list',
+ 'publish a dm inbox',
+ 'delete orphaned',
+ ];
+ return actionablePatterns.some(p => lower.includes(p));
+}
+
+/**
+ * Check if a prescription requires client-side action (not OR fixable).
+ * @param {string} rx
+ * @returns {boolean}
+ */
+function isClientSidePrescription(rx) {
+ const lower = rx.toLowerCase();
+ // NIP-04 migration is now actionable via deleteDeprecatedKind4
+ if (lower.includes('migrate from nip-04')) return false;
+ // 'delete events' removed β prescriptions matching that text are handled
+ // by the 'delete-kps' fix action and should not be classified as CLIENT-only.
+ const clientPatterns = [
+ 'encoding',
+ '0xf2ee',
+ '0x000a',
+ 'ciphersuite',
+ 'mls_extensions',
+ 'keypackagebundle',
+ 'keypackageref',
+ 'rotate mls',
+ 'default extensions',
+ 'stop publishing kind 2',
+ 'i tag',
+ ];
+ return clientPatterns.some(p => lower.includes(p));
+}
+
+/**
+ * Map a prescription to its fix action (data-action value for the FIX IT button).
+ * Returns null if no automatic fix is available.
+ * @param {string} rx
+ * @returns {string|null}
+ */
+function getFixActionForPrescription(rx) {
+ const lower = rx.toLowerCase();
+ if (lower.includes('rebroadcast') && lower.includes('profile')) return 'rebroadcast';
+ if (lower.includes('delete orphaned keypackages')) return 'delete-orphaned-kps';
+ if (lower.includes('unify your k3 and k10002')) return 'unify-relays';
+ if (lower.includes('migrate from nip-04') || (lower.includes('nip-04') && lower.includes('kind 4'))) return 'delete-kind4';
+ if (lower.includes('delete events') && lower.includes('keypackage')) return 'delete-kps';
+ return null;
+}
+
+/**
+ * Get a recommendation explanation for a prescription.
+ * These are the factual explanations; personality wrapper comes from speak().
+ * @param {string} rx
+ * @returns {string|null}
+ */
+function getRecommendationForPrescription(rx) {
+ const lower = rx.toLowerCase();
+
+ if (lower.includes('fix invalid relay') && lower.includes('k3 / k10002')) {
+ return 'Invalid relay URLs prevent clients from discovering where to find your events. Remove the broken URLs and add correct wss:// addresses.';
+ }
+ if (lower.includes('rebroadcast')) {
+ return 'Your profile or contacts are stale on some relays. Rebroadcasting pushes the latest version to all relays so clients see consistent data.';
+ }
+ if (lower.includes('publish a keypackage relay list') || (lower.includes('kind 10051') && lower.includes('publish'))) {
+ return 'Kind 10051 tells other users where to find your KeyPackages. Without it, nobody can invite you to Marmot groups.';
+ }
+ if (lower.includes('add relay tags') && lower.includes('10051')) {
+ return 'Your k10051 event exists but has no relay URLs. Add at least one relay where your KeyPackages are published.';
+ }
+ if (lower.includes('publish an inbox relay list') || lower.includes('publish a dm inbox') || (lower.includes('kind 10050') && lower.includes('publish'))) {
+ return 'Kind 10050 tells clients where to deliver giftwrapped messages to you. Without it, encrypted DMs and Marmot invites cannot reach you.';
+ }
+ if (lower.includes('add relay tags') && lower.includes('10050')) {
+ return 'Your k10050 event exists but has no relay URLs. Add at least one relay to receive giftwrapped messages.';
+ }
+ if (lower.includes('unify')) {
+ return 'Your Contacts (k3) and NIP-65 (k10002) relay lists advertise different relays. Different clients check different lists, so having them agree means better discoverability.';
+ }
+ if (lower.includes('no read relays')) {
+ return 'Without read relays in NIP-65, clients don\'t know where to deliver replies to you. At least one relay needs the read marker.';
+ }
+ if (lower.includes('no write relays')) {
+ return 'Without write relays in NIP-65, clients don\'t know where you publish events. At least one relay needs the write marker.';
+ }
+ if (lower.includes('excessive relay count') || lower.includes('reduce kind 10002')) {
+ return 'More than 10 relays in NIP-65 causes performance issues for clients. Each client must check all of them. Trim to your most reliable 5-8 relays.';
+ }
+ if (lower.includes('encoding')) {
+ return 'The KeyPackage encoding tag must be "base64". MIP-00 requires base64-encoded KeyPackageBundle content.';
+ }
+ if (lower.includes('0xf2ee')) {
+ return 'The marmot_group_data extension (0xf2ee) carries group metadata. Without it, Marmot clients cannot parse your KeyPackage.';
+ }
+ if (lower.includes('0x000a')) {
+ return 'The last_resort extension (0x000a) marks a KeyPackage as always available. MIP-00 requires this for the primary KeyPackage.';
+ }
+ if (lower.includes('ciphersuite')) {
+ return 'The mls_ciphersuite tag identifies which cryptographic algorithms your KeyPackage uses. It must be in the range 0x0001-0x0007.';
+ }
+ if (lower.includes('orphaned keypackages') || lower.includes('delete orphaned')) {
+ return 'KeyPackages on relays not listed in your k10051 are invisible to other users. Either add those relays to k10051 or delete the orphaned KeyPackages.';
+ }
+ if (lower.includes('blossom')) {
+ return 'A Blossom server list (k10063) tells clients where to upload media on your behalf. Without it, media attachments may not work.';
+ }
+ if (lower.includes('nip-04') || lower.includes('kind 4')) {
+ return 'NIP-04 (kind 4) DMs expose metadata (who you\'re talking to and when). NIP-17 and Marmot both encrypt metadata. Migrate for better privacy.';
+ }
+ if (lower.includes('kind 2')) {
+ return 'Kind 2 (Recommend Relay) is deprecated. Use NIP-65 (kind 10002) for relay advertisement instead.';
+ }
+ if (lower.includes('publish at least one keypackage')) {
+ return 'No KeyPackages found on your advertised relays. Your Marmot client needs to publish at least one KeyPackage (kind 443) so others can invite you to groups.';
+ }
+ if (lower.includes('fix i tag') || lower.includes('i tag')) {
+ return 'The i tag must contain a hex-encoded KeyPackageRef matching the ciphersuite hash length. It uniquely identifies the KeyPackage for deduplication.';
+ }
+ if (lower.includes('default extensions')) {
+ return 'Default MLS extensions (0x0001-0x0005) are implicit and must not be listed in mls_extensions. Only custom extensions belong there.';
+ }
+ if (lower.includes('relays tag')) {
+ return 'The relays tag in KeyPackage events tells clients where to deliver Welcome messages. It must contain valid wss:// URLs.';
+ }
+
+ return null;
+}
diff --git a/js/operate/health.js b/js/operate/health.js
new file mode 100644
index 0000000..9dbeada
--- /dev/null
+++ b/js/operate/health.js
@@ -0,0 +1,252 @@
+/**
+ * Relay health diagnostics.
+ * Tests: WebSocket connectivity + latency, NIP-11 info, read latency (REQβEOSE).
+ */
+
+import { fetchNip11, formatNip11Summary } from './nip11.js';
+
+const WS_CONNECT_TIMEOUT_MS = 8000;
+const READ_TEST_TIMEOUT_MS = 6000;
+
+/**
+ * @typedef {Object} HealthResult
+ * @property {string} url - Relay URL
+ * @property {'ok'|'warn'|'dead'} status - Overall health status
+ * @property {number|null} connectMs - WS connect latency (ms) or null if failed
+ * @property {number|null} readMs - REQβEOSE latency (ms) or null if not tested
+ * @property {Object|null} nip11 - NIP-11 info doc or null
+ * @property {string} nip11Summary - Short human-readable NIP-11 summary
+ * @property {string|null} error - Error message if connect failed
+ */
+
+/**
+ * Test WebSocket connectivity and measure connect latency.
+ * @param {string} relayUrl
+ * @returns {Promise<{ connectMs: number|null, error: string|null, ws: WebSocket|null }>}
+ */
+function testWsConnect(relayUrl) {
+ return new Promise((resolve) => {
+ const start = performance.now();
+ let resolved = false;
+ let timer = null;
+
+ const done = (result) => {
+ if (resolved) return;
+ resolved = true;
+ if (timer != null) clearTimeout(timer);
+ resolve(result);
+ };
+
+ let ws;
+ try {
+ ws = new WebSocket(relayUrl);
+ } catch (e) {
+ done({ connectMs: null, error: e?.message || 'WebSocket constructor failed', ws: null });
+ return;
+ }
+
+ timer = setTimeout(() => {
+ try { ws.close(); } catch { /* ignore */ }
+ done({ connectMs: null, error: 'timeout', ws: null });
+ }, WS_CONNECT_TIMEOUT_MS);
+
+ ws.onopen = () => {
+ const elapsed = Math.round(performance.now() - start);
+ done({ connectMs: elapsed, error: null, ws });
+ };
+
+ ws.onerror = () => {
+ try { ws.close(); } catch { /* ignore */ }
+ done({ connectMs: null, error: 'connection failed', ws: null });
+ };
+ });
+}
+
+/**
+ * Test read latency by sending a REQ and measuring time to EOSE.
+ * Uses a filter guaranteed to return zero events (random author).
+ * @param {WebSocket} ws - Open WebSocket connection
+ * @returns {Promise<{ readMs: number|null, error: string|null }>}
+ */
+function testReadLatency(ws) {
+ return new Promise((resolve) => {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ resolve({ readMs: null, error: 'not connected' });
+ return;
+ }
+
+ const subId = '_health_' + Math.random().toString(36).slice(2, 8);
+ const start = performance.now();
+ let resolved = false;
+
+ const done = (result) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timer);
+ ws.removeEventListener('message', onMessage);
+ // Send CLOSE for the subscription
+ try { ws.send(JSON.stringify(['CLOSE', subId])); } catch { /* ignore */ }
+ resolve(result);
+ };
+
+ const timer = setTimeout(() => {
+ done({ readMs: null, error: 'timeout' });
+ }, READ_TEST_TIMEOUT_MS);
+
+ const onMessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (Array.isArray(msg) && msg[1] === subId && msg[0] === 'EOSE') {
+ const elapsed = Math.round(performance.now() - start);
+ done({ readMs: elapsed, error: null });
+ }
+ } catch { /* ignore parse errors */ }
+ };
+
+ ws.addEventListener('message', onMessage);
+
+ // Random hex author that won't match anything
+ const fakeAuthor = 'ff'.repeat(32);
+ const req = JSON.stringify(['REQ', subId, { authors: [fakeAuthor], kinds: [0], limit: 1 }]);
+ try {
+ ws.send(req);
+ } catch (e) {
+ done({ readMs: null, error: e?.message || 'send failed' });
+ }
+ });
+}
+
+/**
+ * Run full health diagnostics on a single relay.
+ * @param {string} relayUrl
+ * @param {function} [onProgress] - Optional callback: (phase: string) => void
+ * @returns {Promise}
+ */
+export async function checkRelayHealth(relayUrl, onProgress) {
+ const result = {
+ url: relayUrl,
+ status: 'dead',
+ connectMs: null,
+ readMs: null,
+ nip11: null,
+ nip11Summary: '',
+ error: null,
+ };
+
+ // Phase 1: WS connect + NIP-11 in parallel
+ onProgress?.('connecting');
+ const [wsResult, nip11Result] = await Promise.all([
+ testWsConnect(relayUrl),
+ fetchNip11(relayUrl),
+ ]);
+
+ result.nip11 = nip11Result;
+ result.nip11Summary = formatNip11Summary(nip11Result);
+
+ if (wsResult.error) {
+ result.error = wsResult.error;
+ result.status = 'dead';
+ return result;
+ }
+
+ result.connectMs = wsResult.connectMs;
+
+ // Phase 2: Read latency test
+ onProgress?.('testing');
+ const readResult = await testReadLatency(wsResult.ws);
+ result.readMs = readResult.readMs;
+
+ // Clean up WebSocket
+ try { wsResult.ws.close(); } catch { /* ignore */ }
+
+ // Determine status
+ const totalLatency = (result.connectMs || 0) + (result.readMs || 0);
+ if (result.connectMs === null) {
+ result.status = 'dead';
+ } else if (totalLatency > 3000 || result.readMs === null) {
+ result.status = 'warn';
+ } else if (totalLatency > 1500) {
+ result.status = 'warn';
+ } else {
+ result.status = 'ok';
+ }
+
+ return result;
+}
+
+/**
+ * Run health checks on multiple relays with progress callbacks.
+ * @param {string[]} relayUrls
+ * @param {function} [onRelayProgress] - (url: string, phase: string) => void
+ * @param {function} [onRelayDone] - (result: HealthResult) => void
+ * @returns {Promise