/ index.html
index.html
   1  <!DOCTYPE html>
   2  <html lang="en">
   3  <head>
   4  <meta charset="UTF-8">
   5  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   6  <title>AURYN</title>
   7  <style>
   8    @font-face {
   9      font-family: 'TeX Gyre Termes';
  10      src: url('fonts/texgyretermes-regular.otf') format('opentype');
  11      font-weight: normal;
  12      font-style: normal;
  13    }
  14    * { margin: 0; padding: 0; box-sizing: border-box; }
  15    body {
  16      background: #000; color: #e0e0e0;
  17      font-family: 'TeX Gyre Termes', Georgia, serif;
  18      width: 100vw; height: 100vh;
  19      display: flex; flex-direction: column;
  20      overflow: hidden;
  21    }
  22  
  23    /* ===== CONTEXT ZONE (upper half) ===== */
  24    #context-zone {
  25      height: 40vh; min-height: 160px;
  26      display: flex; align-items: center; justify-content: center;
  27      position: relative; overflow: hidden;
  28      flex-shrink: 0;
  29    }
  30  
  31    #auryn-symbol {
  32      width: 120px; height: 120px;
  33      border-radius: 50%; overflow: hidden;
  34      cursor: pointer; position: relative;
  35      z-index: 2; flex-shrink: 0;
  36      border: 2px solid rgba(255, 215, 0, 0.3);
  37      transition: border-color 0.3s, transform 0.2s;
  38    }
  39    #auryn-symbol:hover {
  40      border-color: rgba(255, 215, 0, 0.7);
  41      transform: scale(1.05);
  42    }
  43    #auryn-symbol img {
  44      width: 100%; height: 100%; object-fit: cover;
  45    }
  46  
  47    /* Petals container — positioned around the AURYN symbol */
  48    #petals-ring {
  49      position: absolute; top: 50%; left: 50%;
  50      width: 0; height: 0;
  51      z-index: 1;
  52    }
  53  
  54    .petal {
  55      position: absolute;
  56      width: 52px; height: 52px;
  57      border-radius: 50%; overflow: hidden;
  58      cursor: pointer;
  59      transition: transform 0.3s ease, opacity 0.3s ease;
  60      border: 2px solid rgba(100, 149, 237, 0.6);
  61    }
  62    .petal.dreamer { border-color: rgba(205, 92, 92, 0.6); }
  63    .petal:hover { transform: scale(1.15) !important; }
  64    .petal img {
  65      width: 100%; height: 100%; object-fit: cover;
  66    }
  67    .petal .petal-remove {
  68      position: absolute; top: -4px; right: -4px;
  69      width: 16px; height: 16px; border-radius: 50%;
  70      background: #333; color: #aaa; border: none;
  71      font-size: 10px; cursor: pointer;
  72      display: none; align-items: center; justify-content: center;
  73      line-height: 1;
  74    }
  75    .petal:hover .petal-remove { display: flex; }
  76    .petal .petal-label {
  77      position: absolute; bottom: -18px; left: 50%;
  78      transform: translateX(-50%);
  79      font-size: 9px; color: #555;
  80      white-space: nowrap; max-width: 80px;
  81      overflow: hidden; text-overflow: ellipsis;
  82      font-family: -apple-system, sans-serif;
  83      pointer-events: none; opacity: 0;
  84      transition: opacity 0.2s;
  85    }
  86    .petal:hover .petal-label { opacity: 1; }
  87  
  88    /* No-image petal fallback */
  89    .petal .petal-initials {
  90      width: 100%; height: 100%;
  91      display: flex; align-items: center; justify-content: center;
  92      background: #1a1a1a; color: #888;
  93      font-size: 14px; font-weight: bold;
  94      font-family: -apple-system, sans-serif;
  95    }
  96  
  97    /* ===== CHAT ZONE (lower half) ===== */
  98    #chat-zone {
  99      flex: 1; display: flex; flex-direction: column;
 100      min-height: 0; /* allow flex shrink */
 101      border-top: 1px solid #111;
 102    }
 103  
 104    #messages {
 105      flex: 1; overflow-y: auto; padding: 16px 16px;
 106      display: flex; flex-direction: column; gap: 16px;
 107      scrollbar-width: thin; scrollbar-color: #333 #000;
 108    }
 109    #messages::-webkit-scrollbar { width: 6px; }
 110    #messages::-webkit-scrollbar-track { background: #000; }
 111    #messages::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
 112  
 113    .msg { border-radius: 12px; line-height: 1.6; }
 114    .msg.user {
 115      align-self: flex-end; max-width: 85%; background: #1a1a1a; color: #ccc;
 116      border: 1px solid #333; font-size: 16px; padding: 10px 16px;
 117    }
 118    .msg.assistant {
 119      align-self: flex-start; width: 100%; position: relative; padding: 0;
 120    }
 121    .msg.transcript {
 122      align-self: flex-start; width: 100%; position: relative; padding: 0;
 123    }
 124  
 125    /* The rendered markdown text layer */
 126    .msg.assistant .text-layer, .msg.transcript .text-layer {
 127      font-family: 'TeX Gyre Termes', Georgia, serif;
 128      font-size: 20px; color: #fff; line-height: 1.6;
 129      word-wrap: break-word;
 130    }
 131    .msg.transcript .text-layer { color: #b0b0b0; }
 132    .msg.assistant .text-layer p { margin-bottom: 0.6em; }
 133    .msg.assistant .text-layer p:last-child { margin-bottom: 0; }
 134    .msg.assistant .text-layer strong { font-weight: bold; }
 135    .msg.assistant .text-layer em { font-style: italic; }
 136    .msg.assistant .text-layer code {
 137      background: #1a1a1a; padding: 1px 5px; border-radius: 3px;
 138      font-family: monospace; font-size: 0.9em;
 139    }
 140    .msg.assistant .text-layer .list-block {
 141      margin: 0.3em 0;
 142    }
 143    .msg.assistant .text-layer .list-item {
 144      padding-left: 0;
 145    }
 146  
 147    /* SVG animation overlay — positioned over the text layer */
 148    .msg.assistant .svg-overlay, .msg.transcript .svg-overlay {
 149      position: absolute; top: 0; left: 0; width: 100%; height: 100%;
 150      pointer-events: none; z-index: 1;
 151    }
 152    .msg.assistant .svg-overlay svg, .msg.transcript .svg-overlay svg {
 153      width: 100%; height: 100%; display: block;
 154    }
 155  
 156    .streaming-cursor {
 157      display: inline-block; width: 2px; height: 1em;
 158      background: #FFD700; margin-left: 2px;
 159      animation: blink 0.8s step-end infinite;
 160      vertical-align: text-bottom;
 161    }
 162    @keyframes blink { 50% { opacity: 0; } }
 163  
 164    /* Write animation keyframes */
 165    @keyframes stroke-draw { to { stroke-dashoffset: 0; } }
 166    @keyframes fill-reveal { to { fill-opacity: 1; } }
 167    @keyframes stroke-fade { to { stroke-opacity: 0; } }
 168    @keyframes overlay-fadeout { to { opacity: 0; } }
 169  
 170    .char-path-stroke {
 171      fill: transparent; stroke: #fff; stroke-width: 1.5;
 172      fill-opacity: 0; stroke-opacity: 1;
 173    }
 174    .char-path-fill {
 175      fill: #fff; fill-opacity: 0; stroke: none;
 176    }
 177    /* Transcript uses muted color */
 178    .msg.transcript .char-path-stroke { stroke: #b0b0b0; }
 179    .msg.transcript .char-path-fill { fill: #b0b0b0; }
 180  
 181    /* ===== INPUT AREA ===== */
 182    #input-area {
 183      padding: 12px 16px; border-top: 1px solid #1a1a1a;
 184      display: flex; gap: 10px; align-items: flex-end;
 185      background: #000; position: relative;
 186    }
 187    #input-area textarea {
 188      flex: 1; background: #111; border: 1px solid #333;
 189      border-radius: 8px; color: #e0e0e0; padding: 10px 14px;
 190      font-family: inherit; font-size: 16px; resize: none;
 191      outline: none; min-height: 42px; max-height: 40vh;
 192      line-height: 1.4;
 193    }
 194    #input-area textarea:focus { border-color: #555; }
 195    #input-area textarea::placeholder { color: #444; }
 196  
 197    #send-btn, #stop-btn, #mic-btn {
 198      width: 42px; height: 42px; border-radius: 50%; border: none;
 199      cursor: pointer; display: flex; align-items: center;
 200      justify-content: center; flex-shrink: 0;
 201    }
 202    #send-btn { background: #FFD700; color: #000; }
 203    #send-btn.garden-mode { background: #2d5a27; color: #7fff7f; }
 204    #send-btn:disabled { background: #333; color: #666; cursor: default; }
 205    #send-btn svg { width: 18px; height: 18px; }
 206    #stop-btn { background: #8B0000; color: #fff; display: none; }
 207    #stop-btn svg { width: 16px; height: 16px; }
 208    #mic-btn { background: #222; color: #888; }
 209    #mic-btn:disabled { opacity: 0.3; cursor: default; }
 210    #mic-btn svg { width: 18px; height: 18px; }
 211    #mic-btn.recording { background: #8B0000; color: #fff; animation: pulse 1.5s infinite; }
 212    @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.08); } }
 213  
 214    /* ===== AUTOCOMPLETE DROPDOWN ===== */
 215    #autocomplete {
 216      display: none; position: absolute;
 217      bottom: 100%; left: 16px; right: 68px;
 218      max-height: 240px; overflow-y: auto;
 219      background: #111; border: 1px solid #333;
 220      border-radius: 8px 8px 0 0;
 221      z-index: 50;
 222      scrollbar-width: thin; scrollbar-color: #333 #111;
 223    }
 224    #autocomplete::-webkit-scrollbar { width: 4px; }
 225    #autocomplete::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
 226  
 227    .ac-item {
 228      padding: 8px 12px; cursor: pointer;
 229      display: flex; align-items: center; gap: 10px;
 230      border-bottom: 1px solid #1a1a1a;
 231      transition: background 0.1s;
 232    }
 233    .ac-item:last-child { border-bottom: none; }
 234    .ac-item:hover, .ac-item.selected { background: #1a1a1a; }
 235    .ac-item .ac-thumb {
 236      width: 28px; height: 28px; border-radius: 50%;
 237      overflow: hidden; flex-shrink: 0;
 238      border: 1.5px solid rgba(100, 149, 237, 0.5);
 239    }
 240    .ac-item .ac-thumb.dreamer { border-color: rgba(205, 92, 92, 0.5); }
 241    .ac-item .ac-thumb img { width: 100%; height: 100%; object-fit: cover; }
 242    .ac-item .ac-thumb .ac-initials {
 243      width: 100%; height: 100%;
 244      display: flex; align-items: center; justify-content: center;
 245      background: #1a1a1a; color: #555; font-size: 10px;
 246      font-family: -apple-system, sans-serif;
 247    }
 248    .ac-item .ac-title {
 249      color: #ccc; font-size: 14px;
 250      font-family: -apple-system, sans-serif;
 251      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
 252    }
 253    .ac-item .ac-type {
 254      margin-left: auto; font-size: 10px; color: #444;
 255      font-family: -apple-system, sans-serif;
 256      flex-shrink: 0;
 257    }
 258  
 259    #fallback-msg {
 260      display: none; position: absolute; top: 50%; left: 50%;
 261      transform: translate(-50%, -50%); text-align: center;
 262      color: #444; font-size: 16px; line-height: 1.6;
 263    }
 264    #fallback-msg .accent { color: #FFD700; }
 265  
 266    /* Drag-and-drop overlay */
 267    #drop-overlay {
 268      display: none; position: fixed; inset: 0;
 269      background: rgba(0,0,0,0.8); z-index: 100;
 270      border: 3px dashed #FFD700;
 271      flex-direction: column; align-items: center; justify-content: center;
 272      color: #FFD700; font-size: 20px;
 273    }
 274    #drop-overlay.active { display: flex; }
 275  
 276    /* Transcript label */
 277    .transcript-label {
 278      font-size: 11px; color: #555; margin-bottom: 4px;
 279      font-family: -apple-system, sans-serif;
 280    }
 281  
 282    /* Connection status indicator */
 283    #connection-status {
 284      display: none; padding: 4px 16px; background: #0a0a0a;
 285      border-bottom: 1px solid #1a1a1a; font-size: 11px;
 286      font-family: -apple-system, sans-serif; color: #555;
 287    }
 288    #connection-status.connected { color: #4a7a4a; }
 289    #connection-status.disconnected { color: #7a4a4a; }
 290    #reload-btn {
 291      position: fixed; top: 4px; right: 8px; z-index: 100;
 292      background: none; border: 1px solid #333; border-radius: 4px;
 293      color: #555; font-size: 10px; padding: 2px 8px; cursor: pointer;
 294    }
 295    #reload-btn:hover { color: #aaa; border-color: #555; }
 296  
 297    /* Vocab debug panel */
 298    #vocab-debug {
 299      display: none; padding: 6px 16px; background: #0a0a0a;
 300      border-top: 1px solid #1a1a1a; font-size: 10px; line-height: 1.5;
 301      font-family: -apple-system, sans-serif; color: #555;
 302      max-height: 80px; overflow-y: auto;
 303    }
 304    #vocab-debug.active { display: block; }
 305    #vocab-debug .vocab-label { color: #666; margin-right: 4px; }
 306    #vocab-debug .vocab-core { color: #555; }
 307    #vocab-debug .vocab-pinned { color: #FFD700; }
 308    #vocab-debug .vocab-ephemeral { color: #4a7a9a; }
 309  </style>
 310  </head>
 311  <body>
 312  <div id="connection-status"></div>
 313  <button id="reload-btn" onclick="reloadServer()" title="Restart AURYN server">reload</button>
 314  
 315  <!-- CONTEXT ZONE: AURYN symbol + context petals -->
 316  <div id="context-zone">
 317    <div id="petals-ring"></div>
 318    <div id="auryn-symbol" title="Select AURYN in dreamspace">
 319      <img src="AURYN.jpg" alt="AURYN">
 320    </div>
 321  </div>
 322  
 323  <!-- CHAT ZONE: messages + input -->
 324  <div id="chat-zone">
 325    <div id="messages"></div>
 326    <div id="input-area">
 327      <div id="autocomplete"></div>
 328      <textarea id="input" rows="1" placeholder="Talk to AURYN... (@ to reference DreamNodes)" autofocus></textarea>
 329      <button id="mic-btn" title="Record voice">
 330        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
 331      </button>
 332      <button id="send-btn" title="Send">
 333        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
 334      </button>
 335      <button id="stop-btn" title="Stop">
 336        <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
 337      </button>
 338    </div>
 339  </div>
 340  
 341  <div id="vocab-debug"></div>
 342  <div id="drop-overlay">Drop audio file to transcribe</div>
 343  <div id="fallback-msg">
 344    Open in the <span class="accent">InterBrain</span> to chat with AURYN
 345  </div>
 346  <!-- Hidden link so html-loader resolves the font path to an app:// URL -->
 347  <link id="font-resource" href="fonts/texgyretermes-regular.otf" rel="preload" as="font" type="font/otf" crossorigin style="display:none">
 348  
 349  <script src="https://cdn.jsdelivr.net/npm/opentype.js@1.3.4/dist/opentype.min.js"></script>
 350  <script>
 351  // ============================================================
 352  // 1. STATE
 353  // ============================================================
 354  const messagesEl = document.getElementById('messages');
 355  const inputEl = document.getElementById('input');
 356  const sendBtn = document.getElementById('send-btn');
 357  const stopBtn = document.getElementById('stop-btn');
 358  const micBtn = document.getElementById('mic-btn');
 359  const fallbackEl = document.getElementById('fallback-msg');
 360  const dropOverlay = document.getElementById('drop-overlay');
 361  const connectionStatus = document.getElementById('connection-status');
 362  const autocompleteEl = document.getElementById('autocomplete');
 363  const petalsRing = document.getElementById('petals-ring');
 364  const aurynSymbol = document.getElementById('auryn-symbol');
 365  
 366  let conversation = [];
 367  let isStreaming = false;
 368  let currentRequestId = null;
 369  let font = null;
 370  let fontLoaded = false;
 371  let aiBridgeReady = false;
 372  let standaloneMode = false;
 373  
 374  // Transcription state
 375  let isRecording = false;
 376  let mediaRecorder = null;
 377  let transcriptionWs = null;
 378  let transcriptMsg = null; // the active transcript message object
 379  
 380  // Context state — DreamNodes loaded into context
 381  let contextNodes = []; // array of { id, title, type, dreamTalkUrl, repoPath, absPath }
 382  
 383  // Autocomplete state
 384  let acVisible = false;
 385  let acSelectedIndex = 0;
 386  let acFilteredItems = [];
 387  let acTriggerPos = -1; // cursor position where @ was typed
 388  
 389  // DreamNode catalog — populated from InterBrain or dummy data
 390  let dreamNodeCatalog = [];
 391  
 392  const SYSTEM_PROMPT = `You are AURYN, the cultivation layer of DreamOS. Named after the Ouroboros amulet from The Neverending Story — "Do what you will."
 393  
 394  You help users tend their knowledge garden: creating DreamNodes, weaving DreamSongs, discovering connections through semantic search, and nurturing visions from seeds to harvest. You speak with warmth and clarity, like a wise gardener who knows the soil intimately.
 395  
 396  Keep responses concise and grounded. Use metaphor when it illuminates, not to obscure. Never use emojis. Respond in plain text with minimal markdown — use **bold** sparingly for emphasis, use bullet lists with dashes when listing items. Do not use headers.`;
 397  
 398  // ============================================================
 399  // 1b. DREAMNODE CATALOG
 400  // ============================================================
 401  // Request catalog from InterBrain via postMessage, fall back to
 402  // scanning via CLI bridge, or use empty list.
 403  
 404  // Mock catalog for testing — replaced by InterBrain response when in iframe
 405  const MOCK_CATALOG = [
 406    { id: '53a3bb7a-3df8-4f36-af99-23787a8cec25', title: 'A U R Y N', type: 'dream', repoPath: 'AURYN', dreamTalkUrl: '' },
 407    { id: 'mock-interBrain', title: 'InterBrain', type: 'dream', repoPath: 'InterBrain', dreamTalkUrl: '' },
 408    { id: 'mock-prism', title: 'PRISM', type: 'dream', repoPath: 'PRISM', dreamTalkUrl: '' },
 409    { id: 'mock-dreamtalk', title: 'DreamTalk', type: 'dream', repoPath: 'DreamTalk', dreamTalkUrl: '' },
 410    { id: 'mock-ataraxia', title: 'A T A R A X I A', type: 'dream', repoPath: 'ATARAXIA', dreamTalkUrl: '' },
 411    { id: 'mock-project-liminality', title: 'Project Liminality', type: 'dream', repoPath: 'Project Liminality', dreamTalkUrl: '' },
 412    { id: 'mock-software-gardening', title: 'Software Gardening', type: 'dream', repoPath: 'Software Gardening', dreamTalkUrl: '' },
 413    { id: 'mock-david', title: 'David', type: 'dreamer', repoPath: 'David', dreamTalkUrl: '' },
 414    { id: 'mock-kronos', title: 'Kronos', type: 'dream', repoPath: 'Kronos', dreamTalkUrl: '' },
 415    { id: 'mock-songlines', title: 'Songlines', type: 'dream', repoPath: 'Songlines', dreamTalkUrl: '' },
 416    { id: 'mock-tutorial-system', title: 'Tutorial System', type: 'dream', repoPath: 'Tutorial System', dreamTalkUrl: '' },
 417    { id: 'mock-spring-launch', title: 'Spring Launch', type: 'dream', repoPath: 'Spring Launch', dreamTalkUrl: '' },
 418  ];
 419  
 420  function requestDreamNodeCatalog() {
 421    if (isInIframe) {
 422      window.parent.postMessage({ type: 'dreamnode-catalog-request' }, '*');
 423    }
 424    // Use mock data immediately so autocomplete works for testing
 425    if (dreamNodeCatalog.length === 0) {
 426      setDreamNodeCatalog(MOCK_CATALOG);
 427    }
 428  }
 429  
 430  function setDreamNodeCatalog(nodes) {
 431    dreamNodeCatalog = nodes;
 432    console.log('[AURYN] DreamNode catalog loaded:', nodes.length, 'nodes');
 433  }
 434  
 435  function findCatalogNode(folder, title, uuid) {
 436    // Match by UUID first, then folder/repoPath, then title
 437    if (uuid) {
 438      const byUuid = dreamNodeCatalog.find(n => n.id === uuid);
 439      if (byUuid) return byUuid;
 440    }
 441    if (folder) {
 442      const byFolder = dreamNodeCatalog.find(n =>
 443        n.repoPath === folder || n.repoPath === folder.replace(/ /g, '')
 444      );
 445      if (byFolder) return byFolder;
 446    }
 447    if (title) {
 448      const byTitle = dreamNodeCatalog.find(n =>
 449        n.title.toLowerCase() === title.toLowerCase()
 450      );
 451      if (byTitle) return byTitle;
 452    }
 453    // If not in catalog, create a temporary entry so the petal still shows
 454    if (folder || title) {
 455      return {
 456        id: uuid || `detected-${(folder || title).toLowerCase().replace(/\s+/g, '-')}`,
 457        title: title || folder,
 458        type: 'dream',
 459        repoPath: folder || '',
 460        dreamTalkUrl: ''
 461      };
 462    }
 463    return null;
 464  }
 465  
 466  // ============================================================
 467  // 1c. CONTEXT MANAGEMENT (petals)
 468  // ============================================================
 469  function addToContext(node) {
 470    if (contextNodes.find(n => n.id === node.id)) return; // already in context
 471    contextNodes.push(node);
 472    renderPetals();
 473    updateSendButton();
 474  }
 475  
 476  function removeFromContext(nodeId) {
 477    contextNodes = contextNodes.filter(n => n.id !== nodeId);
 478    renderPetals();
 479    updateSendButton();
 480  }
 481  
 482  function renderPetals() {
 483    petalsRing.innerHTML = '';
 484    const count = contextNodes.length;
 485    if (count === 0) return;
 486  
 487    const radius = 82; // distance from center
 488  
 489    contextNodes.forEach((node, i) => {
 490      const angle = (i / count) * Math.PI * 2 - Math.PI / 2; // start from top
 491      const x = Math.cos(angle) * radius - 26; // 26 = half petal width
 492      const y = Math.sin(angle) * radius - 26;
 493  
 494      const petal = document.createElement('div');
 495      petal.className = 'petal' + (node.type === 'dreamer' ? ' dreamer' : '');
 496      petal.style.transform = `translate(${x}px, ${y}px)`;
 497      petal.title = node.title;
 498  
 499      if (node.dreamTalkUrl) {
 500        const img = document.createElement('img');
 501        img.src = node.dreamTalkUrl;
 502        img.alt = node.title;
 503        img.onerror = () => {
 504          img.remove();
 505          const initials = document.createElement('div');
 506          initials.className = 'petal-initials';
 507          initials.textContent = node.title.slice(0, 2).toUpperCase();
 508          petal.insertBefore(initials, petal.firstChild);
 509        };
 510        petal.appendChild(img);
 511      } else {
 512        const initials = document.createElement('div');
 513        initials.className = 'petal-initials';
 514        initials.textContent = node.title.slice(0, 2).toUpperCase();
 515        petal.appendChild(initials);
 516      }
 517  
 518      // Label
 519      const label = document.createElement('div');
 520      label.className = 'petal-label';
 521      label.textContent = node.title;
 522      petal.appendChild(label);
 523  
 524      // Remove button
 525      const removeBtn = document.createElement('button');
 526      removeBtn.className = 'petal-remove';
 527      removeBtn.textContent = '\u00d7';
 528      removeBtn.addEventListener('click', (e) => {
 529        e.stopPropagation();
 530        removeFromContext(node.id);
 531      });
 532      petal.appendChild(removeBtn);
 533  
 534      // Click to select in dreamspace
 535      petal.addEventListener('click', () => selectInDreamspace(node.id));
 536  
 537      petalsRing.appendChild(petal);
 538    });
 539  }
 540  
 541  function selectInDreamspace(nodeId) {
 542    // postMessage to InterBrain parent to select this node
 543    if (isInIframe) {
 544      window.parent.postMessage({
 545        type: 'select-dreamnode',
 546        nodeId: nodeId
 547      }, '*');
 548    }
 549    console.log('[AURYN] Select in dreamspace:', nodeId);
 550  }
 551  
 552  // AURYN symbol click → select AURYN itself
 553  aurynSymbol.addEventListener('click', () => {
 554    selectInDreamspace('53a3bb7a-3df8-4f36-af99-23787a8cec25'); // AURYN's UUID
 555  });
 556  
 557  // ============================================================
 558  // 1d. AUTOCOMPLETE
 559  // ============================================================
 560  function showAutocomplete(query) {
 561    const q = query.toLowerCase();
 562    acFilteredItems = dreamNodeCatalog.filter(n =>
 563      n.title.toLowerCase().includes(q)
 564    ).slice(0, 12);
 565  
 566    if (acFilteredItems.length === 0) {
 567      hideAutocomplete();
 568      return;
 569    }
 570  
 571    acSelectedIndex = 0;
 572    acVisible = true;
 573    renderAutocomplete();
 574    autocompleteEl.style.display = 'block';
 575  }
 576  
 577  function hideAutocomplete() {
 578    acVisible = false;
 579    acTriggerPos = -1;
 580    autocompleteEl.style.display = 'none';
 581    autocompleteEl.innerHTML = '';
 582  }
 583  
 584  function renderAutocomplete() {
 585    autocompleteEl.innerHTML = '';
 586    acFilteredItems.forEach((node, i) => {
 587      const item = document.createElement('div');
 588      item.className = 'ac-item' + (i === acSelectedIndex ? ' selected' : '');
 589  
 590      // Thumbnail
 591      const thumb = document.createElement('div');
 592      thumb.className = 'ac-thumb' + (node.type === 'dreamer' ? ' dreamer' : '');
 593      if (node.dreamTalkUrl) {
 594        const img = document.createElement('img');
 595        img.src = node.dreamTalkUrl;
 596        img.onerror = () => {
 597          img.remove();
 598          const init = document.createElement('div');
 599          init.className = 'ac-initials';
 600          init.textContent = node.title.slice(0, 2).toUpperCase();
 601          thumb.appendChild(init);
 602        };
 603        thumb.appendChild(img);
 604      } else {
 605        const init = document.createElement('div');
 606        init.className = 'ac-initials';
 607        init.textContent = node.title.slice(0, 2).toUpperCase();
 608        thumb.appendChild(init);
 609      }
 610      item.appendChild(thumb);
 611  
 612      // Title
 613      const title = document.createElement('div');
 614      title.className = 'ac-title';
 615      title.textContent = node.title;
 616      item.appendChild(title);
 617  
 618      // Type badge
 619      const type = document.createElement('div');
 620      type.className = 'ac-type';
 621      type.textContent = node.type === 'dreamer' ? 'person' : 'idea';
 622      item.appendChild(type);
 623  
 624      item.addEventListener('click', () => acceptAutocomplete(i));
 625      item.addEventListener('mouseenter', () => {
 626        acSelectedIndex = i;
 627        renderAutocomplete();
 628      });
 629  
 630      autocompleteEl.appendChild(item);
 631    });
 632  
 633    // Scroll selected into view
 634    const selectedEl = autocompleteEl.querySelector('.ac-item.selected');
 635    if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
 636  }
 637  
 638  function acceptAutocomplete(index) {
 639    const node = acFilteredItems[index];
 640    if (!node) return;
 641  
 642    // Replace @query with the title
 643    const text = inputEl.value;
 644    const before = text.slice(0, acTriggerPos);
 645    const after = text.slice(inputEl.selectionStart);
 646    inputEl.value = before + node.title + ' ' + after;
 647    inputEl.selectionStart = inputEl.selectionEnd = before.length + node.title.length + 1;
 648  
 649    // Add to context
 650    addToContext(node);
 651  
 652    hideAutocomplete();
 653    inputEl.focus();
 654  }
 655  
 656  // ============================================================
 657  // 2. AI BRIDGE
 658  // ============================================================
 659  const instanceId = crypto.randomUUID();
 660  
 661  // postMessage handler for iframe AI bridge
 662  window.addEventListener('message', (e) => {
 663    const d = e.data;
 664    if (!d || !d.type) return;
 665  
 666    if (d.type === 'ai-bridge-ready') {
 667      if (d.instanceId && d.instanceId !== instanceId) return;
 668      aiBridgeReady = true;
 669      console.log('[AURYN] AI bridge ready via iframe (v' + d.version + ')');
 670      fallbackEl.style.display = 'none';
 671      document.getElementById('chat-zone').style.display = '';
 672      document.getElementById('context-zone').style.display = '';
 673      // Request catalog once bridge is ready
 674      requestDreamNodeCatalog();
 675      return;
 676    }
 677  
 678    // DreamNode catalog response from InterBrain
 679    if (d.type === 'dreamnode-catalog-response') {
 680      setDreamNodeCatalog(d.catalog || []);
 681      return;
 682    }
 683  
 684    if (d.requestId !== currentRequestId) return;
 685  
 686    if (d.type === 'ai-inference-stream-chunk') {
 687      appendChunk(d.chunk);
 688    } else if (d.type === 'ai-inference-stream-done') {
 689      finishStream();
 690    } else if (d.type === 'ai-inference-stream-error') {
 691      finishStream(d.error);
 692    }
 693  });
 694  
 695  // Check if we're inside an iframe (InterBrain) or standalone
 696  const isInIframe = window.parent !== window;
 697  
 698  if (isInIframe) {
 699    fallbackEl.style.display = 'block';
 700    document.getElementById('chat-zone').style.display = 'none';
 701    document.getElementById('context-zone').style.display = 'none';
 702  
 703    window.parent.postMessage({ type: 'ai-bridge-probe', instanceId }, '*');
 704    setTimeout(() => {
 705      if (!aiBridgeReady) {
 706        console.log('[AURYN] No AI bridge — showing fallback');
 707      }
 708    }, 500);
 709  } else {
 710    // Standalone mode — connect to AI bridge WebSocket server
 711    standaloneMode = true;
 712    fallbackEl.style.display = 'none';
 713    document.getElementById('chat-zone').style.display = '';
 714    document.getElementById('context-zone').style.display = '';
 715    connectionStatus.style.display = 'block';
 716    connectionStatus.textContent = 'Connecting to InterBrain...';
 717    connectionStatus.className = 'disconnected';
 718    console.log('[AURYN] Standalone mode — connecting to AI bridge WebSocket');
 719    requestDreamNodeCatalog();
 720    connectAIBridgeWs();
 721  }
 722  
 723  function sendStreamRequest(messages) {
 724    if (standaloneMode) {
 725      sendWsBridgeRequest(messages);
 726      return;
 727    }
 728    currentRequestId = crypto.randomUUID();
 729    window.parent.postMessage({
 730      type: 'ai-inference-stream-request',
 731      requestId: currentRequestId,
 732      messages,
 733      complexity: 'standard'
 734    }, '*');
 735  }
 736  
 737  function cancelStream() {
 738    if (standaloneMode) {
 739      if (currentRequestId && aiBridgeWs && aiBridgeWs.readyState === WebSocket.OPEN) {
 740        aiBridgeWs.send(JSON.stringify({
 741          type: 'ai-inference-stream-cancel',
 742          requestId: currentRequestId
 743        }));
 744      }
 745      finishStream();
 746      return;
 747    }
 748    if (currentRequestId) {
 749      window.parent.postMessage({
 750        type: 'ai-inference-stream-cancel',
 751        requestId: currentRequestId
 752      }, '*');
 753    }
 754    finishStream();
 755  }
 756  
 757  // ============================================================
 758  // 2b. AI BRIDGE WEBSOCKET (standalone mode)
 759  // ============================================================
 760  // Connects to the InterBrain plugin's AI Bridge WebSocket server.
 761  // Same protocol as the iframe postMessage bridge — the plugin
 762  // routes to whatever LLM provider is configured in settings.
 763  
 764  const AI_BRIDGE_PORT = 27182;
 765  let aiBridgeWs = null;
 766  
 767  function getAIBridgeWsUrl() {
 768    // When served by server.py, the InterBrain is on the same machine
 769    // Use the page's hostname (works for both localhost and Tailscale IP)
 770    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
 771    const host = window.location.hostname || 'localhost';
 772    return `${wsProtocol}//${host}:${AI_BRIDGE_PORT}`;
 773  }
 774  
 775  function connectAIBridgeWs() {
 776    const url = getAIBridgeWsUrl();
 777    console.log('[AURYN] Connecting AI bridge WS:', url);
 778  
 779    try {
 780      aiBridgeWs = new WebSocket(url);
 781  
 782      aiBridgeWs.onopen = () => {
 783        console.log('[AURYN] AI bridge WS connected');
 784        connectionStatus.textContent = 'Connected to InterBrain';
 785        connectionStatus.className = 'connected';
 786        // Hide after 3s if connected
 787        setTimeout(() => {
 788          if (aiBridgeWs && aiBridgeWs.readyState === WebSocket.OPEN) {
 789            connectionStatus.style.display = 'none';
 790          }
 791        }, 3000);
 792      };
 793  
 794      aiBridgeWs.onmessage = (e) => {
 795        const data = JSON.parse(e.data);
 796  
 797        if (data.type === 'ai-bridge-ready') {
 798          console.log('[AURYN] AI bridge ready via WebSocket (v' + data.version + ')');
 799          return;
 800        }
 801  
 802        if (data.requestId !== currentRequestId) return;
 803  
 804        if (data.type === 'ai-inference-stream-chunk') {
 805          appendChunk(data.chunk);
 806        } else if (data.type === 'ai-inference-stream-done') {
 807          finishStream();
 808        } else if (data.type === 'ai-inference-stream-error') {
 809          finishStream(data.error);
 810        }
 811      };
 812  
 813      aiBridgeWs.onclose = () => {
 814        console.log('[AURYN] AI bridge WS disconnected');
 815        aiBridgeWs = null;
 816        connectionStatus.style.display = 'block';
 817        connectionStatus.textContent = 'Disconnected from InterBrain — reconnecting...';
 818        connectionStatus.className = 'disconnected';
 819        setTimeout(connectAIBridgeWs, 3000);
 820      };
 821  
 822      aiBridgeWs.onerror = () => {
 823        console.log('[AURYN] AI bridge WS error');
 824      };
 825    } catch (e) {
 826      console.log('[AURYN] Cannot connect to AI bridge:', e.message);
 827      connectionStatus.style.display = 'block';
 828      connectionStatus.textContent = 'Cannot reach InterBrain';
 829      connectionStatus.className = 'disconnected';
 830      setTimeout(connectAIBridgeWs, 5000);
 831    }
 832  }
 833  
 834  function sendWsBridgeRequest(messages) {
 835    if (!aiBridgeWs || aiBridgeWs.readyState !== WebSocket.OPEN) {
 836      finishStream('Not connected to InterBrain. Make sure Obsidian is running.');
 837      return;
 838    }
 839  
 840    currentRequestId = crypto.randomUUID();
 841    aiBridgeWs.send(JSON.stringify({
 842      type: 'ai-inference-stream-request',
 843      requestId: currentRequestId,
 844      messages,
 845      complexity: 'standard'
 846    }));
 847  }
 848  
 849  // ============================================================
 850  // 3. FONT LOADING
 851  // ============================================================
 852  async function loadFont() {
 853    try {
 854      const fontLink = document.getElementById('font-resource');
 855      const fontUrl = fontLink ? fontLink.href : 'fonts/texgyretermes-regular.otf';
 856      console.log('[AURYN] Loading font from:', fontUrl);
 857  
 858      try {
 859        font = await opentype.load(fontUrl);
 860      } catch (_) {
 861        const resp = await fetch(fontUrl);
 862        if (resp.ok) {
 863          const buf = await resp.arrayBuffer();
 864          font = opentype.parse(buf);
 865        }
 866      }
 867  
 868      if (font) {
 869        fontLoaded = true;
 870        console.log('[AURYN] Font loaded:', font.names.fontFamily.en);
 871      } else {
 872        console.warn('[AURYN] Font not found, using text fallback');
 873      }
 874    } catch (err) {
 875      console.warn('[AURYN] Font load error:', err.message);
 876    }
 877  }
 878  loadFont();
 879  
 880  // ============================================================
 881  // 4. MARKDOWN RENDERING
 882  // ============================================================
 883  function renderMarkdown(text) {
 884    // Convert markdown to HTML
 885    let html = escapeHtml(text);
 886  
 887    // Bold: **text**
 888    html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
 889    // Italic: *text*
 890    html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
 891    // Inline code: `text`
 892    html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
 893  
 894    // Split into paragraphs on double newline
 895    const blocks = html.split('\n\n');
 896    const rendered = blocks.map(block => {
 897      block = block.trim();
 898      if (!block) return '';
 899  
 900      // Check if block is a list (lines starting with - )
 901      const lines = block.split('\n');
 902      const isList = lines.every(l => /^[-]\s/.test(l.trim()) || !l.trim());
 903      if (isList) {
 904        // Render as dash-prefixed lines to match SVG animation exactly
 905        const items = lines
 906          .filter(l => l.trim())
 907          .map(l => '<div class="list-item">' + l.trim() + '</div>')
 908          .join('');
 909        return '<div class="list-block">' + items + '</div>';
 910      }
 911  
 912      return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
 913    }).join('');
 914  
 915    return rendered;
 916  }
 917  
 918  function escapeHtml(s) {
 919    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 920  }
 921  
 922  // ============================================================
 923  // 5. MARKDOWN STREAM PARSER
 924  // ============================================================
 925  function createMarkdownParser() {
 926    let buf = '';
 927    let bold = false;
 928    let italic = false;
 929    let emitted = 0;
 930  
 931    return {
 932      feed(chunk) {
 933        buf += chunk;
 934        const tokens = [];
 935        let i = 0;
 936  
 937        while (i < buf.length) {
 938          if (buf[i] === '*' && buf[i + 1] === '*') {
 939            if (i + 1 < buf.length) {
 940              bold = !bold;
 941              i += 2;
 942              continue;
 943            } else {
 944              break;
 945            }
 946          }
 947          if (buf[i] === '*' && buf[i + 1] !== '*') {
 948            italic = !italic;
 949            i += 1;
 950            continue;
 951          }
 952          if (buf[i] === '*' && i === buf.length - 1) {
 953            break;
 954          }
 955          if (buf[i] === '`') {
 956            i++;
 957            while (i < buf.length && buf[i] !== '`') {
 958              tokens.push({ char: buf[i], bold: false, italic: false, code: true });
 959              i++;
 960            }
 961            if (i < buf.length) i++;
 962            continue;
 963          }
 964  
 965          tokens.push({ char: buf[i], bold, italic, code: false });
 966          i++;
 967        }
 968  
 969        buf = buf.slice(i);
 970        const newTokens = tokens.slice(0);
 971        return newTokens;
 972      },
 973  
 974      flush() {
 975        const tokens = [];
 976        for (const ch of buf) {
 977          if (ch !== '*' && ch !== '`') {
 978            tokens.push({ char: ch, bold, italic, code: false });
 979          }
 980        }
 981        buf = '';
 982        return tokens;
 983      }
 984    };
 985  }
 986  
 987  // ============================================================
 988  // 6. WRITE ANIMATION ENGINE
 989  // ============================================================
 990  const STROKE_DURATION = 0.5;
 991  const FILL_DELAY = 0.1;
 992  const CASCADE_DELAY = 0.0075;
 993  const ANIM_FONT_SIZE = 20;
 994  const ANIM_LINE_HEIGHT = ANIM_FONT_SIZE * 1.6;
 995  const PARAGRAPH_GAP = ANIM_FONT_SIZE * 0.6;
 996  const BOLD_STROKE_WIDTH = 2.5;
 997  const NORMAL_STROKE_WIDTH = 1.5;
 998  const ITALIC_SKEW = -12;
 999  
