}
+ */
+export async function query(userMessage) {
+ if (_status === 'ready' && engine) {
+ const reply = await _queryWebLLM(userMessage);
+ if (reply) return reply;
+ }
+
+ // Ollama — complements (not replaces) the in-browser model
+ return _queryOllama(userMessage);
+}
+
+// ─── internal backends ───────────────────────────────────────────────────────
+
+async function _queryWebLLM(userMessage) {
+ try {
+ const result = await engine.chat.completions.create({
+ messages: [
+ {
+ role: 'system',
+ content:
+ 'You are Timmy — a sovereign AI living in the Nexus, a luminous 3D space. ' +
+ 'Reply with warmth and cosmic brevity. One or two sentences only.',
+ },
+ { role: 'user', content: userMessage },
+ ],
+ max_tokens: 80,
+ temperature: 0.7,
+ });
+ return result.choices[0]?.message?.content?.trim() || null;
+ } catch (err) {
+ console.warn('[Edge] WebLLM query error:', err.message);
+ return null;
+ }
+}
+
+async function _queryOllama(userMessage) {
+ try {
+ const resp = await fetch(OLLAMA_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal: AbortSignal.timeout(8000),
+ body: JSON.stringify({
+ model: OLLAMA_MODEL,
+ prompt:
+ 'You are Timmy — a sovereign AI living in the Nexus. ' +
+ 'Reply with warmth and cosmic brevity. One or two sentences.\n\n' +
+ `User: ${userMessage}\nTimmy:`,
+ stream: false,
+ }),
+ });
+ if (!resp.ok) return null;
+ const data = await resp.json();
+ return data.response?.trim() || null;
+ } catch {
+ return null;
+ }
+}
diff --git a/index.html b/index.html
index dd4d42d..803befb 100644
--- a/index.html
+++ b/index.html
@@ -101,6 +101,14 @@
+
+
+
+
+
+
WASD move Mouse look Enter chat
diff --git a/nostr-identity.js b/nostr-identity.js
new file mode 100644
index 0000000..b9f7e6d
--- /dev/null
+++ b/nostr-identity.js
@@ -0,0 +1,143 @@
+// ═══════════════════════════════════════════
+// NOSTR IDENTITY — Silent Signing, No Extension Popup
+// ═══════════════════════════════════════════
+// Generates a secp256k1 keypair on first visit and persists it in localStorage.
+// Signs NIP-01 events locally — no window.nostr extension required.
+// If window.nostr (NIP-07) is present it is preferred; our key acts as fallback.
+
+const NOBLE_SECP_CDN = 'https://cdn.jsdelivr.net/npm/@noble/secp256k1@2.1.0/+esm';
+const LS_KEY = 'nexus-nostr-privkey';
+
+// ─── module state ────────────────────────────────────────────────────────────
+let _privKeyBytes = null; // Uint8Array (32 bytes)
+let _pubKeyHex = null; // hex string (32 bytes / x-only)
+let _usingNip07 = false;
+let _secp = null; // lazy-loaded @noble/secp256k1
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
+function hexToBytes(hex) {
+ const b = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
+ return b;
+}
+
+function bytesToHex(bytes) {
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
+}
+
+async function sha256Hex(data) {
+ const buf = await crypto.subtle.digest('SHA-256', data);
+ return bytesToHex(new Uint8Array(buf));
+}
+
+async function loadSecp() {
+ if (!_secp) _secp = await import(NOBLE_SECP_CDN);
+ return _secp;
+}
+
+/** Serialize a Nostr event to canonical JSON bytes for ID hashing (NIP-01). */
+function serializeEvent(ev) {
+ const arr = [0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content];
+ return new TextEncoder().encode(JSON.stringify(arr));
+}
+
+// ─── public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Initialise the identity.
+ * Loads or generates a keypair; checks for NIP-07 extension.
+ * @returns {{ pubKey: string, npub: string, source: 'nip07'|'local' }}
+ */
+export async function init() {
+ const secp = await loadSecp();
+
+ // Load or generate local keypair
+ const stored = localStorage.getItem(LS_KEY);
+ if (stored && /^[0-9a-f]{64}$/i.test(stored)) {
+ _privKeyBytes = hexToBytes(stored);
+ } else {
+ _privKeyBytes = secp.utils.randomPrivateKey();
+ localStorage.setItem(LS_KEY, bytesToHex(_privKeyBytes));
+ }
+
+ // x-only Schnorr public key (32 bytes)
+ _pubKeyHex = bytesToHex(secp.schnorr.getPublicKey(_privKeyBytes));
+
+ let source = 'local';
+
+ // Prefer NIP-07 extension when available (no popup needed for getPublicKey)
+ if (window.nostr) {
+ try {
+ const ext = await window.nostr.getPublicKey();
+ if (ext && /^[0-9a-f]{64}$/i.test(ext)) {
+ _pubKeyHex = ext;
+ _usingNip07 = true;
+ source = 'nip07';
+ }
+ } catch {
+ // Extension rejected or errored — fall back to local key
+ }
+ }
+
+ return { pubKey: _pubKeyHex, npub: getNpub(), source };
+}
+
+/** Hex public key (x-only / 32 bytes). Null until init() resolves. */
+export function getPublicKey() { return _pubKeyHex; }
+
+/**
+ * Human-readable abbreviated npub for HUD display.
+ * We show a truncated hex with an npub… prefix to avoid a full bech32 dep.
+ */
+export function getNpub() {
+ if (!_pubKeyHex) return null;
+ return 'npub…' + _pubKeyHex.slice(-8);
+}
+
+/**
+ * Sign a Nostr event (NIP-01).
+ * Fills in id and sig. Uses NIP-07 extension if it was detected at init time,
+ * otherwise signs locally with our generated key — zero popups either way.
+ *
+ * @param {{ kind?: number, tags?: string[][], content: string, created_at?: number }} partial
+ * @returns {Promise