diff --git a/the-matrix/js/edge-worker-client.js b/the-matrix/js/edge-worker-client.js index 5338229..eaa680f 100644 --- a/the-matrix/js/edge-worker-client.js +++ b/the-matrix/js/edge-worker-client.js @@ -2,13 +2,24 @@ * edge-worker-client.js — Main-thread proxy for the edge-worker Web Worker. * * Spawns js/edge-worker.js as a module Worker and exposes: - * classify(text) → Promise<{ label:'local'|'server', score, reason, localReply? }> + * classify(text) → Promise<{ + * complexity: 'trivial'|'moderate'|'complex', + * score: number, + * reason: string, + * localReply?: string // only when complexity === 'trivial' + * }> * sentiment(text) → Promise<{ label:'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }> * onReady(fn) → register a callback fired when models finish loading * isReady() → boolean — true once both models are warm + * warmup() → start the worker early so first classify() is fast * - * If Web Workers are unavailable (SSR / old browser), all calls fall back to - * the naive "route to server" path so the app remains functional. + * Complexity tiers (set by the worker): + * trivial — greeting/small-talk; answered locally, 0 sats, no server call + * moderate — simple question; show cost preview, route to server + * complex — technical/creative/code; always priced, show cost preview + * + * If Web Workers are unavailable (SSR / old browser), all calls fall back + * gracefully: classify → { complexity:'moderate', ... } so the app still works. */ let _worker = null; @@ -61,7 +72,8 @@ function _init() { function _fallback(type) { if (type === 'sentiment') return { label: 'NEUTRAL', score: 0.5 }; - return { label: 'server', score: 0, reason: 'worker-unavailable' }; + // classify fallback: moderate keeps the UI functional (shows estimate, routes to server) + return { complexity: 'moderate', score: 0, reason: 'worker-unavailable' }; } function _send(type, text) { diff --git a/the-matrix/js/edge-worker.js b/the-matrix/js/edge-worker.js index fd5e178..e05ce2c 100644 --- a/the-matrix/js/edge-worker.js +++ b/the-matrix/js/edge-worker.js @@ -29,10 +29,17 @@ import { pipeline, env } from '@xenova/transformers'; env.useBrowserCache = true; // cache model weights via browser Cache API env.allowLocalModels = false; // no filesystem — browser-only environment -const LOCAL_LABELS = ['greeting', 'small-talk', 'simple-question']; -const SERVER_LABELS = ['technical-task', 'creative-work', 'complex-question', 'code-request']; -const ALL_LABELS = [...LOCAL_LABELS, ...SERVER_LABELS]; -const LOCAL_THRESHOLD = 0.55; +// Classification labels → complexity tier mapping +// trivial — handled locally, no server call, no sats +// moderate — real request but may be free-tier; show cost preview before send +// complex — substantive work; always priced; show cost preview before send +const TRIVIAL_LABELS = ['greeting', 'small-talk']; +const MODERATE_LABELS = ['simple-question']; +const COMPLEX_LABELS = ['technical-task', 'creative-work', 'complex-question', 'code-request']; +const ALL_LABELS = [...TRIVIAL_LABELS, ...MODERATE_LABELS, ...COMPLEX_LABELS]; + +const TRIVIAL_THRESHOLD = 0.55; // minimum score to call trivial "trivial" +const MODERATE_THRESHOLD = 0.40; // below this → upgrade to complex (model is uncertain) const LOCAL_REPLIES = [ "Greetings, traveller! Ask me something arcane and I shall conjure wisdom from the ether.", @@ -64,31 +71,55 @@ async function _loadModels() { // ── Handlers ────────────────────────────────────────────────────────────────── +/** + * _classify — returns the complexity tier of the input text. + * + * Returns: + * { + * complexity: 'trivial' | 'moderate' | 'complex', + * score: number, // top-label confidence + * reason: string, // winning label name or heuristic name + * localReply?: string, // only present when complexity === 'trivial' + * } + * + * Complexity tiers: + * trivial — greeting/small-talk with high confidence; answered locally, 0 sats + * moderate — simple question or uncertain classification; show cost preview, route to server + * complex — technical / creative / code work; always priced, show cost preview + */ async function _classify(text) { const trimmed = text.trim(); + // Fast path: single-word/short greetings are trivially local if (_isGreeting(trimmed)) { - return { label: 'local', score: 0.99, reason: 'greeting-heuristic', localReply: _randomReply() }; + return { complexity: 'trivial', score: 0.99, reason: 'greeting-heuristic', localReply: _randomReply() }; } + // If model hasn't loaded yet, default to moderate (show estimate, let server decide) if (!_classifier) { - return { label: 'server', score: 0, reason: 'model-unavailable' }; + return { complexity: 'moderate', score: 0, reason: 'model-unavailable' }; } try { - const result = await _classifier(trimmed, ALL_LABELS, { multi_label: false }); + const result = await _classifier(trimmed, ALL_LABELS, { multi_label: false }); const topLabel = result.labels[0]; const topScore = result.scores[0]; - const isLocal = LOCAL_LABELS.includes(topLabel) && topScore >= LOCAL_THRESHOLD; - return { - label: isLocal ? 'local' : 'server', - score: topScore, - reason: topLabel, - ...(isLocal ? { localReply: _randomReply() } : {}), - }; + if (TRIVIAL_LABELS.includes(topLabel) && topScore >= TRIVIAL_THRESHOLD) { + // High-confidence trivial: small-talk or greeting → answer locally + return { complexity: 'trivial', score: topScore, reason: topLabel, localReply: _randomReply() }; + } + + if (COMPLEX_LABELS.includes(topLabel) || topScore < MODERATE_THRESHOLD) { + // Explicitly complex label, or model is uncertain (score too low to trust) → complex + return { complexity: 'complex', score: topScore, reason: topLabel }; + } + + // Middle ground: simple-question or trivial label with moderate confidence → moderate + return { complexity: 'moderate', score: topScore, reason: topLabel }; + } catch (err) { - return { label: 'server', score: 0, reason: 'classify-error', error: String(err) }; + return { complexity: 'moderate', score: 0, reason: 'classify-error', error: String(err) }; } } diff --git a/the-matrix/js/nostr-identity.js b/the-matrix/js/nostr-identity.js index a71291e..b17b78a 100644 --- a/the-matrix/js/nostr-identity.js +++ b/the-matrix/js/nostr-identity.js @@ -30,6 +30,7 @@ let _pubkey = null; // hex let _token = null; // nostr_token string from server let _tokenExp = 0; // unix ms when token was fetched let _useNip07 = false; // true if window.nostr is available +let _canSign = false; // true if we have a signing capability (NIP-07 or local privkey) let _identityPromptShown = false; // ── Init ────────────────────────────────────────────────────────────────────── @@ -40,6 +41,7 @@ export async function initNostrIdentity(apiBase = '/api') { try { _pubkey = await window.nostr.getPublicKey(); _useNip07 = true; + _canSign = true; console.info('[nostr] Using NIP-07 extension, pubkey:', _pubkey.slice(0, 8) + '…'); } catch (err) { console.warn('[nostr] NIP-07 getPublicKey failed, will use local keypair', err); @@ -47,24 +49,33 @@ export async function initNostrIdentity(apiBase = '/api') { } } - // Try restoring an existing local keypair (consented previously) + // Try restoring / discovering an existing keypair if (!_pubkey) { _pubkey = _loadKeypair(); if (_pubkey) { - console.info('[nostr] Restored local keypair, pubkey:', _pubkey.slice(0, 8) + '…'); + // Check if we actually have a privkey for signing (npub-only discovery gives pubkey but no privkey) + _canSign = !!_getPrivkeyBytes(); + if (_canSign) { + console.info('[nostr] Restored local keypair (with signing), pubkey:', _pubkey.slice(0, 8) + '…'); + } else { + console.info('[nostr] Discovered pubkey (view-only, no privkey), pubkey:', _pubkey.slice(0, 8) + '…'); + } } } // Try to restore cached token _loadCachedToken(); - // If we have a pubkey but no valid token, authenticate now - if (_pubkey && !_isTokenValid()) { + // If we have signing capability and no valid token, authenticate now + if (_pubkey && _canSign && !_isTokenValid()) { await refreshToken(apiBase); } - // If no identity at all, show the opt-in prompt so the user can choose - if (!_pubkey) { + // Show the opt-in prompt if: + // a) No identity at all — user can generate a keypair or connect NIP-07 + // b) Have a pubkey but no signing capability (npub-only discovery) — + // user should be offered a way to add signing (generate new key or NIP-07) + if (!_pubkey || !_canSign) { _scheduleIdentityPrompt(apiBase); } } @@ -185,12 +196,14 @@ export function showIdentityPrompt(apiBase = '/api') { try { _pubkey = await window.nostr.getPublicKey(); _useNip07 = true; + _canSign = true; } catch { return; } } else { // Generate + store keypair (user consented by clicking) - _pubkey = _generateAndSaveKeypair(); + _pubkey = _generateAndSaveKeypair(); + _canSign = true; } - if (_pubkey) { + if (_pubkey && _canSign) { await refreshToken(apiBase); _updateIdentityHUD(); } diff --git a/the-matrix/js/session.js b/the-matrix/js/session.js index 29c2cd3..bfaedf7 100644 --- a/the-matrix/js/session.js +++ b/the-matrix/js/session.js @@ -278,7 +278,9 @@ function _startDepositPolling() { async function poll() { if (!_sessionId) return; try { - const res = await fetch(`${API}/sessions/${_sessionId}`); + const nostrToken = await getOrRefreshToken('/api'); + const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; + const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders }); const data = await res.json(); if (data.state === 'active') { @@ -322,12 +324,16 @@ async function _createTopup() { _btn('session-topup-create-btn', true); try { + const nostrToken = await getOrRefreshToken('/api'); + const topupHeaders = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${_macaroon}`, + }; + if (nostrToken) topupHeaders['X-Nostr-Token'] = nostrToken; + const res = await fetch(`${API}/sessions/${_sessionId}/topup`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${_macaroon}`, - }, + headers: topupHeaders, body: JSON.stringify({ amount_sats: _topupSats }), }); const data = await res.json(); @@ -388,7 +394,9 @@ function _startTopupPolling() { async function poll() { if (!_sessionId) return; try { - const res = await fetch(`${API}/sessions/${_sessionId}`); + const nostrToken = await getOrRefreshToken('/api'); + const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; + const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders }); const data = await res.json(); if (data.balanceSats > prevBalance || data.state === 'active') { @@ -421,7 +429,9 @@ async function _tryRestore() { if (!sessionId || !macaroon) return; // Validate the session is still live - const res = await fetch(`${API}/sessions/${sessionId}`); + const nostrToken = await getOrRefreshToken('/api'); + const restoreHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; + const res = await fetch(`${API}/sessions/${sessionId}`, { headers: restoreHeaders }); if (!res.ok) { localStorage.removeItem(LS_KEY); return; } const data = await res.json(); diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index c824102..dc5c700 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -150,10 +150,11 @@ function initInputBar() { } // ── Edge triage — classify text in the Web Worker ───────────────────────── + // Worker returns { complexity: 'trivial'|'moderate'|'complex', score, reason, localReply? } const cls = await classify(text); - if (cls.label === 'local' && cls.localReply) { - // Trivial/conversational — answer locally, no server round-trip + if (cls.complexity === 'trivial' && cls.localReply) { + // Greeting / small-talk → answer locally, 0 sats, no network call appendSystemMessage(`you: ${text}`); appendSystemMessage(`Timmy [local]: ${cls.localReply}`); _showCostPreview('answered locally ⚡ 0 sats', '#44dd88'); @@ -161,7 +162,15 @@ function initInputBar() { return; } - // Substantive — route to server via WebSocket + // moderate or complex — fetch cost estimate (driven by complexity outcome), + // then route to server. The preview is already shown via debounce on input, + // but we refresh it now with the actual classification context. + if (cls.complexity === 'moderate' || cls.complexity === 'complex') { + // Fire estimate fetch; don't await — let it update the badge async while WS is in flight + _fetchEstimate(text); + } + + // Route to server via WebSocket sendVisitorMessage(text); appendSystemMessage(`you: ${text}`); }