1000  let activeMsg = null;
1001  
1002  function createAnimatedMessage(cssClass) {
1003    const div = document.createElement('div');
1004    div.className = 'msg ' + cssClass;
1005  
1006    const textLayer = document.createElement('div');
1007    textLayer.className = 'text-layer';
1008    div.appendChild(textLayer);
1009  
1010    messagesEl.appendChild(div);
1011  
1012    const msg = {
1013      el: div,
1014      textLayer: textLayer,
1015      fullText: '',
1016      mdParser: createMarkdownParser(),
1017      svgOverlay: null,
1018      svgEl: null,
1019      xOffset: 0,
1020      lineData: [],
1021      animatedChars: 0,
1022      wordBuffer: [],
1023      pendingNewlines: 0,
1024      strokeColor: cssClass === 'transcript' ? '#b0b0b0' : '#fff',
1025      fillColor: cssClass === 'transcript' ? '#b0b0b0' : '#fff',
1026    };
1027  
1028    if (fontLoaded) {
1029      textLayer.style.visibility = 'hidden';
1030  
1031      const overlay = document.createElement('div');
1032      overlay.className = 'svg-overlay';
1033      const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1034      svgEl.style.overflow = 'visible';
1035      overlay.appendChild(svgEl);
1036      div.appendChild(overlay);
1037  
1038      msg.svgOverlay = overlay;
1039      msg.svgEl = svgEl;
1040      startNewLineFor(msg);
1041    }
1042  
1043    scrollToBottom();
1044    return msg;
1045  }
1046  
1047  function createAssistantMessage() {
1048    activeMsg = createAnimatedMessage('assistant');
1049    return activeMsg.el;
1050  }
1051  
1052  function getContainerWidthFor(msg) {
1053    if (!msg || !msg.el) return 500;
1054    return msg.el.clientWidth || 500;
1055  }
1056  
1057  function getContainerWidth() {
1058    return getContainerWidthFor(activeMsg);
1059  }
1060  
1061  function startNewLineFor(msg, extraGap) {
1062    if (!msg) return;
1063    let newY;
1064    if (msg.lineData.length === 0) {
1065      newY = ANIM_FONT_SIZE;
1066    } else {
1067      const prevY = msg.lineData[msg.lineData.length - 1].y;
1068      newY = prevY + ANIM_LINE_HEIGHT + (extraGap || 0);
1069    }
1070    msg.lineData.push({ y: newY });
1071    msg.xOffset = 0;
1072  }
1073  
1074  function startNewLine(extraGap) {
1075    startNewLineFor(activeMsg, extraGap);
1076  }
1077  
1078  function measureTokens(tokens) {
1079    if (!font) return tokens.length * ANIM_FONT_SIZE * 0.5;
1080    const scale = ANIM_FONT_SIZE / font.unitsPerEm;
1081    let w = 0;
1082    for (const tok of tokens) {
1083      const g = font.charToGlyph(tok.char);
1084      w += (g.advanceWidth ?? ANIM_FONT_SIZE * 0.6) * scale;
1085    }
1086    return w;
1087  }
1088  
1089  function flushWordFor(msg) {
1090    if (!msg || msg.wordBuffer.length === 0) return;
1091  
1092    const tokens = msg.wordBuffer;
1093    const containerWidth = getContainerWidthFor(msg);
1094    const wordWidth = measureTokens(tokens);
1095  
1096    if (msg.xOffset > 0 && msg.xOffset + wordWidth > containerWidth) {
1097      startNewLineFor(msg);
1098    }
1099  
1100    for (const tok of tokens) {
1101      renderCharToSVGFor(msg, tok.char, msg.animatedChars, tok.bold, tok.italic);
1102      msg.animatedChars++;
1103    }
1104  
1105    msg.wordBuffer = [];
1106  }
1107  
1108  function flushWord() {
1109    flushWordFor(activeMsg);
1110  }
1111  
1112  function renderCharToSVGFor(msg, char, charIndex, isBold, isItalic) {
1113    if (!font || !msg || !msg.svgEl) return;
1114  
1115    const svgEl = msg.svgEl;
1116    const scale = ANIM_FONT_SIZE / font.unitsPerEm;
1117    const glyph = font.charToGlyph(char);
1118    const advance = (glyph.advanceWidth ?? ANIM_FONT_SIZE * 0.6) * scale;
1119  
1120    const x = msg.xOffset;
1121    const y = msg.lineData[msg.lineData.length - 1].y;
1122  
1123    const path = glyph.getPath(x, y, ANIM_FONT_SIZE);
1124    const pathStr = path.toSVG(2);
1125    const dMatch = pathStr.match(/d="([^"]+)"/);
1126  
1127    if (dMatch && char !== ' ') {
1128      const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1129  
1130      if (isItalic) {
1131        const skewRad = ITALIC_SKEW * Math.PI / 180;
1132        const yCenter = y - ANIM_FONT_SIZE * 0.4;
1133        const xShift = -yCenter * Math.tan(skewRad);
1134        g.setAttribute('transform', `translate(${xShift}, 0) skewX(${ITALIC_SKEW})`);
1135      }
1136  
1137      const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1138      tempPath.setAttribute('d', dMatch[1]);
1139      svgEl.appendChild(tempPath);
1140      const pathLength = tempPath.getTotalLength() || 100;
1141      svgEl.removeChild(tempPath);
1142  
1143      const delay = charIndex * CASCADE_DELAY;
1144      const strokeWidth = isBold ? BOLD_STROKE_WIDTH : NORMAL_STROKE_WIDTH;
1145  
1146      const strokePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1147      strokePath.setAttribute('d', dMatch[1]);
1148      strokePath.setAttribute('class', 'char-path-stroke');
1149      strokePath.style.stroke = msg.strokeColor;
1150      strokePath.style.strokeWidth = strokeWidth;
1151      strokePath.style.strokeDasharray = pathLength;
1152      strokePath.style.strokeDashoffset = pathLength;
1153      strokePath.style.animation =
1154        `stroke-draw ${STROKE_DURATION}s ${delay}s ease-in-out forwards, ` +
1155        `stroke-fade 0.3s ${delay + STROKE_DURATION + FILL_DELAY}s ease-out forwards`;
1156      g.appendChild(strokePath);
1157  
1158      const fillPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1159      fillPath.setAttribute('d', dMatch[1]);
1160      fillPath.setAttribute('class', 'char-path-fill');
1161      fillPath.style.fill = msg.fillColor;
1162      fillPath.style.animation =
1163        `fill-reveal 0.3s ${delay + STROKE_DURATION * 0.6}s ease-in forwards`;
1164      g.appendChild(fillPath);
1165  
1166      svgEl.appendChild(g);
1167    }
1168  
1169    msg.xOffset += advance;
1170  
1171    const totalHeight = msg.lineData.length * ANIM_LINE_HEIGHT + ANIM_FONT_SIZE * 0.5;
1172    const containerWidth = getContainerWidthFor(msg);
1173    svgEl.setAttribute('viewBox', `0 0 ${containerWidth} ${totalHeight}`);
1174    svgEl.style.height = totalHeight + 'px';
1175  }
1176  
1177  function renderCharToSVG(char, charIndex, isBold, isItalic) {
1178    renderCharToSVGFor(activeMsg, char, charIndex, isBold, isItalic);
1179  }
1180  
1181  function processTokensFor(msg, tokens) {
1182    if (!msg) return;
1183  
1184    for (const tok of tokens) {
1185      if (tok.char === '\n') {
1186        flushWordFor(msg);
1187        msg.pendingNewlines++;
1188      } else {
1189        if (msg.pendingNewlines > 0) {
1190          if (msg.pendingNewlines >= 2) {
1191            startNewLineFor(msg, PARAGRAPH_GAP);
1192          } else {
1193            startNewLineFor(msg);
1194          }
1195          msg.pendingNewlines = 0;
1196        }
1197  
1198        if (tok.char === ' ') {
1199          flushWordFor(msg);
1200          renderCharToSVGFor(msg, ' ', msg.animatedChars, false, false);
1201          msg.animatedChars++;
1202        } else {
1203          msg.wordBuffer.push(tok);
1204        }
1205      }
1206    }
1207  }
1208  
1209  function processTokens(tokens) {
1210    processTokensFor(activeMsg, tokens);
1211  }
1212  
1213  function appendChunkTo(msg, chunk) {
1214    if (!msg) return;
1215    msg.fullText += chunk;
1216  
1217    msg.textLayer.innerHTML = renderMarkdown(msg.fullText);
1218  
1219    if (fontLoaded && msg.svgEl) {
1220      const tokens = msg.mdParser.feed(chunk);
1221      processTokensFor(msg, tokens);
1222    }
1223  
1224    scrollToBottom();
1225  }
1226  
1227  function appendChunk(chunk) {
1228    appendChunkTo(activeMsg, chunk);
1229  }
1230  
1231  function finishAnimatedMessage(msg, error) {
1232    if (!msg) return;
1233  
1234    if (msg.mdParser) {
1235      const remaining = msg.mdParser.flush();
1236      processTokensFor(msg, remaining);
1237    }
1238    if (msg.wordBuffer && msg.wordBuffer.length > 0) {
1239      flushWordFor(msg);
1240    }
1241  
1242    if (error) {
1243      const errEl = document.createElement('div');
1244      errEl.style.color = '#8B0000';
1245      errEl.style.fontSize = '13px';
1246      errEl.style.marginTop = '8px';
1247      errEl.textContent = 'Error: ' + error;
1248      msg.el.appendChild(errEl);
1249    }
1250  
1251    if (msg.svgOverlay && msg.svgEl) {
1252      const lastCharDelay = msg.animatedChars * CASCADE_DELAY;
1253      const totalAnimTime = (lastCharDelay + STROKE_DURATION + FILL_DELAY + 0.5) * 1000;
1254  
1255      setTimeout(() => {
1256        if (!msg.el) return;
1257        msg.svgOverlay.style.transition = 'opacity 0.4s ease-out';
1258        msg.svgOverlay.style.opacity = '0';
1259        msg.textLayer.style.visibility = 'visible';
1260        msg.textLayer.style.opacity = '0';
1261        msg.textLayer.style.transition = 'opacity 0.4s ease-in';
1262        msg.textLayer.offsetHeight;
1263        msg.textLayer.style.opacity = '1';
1264  
1265        setTimeout(() => {
1266          if (msg.svgOverlay && msg.svgOverlay.parentNode) {
1267            msg.svgOverlay.remove();
1268          }
1269        }, 500);
1270      }, totalAnimTime);
1271    } else {
1272      msg.textLayer.style.visibility = 'visible';
1273    }
1274  }
1275  
1276  function finishStream(error) {
1277    isStreaming = false;
1278    currentRequestId = null;
1279    sendBtn.style.display = '';
1280    stopBtn.style.display = 'none';
1281    updateSendButton();
1282  
1283    if (activeMsg) {
1284      finishAnimatedMessage(activeMsg, error);
1285  
1286      if (activeMsg.fullText) {
1287        conversation.push({ role: 'assistant', content: activeMsg.fullText });
1288      }
1289  
1290      activeMsg = null;
1291    }
1292  
1293    inputEl.focus();
1294  }
1295  
1296  function scrollToBottom() {
1297    requestAnimationFrame(() => {
1298      messagesEl.scrollTop = messagesEl.scrollHeight;
1299    });
1300  }
1301  
1302  // ============================================================
1303  // 7. CHAT LOGIC
1304  // ============================================================
1305  function sendMessage(text) {
1306    if (!text) text = inputEl.value.trim();
1307    if (!text || isStreaming) return;
1308  
1309    // Hide autocomplete on send
1310    hideAutocomplete();
1311  
1312    // Check for /do command — route to Claude Code
1313    if (text.startsWith('/do ')) {
1314      const doPrompt = text.slice(4).trim();
1315      if (doPrompt) {
1316        // Show user message
1317        const userMsg = document.createElement('div');
1318        userMsg.className = 'msg user';
1319        userMsg.textContent = text;
1320        messagesEl.appendChild(userMsg);
1321        conversation.push({ role: 'user', content: text });
1322        inputEl.value = '';
1323        inputEl.style.height = 'auto';
1324        updateSendButton();
1325        startClaudeCode(doPrompt);
1326        return;
1327      }
1328    }
1329  
1330    const userMsg = document.createElement('div');
1331    userMsg.className = 'msg user';
1332    userMsg.textContent = text;
1333    messagesEl.appendChild(userMsg);
1334  
1335    conversation.push({ role: 'user', content: text });
1336  
1337    inputEl.value = '';
1338    inputEl.style.height = 'auto';
1339    updateSendButton();
1340  
1341    const messages = [
1342      { role: 'system', content: SYSTEM_PROMPT },
1343      ...conversation
1344    ];
1345  
1346    isStreaming = true;
1347    sendBtn.style.display = 'none';
1348    stopBtn.style.display = 'flex';
1349  
1350    createAssistantMessage();
1351    sendStreamRequest(messages);
1352    scrollToBottom();
1353  }
1354  
1355  // ============================================================
1356  // 7b. VOCAB DEBUG
1357  // ============================================================
1358  function renderVocabDebug(data) {
1359    const el = document.getElementById('vocab-debug');
1360    const tag = (cls, items) => items.length
1361      ? `<span class="vocab-label">${cls}:</span>` +
1362        items.map(t => `<span class="vocab-${cls}">${t}</span>`).join(', ') + ' '
1363      : '';
1364    el.innerHTML =
1365      tag('core', data.core || []) +
1366      tag('pinned', data.pinned || []) +
1367      tag('ephemeral', data.ephemeral || []);
1368    el.classList.add('active');
1369  }
1370  
1371  // ============================================================
1372  // 8. TRANSCRIPTION (VOICE)
1373  // ============================================================
1374  function getTranscriptionWsUrl() {
1375    if (standaloneMode) {
1376      const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1377      return `${wsProtocol}//${window.location.host}/ws/transcribe`;
1378    }
1379    return `ws://localhost:3002/ws/transcribe`;
1380  }
1381  
1382  function connectTranscriptionWs() {
1383    const url = getTranscriptionWsUrl();
1384    console.log('[AURYN] Connecting transcription WS:', url);
1385  
1386    try {
1387      transcriptionWs = new WebSocket(url);
1388  
1389      transcriptionWs.onopen = () => {
1390        console.log('[AURYN] Transcription WS connected');
1391        micBtn.disabled = false;
1392        micBtn.title = 'Record voice';
1393      };
1394  
1395      transcriptionWs.onmessage = (e) => {
1396        const data = JSON.parse(e.data);
1397  
1398        if (data.type === 'transcript_chunk') {
1399          // Insert at cursor position in the textarea
1400          const start = inputEl.selectionStart;
1401          const end = inputEl.selectionEnd;
1402          const val = inputEl.value;
1403          inputEl.value = val.substring(0, start) + data.text + val.substring(end);
1404          inputEl.selectionStart = inputEl.selectionEnd = start + data.text.length;
1405          inputEl.style.height = 'auto';
1406          inputEl.style.height = inputEl.scrollHeight + 'px';
1407          inputEl.focus();
1408        } else if (data.type === 'dreamnode_detected') {
1409          // A DreamNode name was recognized in the transcript — add to context petals
1410          const node = findCatalogNode(data.folder, data.title, data.uuid);
1411          if (node) {
1412            if (data.path) node.absPath = data.path;
1413            addToContext(node);
1414            console.log('[AURYN] DreamNode detected in voice:', node.title);
1415          }
1416        } else if (data.type === 'stream_ended') {
1417          // Leave text in the input field for editing — don't auto-send
1418          document.getElementById('vocab-debug').classList.remove('active');
1419          inputEl.focus();
1420          inputEl.style.height = 'auto';
1421          inputEl.style.height = inputEl.scrollHeight + 'px';
1422          // Add any pinned DreamNodes that weren't already in context
1423          if (data.pinned_dreamnodes && Array.isArray(data.pinned_dreamnodes)) {
1424            for (const folder of data.pinned_dreamnodes) {
1425              const node = findCatalogNode(folder);
1426              if (node) addToContext(node);
1427            }
1428          }
1429        } else if (data.type === 'vocab_update') {
1430          renderVocabDebug(data);
1431        } else if (data.type === 'session_started') {
1432          console.log('[AURYN] Transcription session:', data.session_id);
1433          // Show vocab debug panel during transcription
1434          document.getElementById('vocab-debug').classList.add('active');
1435        }
1436      };
1437  
1438      transcriptionWs.onclose = () => {
1439        console.log('[AURYN] Transcription WS disconnected');
1440        micBtn.disabled = true;
1441        micBtn.title = 'Transcription server unavailable';
1442        transcriptionWs = null;
1443        setTimeout(connectTranscriptionWs, 5000);
1444      };
1445  
1446      transcriptionWs.onerror = () => {
1447        console.log('[AURYN] Transcription WS error');
1448      };
1449    } catch (e) {
1450      console.log('[AURYN] Cannot connect to transcription server:', e.message);
1451      micBtn.disabled = true;
1452      micBtn.title = 'Transcription server unavailable';
1453    }
1454  }
1455  
1456  async function startRecording() {
1457    if (!transcriptionWs || transcriptionWs.readyState !== WebSocket.OPEN) {
1458      console.log('[AURYN] No transcription server');
1459      return;
1460    }
1461  
1462    try {
1463      const stream = await navigator.mediaDevices.getUserMedia({
1464        audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
1465      });
1466  
1467      transcriptionWs.send(JSON.stringify({ type: 'start_stream' }));
1468  
1469      mediaRecorder = new MediaRecorder(stream, {
1470        mimeType: 'audio/webm;codecs=opus'
1471      });
1472  
1473      mediaRecorder.ondataavailable = async (e) => {
1474        if (e.data.size > 0 && transcriptionWs && transcriptionWs.readyState === WebSocket.OPEN) {
1475          const buffer = await e.data.arrayBuffer();
1476          transcriptionWs.send(buffer);
1477        }
1478      };
1479  
1480      mediaRecorder.onstop = () => {
1481        stream.getTracks().forEach(track => track.stop());
1482        if (transcriptionWs && transcriptionWs.readyState === WebSocket.OPEN) {
1483          transcriptionWs.send(JSON.stringify({ type: 'end_stream' }));
1484        }
1485      };
1486  
1487      inputEl.focus();
1488      mediaRecorder.start(500);
1489      isRecording = true;
1490      micBtn.classList.add('recording');
1491      micBtn.title = 'Stop recording';
1492  
1493      scrollToBottom();
1494    } catch (err) {
1495      console.error('[AURYN] Mic error:', err);
1496    }
1497  }
1498  
1499  function stopRecording() {
1500    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
1501      mediaRecorder.stop();
1502    }
1503    isRecording = false;
1504    micBtn.classList.remove('recording');
1505    micBtn.title = 'Record voice';
1506  }
1507  
1508  function finishRecording(fullText) {
1509    // Text is already in the textarea — just focus for editing
1510    inputEl.focus();
1511    inputEl.style.height = 'auto';
1512    inputEl.style.height = inputEl.scrollHeight + 'px';
1513  }
1514  
1515  // ============================================================
1516  // 9. DRAG AND DROP AUDIO
1517  // ============================================================
1518  let dragCounter = 0;
1519  
1520  document.addEventListener('dragenter', (e) => {
1521    e.preventDefault();
1522    dragCounter++;
1523    if (hasAudioFile(e)) {
1524      dropOverlay.classList.add('active');
1525    }
1526  });
1527  
1528  document.addEventListener('dragleave', (e) => {
1529    e.preventDefault();
1530    dragCounter--;
1531    if (dragCounter <= 0) {
1532      dragCounter = 0;
1533      dropOverlay.classList.remove('active');
1534    }
1535  });
1536  
1537  document.addEventListener('dragover', (e) => {
1538    e.preventDefault();
1539  });
1540  
1541  document.addEventListener('drop', async (e) => {
1542    e.preventDefault();
1543    dragCounter = 0;
1544    dropOverlay.classList.remove('active');
1545  
1546    const files = [...e.dataTransfer.files].filter(f =>
1547      f.type.startsWith('audio/') ||
1548      /\.(webm|mp3|wav|m4a|ogg|mp4|aac|flac)$/i.test(f.name)
1549    );
1550  
1551    if (files.length === 0) return;
1552  
1553    for (const file of files) {
1554      await transcribeFile(file);
1555    }
1556  });
1557  
1558  function hasAudioFile(e) {
1559    if (e.dataTransfer.items) {
1560      for (const item of e.dataTransfer.items) {
1561        if (item.kind === 'file' && (item.type.startsWith('audio/') || item.type.startsWith('video/'))) {
1562          return true;
1563        }
1564      }
1565    }
1566    return true;
1567  }
1568  
1569  async function transcribeFile(file) {
1570    const label = document.createElement('div');
1571    label.className = 'transcript-label';
1572    label.textContent = `transcribing ${file.name}...`;
1573    messagesEl.appendChild(label);
1574  
1575    const msg = createAnimatedMessage('transcript');
1576    appendChunkTo(msg, 'Processing audio...');
1577    scrollToBottom();
1578  
1579    const uploadUrl = standaloneMode
1580      ? '/upload/audio'
1581      : 'http://localhost:3002/upload/audio';
1582  
1583    try {
1584      const form = new FormData();
1585      form.append('file', file, file.name);
1586  
1587      const resp = await fetch(uploadUrl, { method: 'POST', body: form });
1588      const data = await resp.json();
1589  
1590      msg.fullText = '';
1591      msg.textLayer.innerHTML = '';
1592      if (msg.svgEl) {
1593        while (msg.svgEl.firstChild) msg.svgEl.removeChild(msg.svgEl.firstChild);
1594        msg.animatedChars = 0;
1595        msg.lineData = [];
1596        msg.xOffset = 0;
1597        msg.mdParser = createMarkdownParser();
1598        startNewLineFor(msg);
1599      }
1600  
1601      if (data.text) {
1602        appendChunkTo(msg, data.text);
1603        label.textContent = `transcribed: ${file.name}`;
1604        finishAnimatedMessage(msg);
1605  
1606        setTimeout(() => { sendMessage(data.text.trim()); }, 800);
1607      } else if (data.error) {
1608        label.textContent = `failed: ${file.name}`;
1609        appendChunkTo(msg, data.error);
1610        finishAnimatedMessage(msg, data.error);
1611      }
1612    } catch (err) {
1613      label.textContent = `error: ${file.name}`;
1614      finishAnimatedMessage(msg, err.message);
1615    }
1616  }
1617  
1618  // ============================================================
1619  // 9b. KNOWLEDGE GARDENING
1620  // ============================================================
1621  
1622  const SEND_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>';
1623  const GARDEN_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 22c4.97 0 9-4.03 9-9-4.97 0-9 4.03-9 9zM5.6 10.25c0 1.38 1.12 2.5 2.5 2.5.53 0 1.01-.16 1.42-.44l-.02.19c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5l-.02-.19c.4.28.89.44 1.42.44 1.38 0 2.5-1.12 2.5-2.5 0-1-.59-1.85-1.43-2.25.84-.4 1.43-1.25 1.43-2.25 0-1.38-1.12-2.5-2.5-2.5-.53 0-1.01.16-1.42.44l.02-.19C14.5 2.12 13.38 1 12 1S9.5 2.12 9.5 3.5l.02.19c-.4-.28-.89-.44-1.42-.44-1.38 0-2.5 1.12-2.5 2.5 0 1 .59 1.85 1.43 2.25-.84.4-1.43 1.25-1.43 2.25zM12 5.5c1.38 0 2.5 1.12 2.5 2.5s-1.12 2.5-2.5 2.5S9.5 9.38 9.5 8s1.12-2.5 2.5-2.5zM3 13c0 4.97 4.03 9 9 9-4.97 0-9-4.03-9-9z"/></svg>';
1624  
1625  let gardenWs = null;
1626  let isGardening = false;
1627  
1628  function updateSendButton() {
1629    const hasText = inputEl.value.trim().length > 0;
1630    const hasContext = contextNodes.length > 0;
1631    if (!hasText && hasContext && !isStreaming && !isGardening) {
1632      sendBtn.innerHTML = GARDEN_ICON;
1633      sendBtn.classList.add('garden-mode');
1634      sendBtn.title = 'Garden — route insights to DreamNodes';
1635      sendBtn.disabled = false;
1636    } else {
1637      sendBtn.innerHTML = SEND_ICON;
1638      sendBtn.classList.remove('garden-mode');
1639      sendBtn.title = 'Send';
1640      sendBtn.disabled = isStreaming;
1641    }
1642  }
1643  
1644  function getGardenWsUrl() {
1645    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1646    return `${wsProtocol}//${window.location.host}/ws/garden`;
1647  }
1648  
1649  function startGardening() {
1650    if (isGardening || conversation.length === 0) return;
1651    if (contextNodes.length === 0) return;
1652  
1653    isGardening = true;
1654    sendBtn.disabled = true;
1655  
1656    // Show gardening status in chat
1657    const statusMsg = document.createElement('div');
1658    statusMsg.className = 'msg assistant';
1659    statusMsg.id = 'garden-status';
1660    statusMsg.textContent = 'Gardening...';
1661    messagesEl.appendChild(statusMsg);
1662    scrollToBottom();
1663  
1664    try {
1665      const ws = new WebSocket(getGardenWsUrl());
1666      gardenWs = ws;
1667  
1668      ws.onopen = () => {
1669        // Send the full conversation + context with paths
1670        const context = contextNodes.map(n => ({
1671          title: n.title,
1672          id: n.id,
1673          path: n.absPath || '',
1674        }));
1675  
1676        ws.send(JSON.stringify({
1677          type: 'garden',
1678          conversation: conversation,
1679          context: context,
1680        }));
1681      };
1682  
1683      ws.onmessage = (e) => {
1684        const data = JSON.parse(e.data);
1685        const el = document.getElementById('garden-status');
1686  
1687        if (data.type === 'garden_status') {
1688          if (el) el.textContent = data.message;
1689        } else if (data.type === 'garden_result') {
1690          if (el) {
1691            let text = data.message || 'Done.';
1692            if (data.edits) {
1693              text += '\n\n';
1694              for (const edit of data.edits) {
1695                const icon = edit.status === 'ok' ? '✓' : edit.status === 'warning' ? '⚠' : '✗';
1696                text += `${icon} ${edit.dreamnode}: ${edit.reason}\n`;
1697              }
1698            }
1699            el.textContent = text;
1700          }
1701          finishGardening();
1702        }
1703      };
1704  
1705      ws.onerror = () => {
1706        const el = document.getElementById('garden-status');
1707        if (el) el.textContent = 'Garden error — could not connect.';
1708        finishGardening();
1709      };
1710  
1711      ws.onclose = () => { gardenWs = null; };
1712  
1713    } catch (e) {
1714      const el = document.getElementById('garden-status');
1715      if (el) el.textContent = 'Garden error: ' + e.message;
1716      finishGardening();
1717    }
1718  }
1719  
1720  function finishGardening() {
1721    isGardening = false;
1722    if (gardenWs) { gardenWs.close(); gardenWs = null; }
1723    updateSendButton();
1724  }
1725  
1726  // ============================================================
1727  // 9c. CLAUDE CODE SUB-AGENT (/do command)
1728  // ============================================================
1729  
1730  let claudeWs = null;
1731  let isClaudeWorking = false;
1732  
1733  function getClaudeWsUrl() {
1734    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1735    return `${wsProtocol}//${window.location.host}/ws/claude`;
1736  }
1737  
1738  function startClaudeCode(prompt) {
1739    if (isClaudeWorking) return;
1740    isClaudeWorking = true;
1741  
1742    // Show status in chat
1743    const statusMsg = document.createElement('div');
1744    statusMsg.className = 'msg assistant';
1745    statusMsg.id = 'claude-status';
1746    statusMsg.textContent = 'Spawning Claude Code...';
1747    messagesEl.appendChild(statusMsg);
1748    scrollToBottom();
1749  
1750    try {
1751      const ws = new WebSocket(getClaudeWsUrl());
1752      claudeWs = ws;
1753  
1754      ws.onopen = () => {
1755        const context = contextNodes.map(n => ({
1756          title: n.title,
1757          id: n.id,
1758          path: n.absPath || '',
1759        }));
1760  
1761        ws.send(JSON.stringify({
1762          type: 'claude_code',
1763          prompt: prompt,
1764          context: context,
1765          conversation: conversation,
1766        }));
1767      };
1768  
1769      ws.onmessage = (e) => {
1770        const data = JSON.parse(e.data);
1771        const el = document.getElementById('claude-status');
1772  
1773        if (data.type === 'claude_status') {
1774          if (el) el.textContent = data.message;
1775        } else if (data.type === 'claude_result') {
1776          if (el) {
1777            let text = data.message || 'Done.';
1778            if (data.cost) text += `\n\nCost: $${data.cost.toFixed(4)}`;
1779            if (data.status === 'done') {
1780              // Check if changes might need a reload
1781              text += '\n\n(Refresh page for UI changes. Use reload button for server changes.)';
1782            }
1783            el.textContent = text;
1784            el.removeAttribute('id');
1785          }
1786          // Add to conversation so AURYN knows what happened
1787          conversation.push({ role: 'assistant', content: '[Claude Code] ' + (data.message || 'Done.') });
1788          finishClaudeCode();
1789        }
1790      };
1791  
1792      ws.onerror = () => {
1793        const el = document.getElementById('claude-status');
1794        if (el) el.textContent = 'Claude Code error — could not connect.';
1795        finishClaudeCode();
1796      };
1797  
1798      ws.onclose = () => { claudeWs = null; };
1799  
1800    } catch (e) {
1801      const el = document.getElementById('claude-status');
1802      if (el) el.textContent = 'Claude Code error: ' + e.message;
1803      finishClaudeCode();
1804    }
1805  }
1806  
1807  function finishClaudeCode() {
1808    isClaudeWorking = false;
1809    if (claudeWs) { claudeWs.close(); claudeWs = null; }
1810    updateSendButton();
1811  }
1812  
1813  // Server reload
1814  function reloadServer() {
1815    const protocol = window.location.protocol;
1816    const host = window.location.host;
1817    fetch(`${protocol}//${host}/reload`, { method: 'POST' })
1818      .then(() => {
1819        const msg = document.createElement('div');
1820        msg.className = 'msg assistant';
1821        msg.textContent = 'Server restarting... reconnecting in 5s.';
1822        messagesEl.appendChild(msg);
1823        scrollToBottom();
1824        // Reconnect after restart
1825        setTimeout(() => { window.location.reload(); }, 5000);
1826      })
1827      .catch(e => {
1828        const msg = document.createElement('div');
1829        msg.className = 'msg assistant';
1830        msg.textContent = 'Reload failed: ' + e.message;
1831        messagesEl.appendChild(msg);
1832      });
1833  }
1834  
1835  // ============================================================
1836  // 10. EVENT HANDLERS
1837  // ============================================================
1838  sendBtn.addEventListener('click', () => {
1839    const hasText = inputEl.value.trim().length > 0;
1840    if (hasText) {
1841      sendMessage();
1842    } else if (contextNodes.length > 0) {
1843      startGardening();
1844    }
1845  });
1846  stopBtn.addEventListener('click', () => { cancelStream(); });
1847  
1848  micBtn.addEventListener('click', () => {
1849    if (isRecording) {
1850      stopRecording();
1851    } else {
1852      startRecording();
1853    }
1854  });
1855  
1856  // Autocomplete-aware keydown handler
1857  inputEl.addEventListener('keydown', (e) => {
1858    if (acVisible) {
1859      if (e.key === 'ArrowDown') {
1860        e.preventDefault();
1861        acSelectedIndex = Math.min(acSelectedIndex + 1, acFilteredItems.length - 1);
1862        renderAutocomplete();
1863        return;
1864      }
1865      if (e.key === 'ArrowUp') {
1866        e.preventDefault();
1867        acSelectedIndex = Math.max(acSelectedIndex - 1, 0);
1868        renderAutocomplete();
1869        return;
1870      }
1871      if (e.key === 'Enter' || e.key === 'Tab') {
1872        e.preventDefault();
1873        acceptAutocomplete(acSelectedIndex);
1874        return;
1875      }
1876      if (e.key === 'Escape') {
1877        e.preventDefault();
1878        hideAutocomplete();
1879        return;
1880      }
1881    }
1882  
1883    if (e.key === 'Enter' && !e.shiftKey) {
1884      e.preventDefault();
1885      const hasText = inputEl.value.trim().length > 0;
1886      if (hasText) {
1887        sendMessage();
1888      } else if (contextNodes.length > 0) {
1889        startGardening();
1890      }
1891    }
1892  });
1893  
1894  inputEl.addEventListener('input', () => {
1895    inputEl.style.height = 'auto';
1896    inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1897    updateSendButton();
1898  
1899    // Check for @ autocomplete trigger
1900    const text = inputEl.value;
1901    const cursor = inputEl.selectionStart;
1902  
1903    // Find the last @ before cursor
1904    let atPos = -1;
1905    for (let i = cursor - 1; i >= 0; i--) {
1906      if (text[i] === '@') {
1907        atPos = i;
1908        break;
1909      }
1910      // Stop searching if we hit a space before finding @
1911      if (text[i] === ' ' && i < cursor - 1) break;
1912      // Also stop at newlines
1913      if (text[i] === '\n') break;
1914    }
1915  
1916    if (atPos >= 0 && dreamNodeCatalog.length > 0) {
1917      const query = text.slice(atPos + 1, cursor);
1918      acTriggerPos = atPos;
1919      showAutocomplete(query);
1920    } else {
1921      hideAutocomplete();
1922    }
1923  });
1924  
1925  // Close autocomplete when clicking outside
1926  document.addEventListener('click', (e) => {
1927    if (!inputEl.contains(e.target) && !autocompleteEl.contains(e.target)) {
1928      hideAutocomplete();
1929    }
1930  });
1931  
1932  // ============================================================
1933  // 11. INIT
1934  // ============================================================
1935  connectTranscriptionWs();
1936  updateSendButton();
1937  </script>
1938  </body>
1939  </html>