1// Memory Management Functions 2// This module handles all memory-related operations 3 4import uiModule from './ui.js'; 5import sessionModule from './sessions.js'; 6import spinnerModule from './spinner.js'; 7import { makeWindowDraggable } from './windowDrag.js'; 8import { snapModalToZone } from './tileManager.js'; 9import { topPortalZ } from './toolWindowZOrder.js'; 10 11var escapeHtml = uiModule.esc; 12 13let memories = []; 14let activeCategory = 'all'; 15let sortOrder = 'newest'; 16let selectMode = false; 17let selectedIds = new Set(); 18let memoriesLoading = false; 19 20 21const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task']; 22 23// Sort-option icons for the custom Memory sort picker (and Skills picker 24// once it reuses the same markup). Each value maps to a 13px Feather-style 25// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used. 26const _MEMORY_SORT_ICONS = { 27 newest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', 28 oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>', 29 alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>', 30 uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>', 31}; 32 33function _memorySortIcon(value) { 34 return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest; 35} 36 37function _renderMemorySortPickerCurrent() { 38 const sel = document.getElementById('memory-sort'); 39 const btn = document.getElementById('memory-sort-btn'); 40 if (!sel || !btn) return; 41 const value = sel.value || 'newest'; 42 const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`); 43 const label = opt ? opt.textContent : value; 44 const iconWrap = btn.querySelector('.memory-sort-icon-cur'); 45 const labelEl = btn.querySelector('.memory-sort-label'); 46 if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value); 47 if (labelEl) labelEl.textContent = label; 48} 49 50function _initMemorySortPicker() { 51 const sel = document.getElementById('memory-sort'); 52 const picker = document.getElementById('memory-sort-picker'); 53 const btn = document.getElementById('memory-sort-btn'); 54 const menu = document.getElementById('memory-sort-menu'); 55 if (!sel || !picker || !btn || !menu || picker._wired) return; 56 picker._wired = true; 57 58 const items = Array.from(sel.children) 59 .filter(o => o.tagName === 'OPTION') 60 .map(o => ({ value: o.value, label: o.textContent })); 61 62 menu.innerHTML = items.map(it => ` 63 <button type="button" role="option" class="memory-sort-item" data-value="${it.value}"> 64 <span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span> 65 <span class="memory-sort-item-label">${it.label}</span> 66 </button> 67 `).join(''); 68 69 const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); }; 70 const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); }; 71 72 btn.addEventListener('click', (e) => { 73 e.stopPropagation(); 74 if (menu.hidden) open(); else close(); 75 }); 76 menu.addEventListener('click', (e) => { 77 const item = e.target.closest('.memory-sort-item'); 78 if (!item) return; 79 sel.value = item.dataset.value; 80 sel.dispatchEvent(new Event('change', { bubbles: true })); 81 _renderMemorySortPickerCurrent(); 82 close(); 83 }); 84 document.addEventListener('click', (e) => { 85 if (!menu.hidden && !picker.contains(e.target)) close(); 86 }); 87 document.addEventListener('keydown', (e) => { 88 if (e.key === 'Escape' && !menu.hidden) { 89 e.stopPropagation(); 90 close(); 91 } 92 }, { capture: true }); 93 94 _renderMemorySortPickerCurrent(); 95} 96 97function _ensureNewMemoryCategorySelect() { 98 const sel = document.getElementById('new-memory-category'); 99 if (!sel || sel.dataset.wired === '1') return; 100 sel.dataset.wired = '1'; 101 MEMORY_CATEGORIES.forEach(cat => { 102 const opt = document.createElement('option'); 103 opt.value = cat; 104 opt.textContent = cat; 105 if (cat === 'fact') opt.selected = true; 106 sel.appendChild(opt); 107 }); 108} 109 110function _readNewMemoryCategory() { 111 _ensureNewMemoryCategorySelect(); 112 const sel = document.getElementById('new-memory-category'); 113 const cat = sel?.value || 'fact'; 114 return MEMORY_CATEGORIES.includes(cat) ? cat : 'fact'; 115} 116 117let _memoryDragWired = false;
118function _wireMemoryDrag() { 119 if (_memoryDragWired) return; 120 const modal = document.getElementById('memory-modal'); 121 const content = modal && modal.querySelector('.modal-content'); 122 const header = modal && modal.querySelector('.modal-header'); 123 if (!modal || !content || !header) return; 124 _memoryDragWired = true; 125 makeWindowDraggable(modal, { 126 content, 127 header, 128 skipSelector: 'button, input, select, label', 129 enableDock: true, 130 enableLeftDock: true, 131 onEnterFullscreen: () => { 132 snapModalToZone(modal, { 133 name: 'fullscreen', 134 rect: { 135 left: 0, 136 top: 0, 137 width: window.innerWidth || document.documentElement.clientWidth || 0, 138 height: window.innerHeight || document.documentElement.clientHeight || 0, 139 }, 140 }); 141 }, 142 }); 143} 144 145function relativeTime(timestamp) { 146 const now = Math.floor(Date.now() / 1000); 147 const diff = now - timestamp; 148 if (diff < 60) return 'just now'; 149 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 150 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 151 if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; 152 if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`; 153 if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`; 154 return `${Math.floor(diff / 31536000)}y ago`; 155} 156 157function buildCategoryChips() { 158 const container = document.getElementById('memory-category-filters'); 159 if (!container) return; 160 161 // Hide the chip row entirely when there are no memories â no point showing 162 // an "all" chip with nothing to filter. 163 if (!memories.length) { container.innerHTML = ''; return; } 164 165 const cats = new Set(memories.map(m => m.category || 'fact')); 166 const sorted = ['all', ...Array.from(cats).sort()]; 167 168 container.innerHTML = ''; 169 sorted.forEach(cat => { 170 const btn = document.createElement('button'); 171 btn.className = 'memory-cat-chip' + (cat === activeCategory ? ' active' : ''); 172 btn.dataset.cat = cat; 173 btn.textContent = cat; 174 btn.addEventListener('click', () => { 175 activeCategory = cat; 176 container.querySelectorAll('.memory-cat-chip').forEach(b => b.classList.remove('active')); 177 btn.classList.add('active'); 178 renderMemoryList(); 179 updateMemoryCount(); 180 }); 181 container.appendChild(btn); 182 }); 183} 184 185async function syncToggles() { 186 // The settings tab no longer hosts a separate "Memory in context" toggle â 187 // the header toggle owns that pref directly now. 188 await syncPrefToggle('memory-enabled-header-toggle', 'memory_enabled', 'Memory enabled', 'Memory disabled', false); 189 // The Skills header toggle owns the `skills_enabled` pref (was never wired â 190 // toggling it did nothing, so skills stayed on). Now it actually gates skill 191 // injection (see chat_helpers.py: uprefs.skills_enabled). 192 await syncPrefToggle('skills-enabled-header-toggle', 'skills_enabled', 'Skills enabled', 'Skills disabled', false); 193 await syncPrefToggle('auto-memory-toggle', 'auto_memory', 'Auto-extract memories enabled', 'Auto-extract memories disabled', false); 194 await syncPrefToggle('auto-skills-toggle', 'auto_skills', 'Auto-extract skills enabled', 'Auto-extract skills disabled', false); 195 await syncPrefToggle('auto-approve-skills-toggle', 'auto_approve_skills', 'Auto-approve skills enabled', 'Auto-approve skills disabled', false); 196 await syncPrefSlider('skill-confidence-slider', 'skill_min_confidence', 'skill-confidence-label', 0.85); 197 await syncPrefNumber('skill-max-input', 'skill_max_injected', 3); 198 199 // Reflect the header toggle into the sidebar dim + modal body opacity. 200 const headerToggle = document.getElementById('memory-enabled-header-toggle'); 201 if (headerToggle) { 202 const modalBody = document.querySelector('.memory-modal-body'); 203 if (modalBody) modalBody.style.opacity = headerToggle.checked ? '' : '0.3'; 204 reflectMemoryToggleInSidebar(headerToggle.checked); 205 if (!headerToggle.dataset.boundUx) { 206 headerToggle.dataset.boundUx = '1'; 207 headerToggle.addEventListener('change', () => { 208 if (modalBody) modalBody.style.opacity = headerToggle.checked ? '' : '0.3'; 209 reflectMemoryToggleInSidebar(headerToggle.checked); 210 }); 211 } 212 } 213 214 // Same dim treatment for the Skills toggle â dims the skills panel when off. 215 const skillsToggle = document.getElementById('skills-enabled-header-toggle'); 216 if (skillsToggle) { 217 const skillsPanel = document.querySelector('[data-memory-panel="skills"]'); 218 const applyDim = () => { if (skillsPanel) skillsPanel.style.opacity = skillsToggle.checked ? '' : '0.3'; }; 219 applyDim(); 220 if (!skillsToggle.dataset.boundUx) { 221 skillsToggle.dataset.boundUx = '1'; 222 skillsToggle.addEventListener('change', applyDim); 223 } 224 } 225} 226 227function reflectMemoryToggleInSidebar(enabled) { 228 const btn = document.getElementById('tool-memory-btn'); 229 if (btn) btn.classList.toggle('tool-disabled', !enabled); 230} 231 232function syncToggleDim(toggle) { 233 const card = toggle.closest('.admin-card'); 234 if (!card) return; 235 const toggleRow = toggle.closest('div[style*="justify-content"]'); 236 let sibling = toggleRow ? toggleRow.nextElementSibling : null; 237 while (sibling) { 238 sibling.style.opacity = toggle.checked ? '' : '0.35'; 239 sibling.style.pointerEvents = toggle.checked ? '' : 'none'; 240 sibling = sibling.nextElementSibling; 241 } 242} 243
244/** Load/save a confidence slider backed by a float pref (0 = "All", else 245 * 0.50â1.00). Slider position is the percent; the MAX position means "All" 246 * (no minimum), and sliding down sets the bar to 95%, 90%, 85%⦠*/ 247async function syncPrefSlider(elementId, prefKey, labelId, defaultVal) { 248 const slider = document.getElementById(elementId); 249 if (!slider) return; 250 const label = labelId ? document.getElementById(labelId) : null; 251 const maxPos = Number(slider.max); 252 const fmt = (pos) => (Number(pos) >= maxPos ? 'All' : `⥠${pos}%`); 253 try { 254 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`); 255 if (res.ok) { 256 const data = await res.json(); 257 let pref = (data.value === undefined || data.value === null) ? defaultVal : Number(data.value); 258 // pref 0 (or falsy) = "All" â max slider position; else percent. 259 let pos = (!pref || pref <= 0) ? maxPos : Math.round(pref * 100); 260 pos = Math.max(Number(slider.min), Math.min(maxPos, pos)); 261 slider.value = String(pos); 262 } 263 } catch (e) { 264 console.error(`Failed to load ${prefKey} pref:`, e); 265 } 266 if (label) label.textContent = fmt(slider.value); 267 if (!slider.dataset.bound) { 268 slider.dataset.bound = '1'; 269 slider.addEventListener('input', () => { if (label) label.textContent = fmt(slider.value); }); 270 slider.addEventListener('change', async () => { 271 const pos = Number(slider.value); 272 const pref = pos >= maxPos ? 0 : pos / 100;
273 try { 274 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, { 275 method: 'PUT', 276 headers: { 'Content-Type': 'application/json' }, 277 body: JSON.stringify({ value: pref }) 278 }); 279 if (!res.ok) { showError('Failed to save preference'); return; } 280 showToast(pref === 0 ? 'Skill confidence: All' : `Skill confidence ⥠${Math.round(pref * 100)}%`); 281 } catch (e) { 282 console.error(`Failed to save ${prefKey} pref:`, e); 283 showError('Failed to save preference'); 284 } 285 }); 286 } 287} 288 289/** Load/save an integer-valued pref backed by a <input type="number">. */ 290async function syncPrefNumber(elementId, prefKey, defaultVal) { 291 const input = document.getElementById(elementId); 292 if (!input) return; 293 const clamp = (raw) => { 294 let v = parseInt(raw, 10); 295 if (isNaN(v)) v = defaultVal; 296 const lo = Number(input.min), hi = Number(input.max); 297 if (!isNaN(lo)) v = Math.max(lo, v); 298 if (!isNaN(hi)) v = Math.min(hi, v); 299 return v; 300 }; 301 try { 302 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`); 303 if (res.ok) { 304 const data = await res.json(); 305 input.value = String((data.value === undefined || data.value === null) ? defaultVal : clamp(data.value)); 306 } 307 } catch (e) { 308 console.error(`Failed to load ${prefKey} pref:`, e); 309 } 310 if (!input.dataset.bound) { 311 input.dataset.bound = '1'; 312 input.addEventListener('change', async () => { 313 const v = clamp(input.value); 314 input.value = String(v); 315 try { 316 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, { 317 method: 'PUT', 318 headers: { 'Content-Type': 'application/json' }, 319 body: JSON.stringify({ value: v }) 320 }); 321 if (!res.ok) { showError('Failed to save preference'); return; } 322 showToast(v === 0 ? 'No skills injected' : `Max injected skills: ${v}`); 323 } catch (e) { 324 console.error(`Failed to save ${prefKey} pref:`, e); 325 showError('Failed to save preference'); 326 } 327 }); 328 } 329} 330 331async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true) { 332 const toggle = document.getElementById(elementId); 333 if (!toggle) return; 334 try { 335 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`); 336 if (res.ok) { 337 const data = await res.json(); 338 toggle.checked = data.value !== false; 339 } 340 } catch (e) { 341 console.error(`Failed to load ${prefKey} pref:`, e); 342 } 343 if (dimBelow) syncToggleDim(toggle); 344 if (!toggle.dataset.bound) { 345 toggle.dataset.bound = '1'; 346 toggle.addEventListener('change', async () => { 347 if (dimBelow) syncToggleDim(toggle);
348 try { 349 const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, { 350 method: 'PUT', 351 headers: { 'Content-Type': 'application/json' }, 352 body: JSON.stringify({ value: toggle.checked }) 353 }); 354 if (!res.ok) { 355 console.error(`PUT ${prefKey} returned ${res.status}`); 356 toggle.checked = !toggle.checked; // revert 357 if (dimBelow) syncToggleDim(toggle); 358 showError('Failed to save preference'); 359 return; 360 } 361 showToast(toggle.checked ? onMsg : offMsg); 362 } catch (e) { 363 console.error(`Failed to save ${prefKey} pref:`, e); 364 toggle.checked = !toggle.checked; // revert 365 if (dimBelow) syncToggleDim(toggle); 366 showError('Failed to save preference'); 367 } 368 }); 369 } 370} 371 372export async function loadMemories() { 373 _ensureNewMemoryCategorySelect(); 374 memoriesLoading = true; 375 renderMemoryList(); 376 updateMemoryCount(); 377 try { 378 const response = await fetch(`${window.location.origin}/api/memory`); 379 380 if (!response.ok) { 381 console.error('Memory fetch failed with status:', response.status); 382 memories = []; 383 memoriesLoading = false; 384 buildCategoryChips(); 385 renderMemoryList(); 386 updateMemoryCount(); 387 syncToggles(); 388 return; 389 } 390 391 const data = await response.json(); 392 393 if (data && data.memory) { 394 memories = data.memory; 395 } else if (Array.isArray(data)) { 396 memories = data; 397 } else { 398 memories = []; 399 } 400 401 memoriesLoading = false; 402 buildCategoryChips(); 403 renderMemoryList(); 404 updateMemoryCount(); 405 } catch (error) { 406 console.error('Failed to load memories:', error); 407 memories = []; 408 memoriesLoading = false; 409 buildCategoryChips(); 410 renderMemoryList(); 411 updateMemoryCount(); 412 } 413 // Always wire toggles, even if memory API failed 414 syncToggles(); 415} 416 417// ---- Bulk select mode ---- 418 419const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewB
419ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>'; 420const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; 421 422function enterSelectMode() { 423 selectMode = true; 424 selectedIds.clear(); 425 const bulkBar = document.getElementById('memory-bulk-bar'); 426 const selectBtn = document.getElementById('memory-select-btn'); 427 if (bulkBar) bulkBar.classList.remove('hidden'); 428 if (selectBtn) { selectBtn.classList.add('active'); selectBtn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; } 429 updateBulkCount(); 430 renderMemoryList(); 431} 432 433function exitSelectMode() { 434 selectMode = false; 435 selectedIds.clear(); 436 const bulkBar = document.getElementById('memory-bulk-bar'); 437 const selectBtn = document.getElementById('memory-select-btn'); 438 const selectAll = document.getElementById('memory-select-all'); 439 if (bulkBar) bulkBar.classList.add('hidden'); 440 if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; } 441 if (selectAll) selectAll.checked = false; 442 renderMemoryList(); 443} 444 445function toggleSelectItem(id) { 446 if (selectedIds.has(id)) { 447 selectedIds.delete(id); 448 } else { 449 selectedIds.add(id); 450 } 451 updateBulkCount(); 452} 453 454function updateBulkCount() { 455 const countEl = document.getElementById('memory-selected-count'); 456 const deleteBtn = document.getElementById('memory-bulk-delete'); 457 if (countEl) countEl.textContent = `${selectedIds.size} Selected`; 458 if (deleteBtn) deleteBtn.disabled = selectedIds.size === 0; 459} 460 461function toggleSelectAll() { 462 const selectAllEl = document.getElementById('memory-select-all'); 463 if (!selectAllEl) return; 464 465 if (selectAllEl.checked) { 466 // Select all currently visible/filtered items 467 const visible = getFilteredMemories(); 468 visible.forEach(m => selectedIds.add(m.id)); 469 } else { 470 selectedIds.clear(); 471 } 472 updateBulkCount(); 473 renderMemoryList(); 474} 475 476async function bulkDelete() { 477 if (selectedIds.size === 0) return; 478 const count = selectedIds.size; 479 if (!await uiModule.styledConfirm(`Delete ${count} ${count === 1 ? 'memory' : 'memories'}?`, { confirmText: 'Delete', danger: true })) return; 480 481 let deleted = 0; 482 const deletedIds = []; 483 for (const id of selectedIds) { 484 try { 485 const res = await fetch(`${window.location.origin}/api/memory/${id}`, { method: 'DELETE' }); 486 if (res.ok) { 487 deleted++; 488 deletedIds.push(id); 489 } 490 } catch (e) { 491 console.error('Failed to delete memory:', id, e); 492 } 493 } 494 495 await animateMemoryRemoval(deletedIds); 496 exitSelectMode(); 497 await loadMemories(); 498 showToast(`Deleted ${deleted} ${deleted === 1 ? 'memory' : 'memories'}`); 499} 500 501// ---- Tidy (audit) ---- 502 503export async function tidyMemories() { 504 const tidyBtn = document.getElementById('memory-tidy-btn'); 505 let tidySpinner = null; 506 if (tidyBtn) { 507 tidyBtn.disabled = true; 508 tidyBtn.textContent = ''; 509 // Drop the button border while the whirlpool spins â just the spinner, 510 // no box around it (restored in the finally below). 511 tidyBtn.style.border = 'none'; 512 tidyBtn.style.background = 'none'; 513 tidySpinner = spinnerModule.create('', 'clean', 'whirlpool'); 514 const _spEl = tidySpinner.createElement(); 515 _spEl.style.position = 'relative'; 516 _spEl.style.top = '1px'; 517 tidyBtn.appendChild(_spEl); 518 tidySpinner.start(); 519 } 520 521 // Snapshot current state for diffing 522 const beforeMap = new Map(memories.map(m => [m.id, { ...m }])); 523 524 try { 525 const res = await fetch(`${window.location.origin}/api/memory/audit`, { 526 method: 'POST', 527 }); 528 529 if (!res.ok) { 530 const err = await res.json().catch(() => ({})); 531 throw new Error(err.detail || 'Audit failed'); 532 } 533 534 const data = await res.json(); 535 if ((data.removed || 0) === 0) { 536 if (tidySpinner) tidySpinner.destroy(); 537 if (tidyBtn) { tidyBtn.disabled = false;
537 tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; } 538 showToast('Already clean'); 539 return; 540 } 541 542 // Fetch the new state 543 const freshRes = await fetch(`${window.location.origin}/api/memory`); 544 const freshData = await freshRes.json(); 545 const afterList = freshData.memory || freshData || []; 546 const afterMap = new Map(afterList.map(m => [m.id, m])); 547 548 // Compute diff 549 const removed = []; // IDs that no longer exist 550 const edited = []; // IDs where text changed 551 for (const [id, oldMem] of beforeMap) { 552 if (!afterMap.has(id)) { 553 removed.push(id); 554 } else if (afterMap.get(id).text !== oldMem.text) { 555 edited.push({ id, oldText: oldMem.text, newText: afterMap.get(id).text }); 556 } 557 } 558 559 if (tidySpinner) tidySpinner.updateMessage('Tidying memories'); 560 561 // Animate the diff on the currently rendered list 562 await animateTidyDiff(removed, edited); 563 564 // Now load the clean state 565 memories = afterList; 566 buildCategoryChips(); 567 renderMemoryList(); 568 updateMemoryCount(); 569 570 showToast(`Tidied: ${data.removed} removed (${data.before} \u2192 ${data.after})`); 571 } catch (error) { 572 console.error('Tidy failed:', error); 573 showError('Tidy failed â check console'); 574 } finally { 575 if (tidySpinner) tidySpinner.destroy(); 576 if (tidyBtn) { 577 tidyBtn.disabled = false; 578 tidyBtn.style.border = ''; 579 tidyBtn.style.background = '';
580 tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; 581 } 582 } 583} 584 585function sleep(ms) { 586 return new Promise(r => setTimeout(r, ms)); 587} 588 589async function animateMemoryRemoval(ids) { 590 const idSet = new Set([...ids].map(id => String(id))); 591 const memoryList = document.getElementById('memory-list'); 592 if (!memoryList || !idSet.size) return; 593 const items = Array.from(memoryList.querySelectorAll('.memory-item[data-memory-id]')) 594 .filter(el => idSet.has(String(el.dataset.memoryId))); 595 if (!items.length) return; 596 for (const el of items) { 597 el.style.maxHeight = `${Math.max(el.getBoundingClientRect().height, el.scrollHeight)}px`; 598 el.classList.add('memory-tidy-removing'); 599 } 600 await sleep(520); 601} 602 603async function animateTidyDiff(removedIds, editedItems) { 604 const memoryList = document.getElementById('memory-list'); 605 if (!memoryList) return; 606 607 // Tag each rendered item with its memory ID for lookup 608 const items = memoryList.querySelectorAll('.memory-item'); 609 const itemMap = new Map(); 610 const filtered = getFilteredMemories(); 611 items.forEach((el, i) => { 612 if (filtered[i]) itemMap.set(filtered[i].id, el); 613 }); 614 615 // Animate edits first â show text morphing 616 for (const { id, oldText, newText } of editedItems) { 617 const el = itemMap.get(id); 618 if (!el) continue; 619 620 const textEl = el.querySelector('.memory-item-text'); 621 if (!textEl) continue; 622 623 el.classList.add('memory-tidy-editing'); 624 textEl.classList.add('memory-tidy-text-old'); 625 await sleep(300); 626 627 textEl.textContent = newText; 628 textEl.classList.remove('memory-tidy-text-old'); 629 textEl.classList.add('memory-tidy-text-new'); 630 await sleep(400); 631 632 el.classList.remove('memory-tidy-editing'); 633 textEl.classList.remove('memory-tidy-text-new'); 634 await sleep(100); 635 } 636 637 // Animate removals â strikethrough then fade out 638 for (const id of removedIds) { 639 const el = itemMap.get(id); 640 if (!el) continue; 641 642 el.classList.add('memory-tidy-removing'); 643 await sleep(200); 644 } 645 646 // Let all removals animate together, then wait for them to finish 647 if (removedIds.length > 0) { 648 await sleep(500); 649 } 650} 651 652// ---- Filtering helper ---- 653 654function getFilteredMemories() { 655 const searchTerm = document.getElementById('memory-search')?.value?.toLowerCase().trim() || ''; 656 657 let filtered = searchTerm 658 ? memories.filter(m => m.text && m.text.toLowerCase().includes(searchTerm)) 659 : [...memories]; 660 661 if (activeCategory !== 'all') { 662 filtered = filtered.filter(m => (m.category || 'fact') === activeCategory); 663 } 664 665 const sortSelect = document.getElementById('memory-sort'); 666 const sort = sortSelect ? sortSelect.value : sortOrder; 667 if (sort === 'newest') { 668 filtered.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); 669 } else if (sort === 'oldest') { 670 filtered.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); 671 } else if (sort === 'alpha') { 672 filtered.sort((a, b) => (a.text || '').localeCompare(b.text || '')); 673 } else if (sort === 'uses') { 674 filtered.sort((a, b) => (b.uses || 0) - (a.uses || 0) || (b.timestamp || 0) - (a.timestamp || 0)); 675 } 676 677 // Pinned always float to top 678 filtered.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)); 679 680 return filtered; 681} 682 683// ---- Render ---- 684 685export function renderMemoryList() { 686 const memoryList = document.getElementById('memory-list'); 687 if (!memoryList) { 688 console.error('Memory list element not found'); 689 return; 690 } 691 692 const filtered = getFilteredMemories(); 693 memoryList.innerHTML = ''; 694 695 if (filtered.length === 0) { 696 const selectBtn = document.getElementById('memory-select-btn'); 697 if (selectBtn) selectBtn.disabled = true; 698 if (selectMode) exitSelectMode(); 699 if (memoriesLoading) { 700 const row = spinnerModule.createLoadingRow('Loading memories...', 14); 701 row.classList.add('memory-empty'); 702 memoryList.replaceChildren(row); 703 return; 704 } 705 const searchTerm = document.getElementById('memory-search')?.value?.trim() || ''; 706 const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>'; 707 if (searchTerm || activeCategory !== 'all') { 708 memoryList.innerHTML = `<div class="memory-empty">No matches.</div>`; 709 } else { 710 memoryList.innerHTML = `<div class="memory-empty" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;">
711 <span>No memories yet${_smiley}</span> 712 <span style="opacity:0.7;font-size:11px;display:block;"> 713 <a href="#" data-mem-goto-add style="color:var(--accent,var(--red));text-decoration:underline;">Import in Add tab</a> 714 </span> 715 </div>`; 716 memoryList.querySelector('[data-mem-goto-add]')?.addEventListener('click', (e) => { 717 e.preventDefault(); 718 document.querySelector('.memory-tab[data-memory-tab="add"]')?.click(); 719 }); 720 } 721 return; 722 } 723 724 const selectBtn = document.getElementById('memory-select-btn'); 725 if (selectBtn) selectBtn.disabled = false; 726 727 filtered.forEach(memory => { 728 const item = document.createElement('div'); 729 item.className = 'memory-item'; 730 item.dataset.memoryId = String(memory.id); 731 732 // Checkbox for select mode 733 if (selectMode) { 734 const cb = document.createElement('input'); 735 cb.type = 'checkbox'; 736 cb.className = 'memory-select-cb'; 737 cb.checked = selectedIds.has(memory.id); 738 cb.addEventListener('change', () => { 739 toggleSelectItem(memory.id); 740 const selectAllEl = document.getElementById('memory-select-all'); 741 if (selectAllEl) selectAllEl.checked = filtered.every(m => selectedIds.has(m.id)); 742 }); 743 item.appendChild(cb); 744 item.style.cursor = 'pointer'; 745 item.addEventListener('click', (e) => { 746 if (e.target === cb) return; 747 cb.checked = !cb.checked; 748 cb.dispatchEvent(new Event('change')); 749 }); 750 } 751 752 // Content: text + metadata 753 const content = document.createElement('div'); 754 content.className = 'memory-item-content'; 755 756 const textSpan = document.createElement('span'); 757 textSpan.className = 'memory-item-text'; 758 textSpan.textContent = memory.text; 759 760 const meta = document.createElement('div'); 761 meta.className = 'memory-item-meta'; 762 763 if (memory.pinned) { 764 const pinBadge = document.createElement('span'); 765 pinBadge.className = 'memory-cat-badge memory-cat-pinned'; 766 pinBadge.textContent = 'pinned'; 767 meta.appendChild(pinBadge); 768 } 769 770 const catBadge = document.createElement('span'); 771 const cat = memory.category || 'fact'; 772 catBadge.className = 'memory-cat-badge memory-cat-' + cat; 773 catBadge.textContent = cat; 774 meta.appendChild(catBadge); 775 776 const srcSpan = document.createElement('span'); 777 srcSpan.className = 'memory-item-source'; 778 srcSpan.textContent = memory.source === 'auto' ? 'auto' : 'manual'; 779 meta.appendChild(srcSpan); 780 781 const uses = Number(memory.uses || 0); 782 if (uses > 0) { 783 const useSpan = document.createElement('span'); 784 useSpan.className = 'memory-item-uses'; 785 useSpan.textContent = `${uses}Ã`; 786 useSpan.title = `Injected into chat context ${uses} time${uses === 1 ? '' : 's'}`; 787 meta.appendChild(useSpan); 788 } 789 790 if (memory.timestamp) { 791 const timeSpan = document.createElement('span'); 792 timeSpan.className = 'memory-item-time'; 793 timeSpan.textContent = relativeTime(memory.timestamp); 794 timeSpan.title = new Date(memory.timestamp * 1000).toLocaleString(); 795 meta.appendChild(timeSpan); 796 } 797 798 content.appendChild(textSpan); 799 content.appendChild(meta); 800 801 if (memory.pinned) item.classList.add('memory-pinned'); 802 803 item.appendChild(content); 804 805 // Double-click text to edit (not in select mode) 806 if (!selectMode) { 807 textSpan.addEventListener('dblclick', (e) => { 808 e.stopPropagation(); 809 startInlineEdit(item, memory); 810 }); 811 textSpan.style.cursor = 'text'; 812 } 813 814 // Menu button (hidden in select mode) 815 if (!selectMode) { 816 const menuBtn = document.createElement('button'); 817 menuBtn.className = 'memory-menu-btn'; 818 menuBtn.innerHTML = '\u22EE'; 819 menuBtn.title = 'Actions'; 820 821 const dropdown = document.createElement('div'); 822 dropdown.className = 'memory-item-dropdown'; 823 824 // Pin / Unpin â bookmark icon matches the chat-session "Favorite" SVG. 825 // Filled when pinned, outlined when not. 826 const _bookmarkPath = '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>'; 827 const _pinSvg = memory.pinned 828 ? `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_bookmarkPath}</svg>` 829 : `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_bookmarkPath}</svg>`; 830 const pinItem = document.createElement('div'); 831 pinItem.className = 'dropdown-item-compact'; 832 pinItem.innerHTML = `<span class="dropdown-icon">${_pinSvg}</span><span>${memory.pinned ? 'Unpin' : 'Pin'}</span>`;
833 pinItem.addEventListener('click', () => { dropdown.style.display = 'none'; togglePin(memory.id, !memory.pinned); }); 834 835 const editItem = document.createElement('div'); 836 editItem.className = 'dropdown-item-compact'; 837 editItem.textContent = 'â Edit'; 838 editItem.addEventListener('click', () => { dropdown.style.display = 'none'; startInlineEdit(item, memory); }); 839 840 const deleteItem = document.createElement('div'); 841 deleteItem.className = 'dropdown-item-compact memory-dropdown-delete'; 842 deleteItem.textContent = 'â Delete'; 843 deleteItem.addEventListener('click', () => { dropdown.style.display = 'none'; deleteMemory(memory.id); }); 844 845 // Select â enters bulk-select mode and pre-selects this memory. Same 846 // pattern as the email/documents/skills Select item. 847 const selectItem = document.createElement('div'); 848 selectItem.className = 'dropdown-item-compact'; 849 selectItem.innerHTML = '<span class="dropdown-icon"><span style="font-size:16px;line-height:1;">â</span></span><span>Select</span>'; 850 selectItem.addEventListener('click', (e) => { 851 e.stopPropagation(); 852 if (dropdown.parentNode) dropdown.remove(); 853 if (!selectMode) enterSelectMode(); 854 selectedIds.add(memory.id); 855 updateBulkCount(); 856 renderMemoryList(); 857 }); 858 859 // Mobile-only Cancel â mirrors the email/documents popup pattern. CSS 860 // hides `.dropdown-cancel-mobile` on desktop where outside-click already 861 // dismisses cleanly. 862 const cancelItem = document.createElement('div'); 863 cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile'; 864 cancelItem.textContent = 'â Cancel'; 865 cancelItem.addEventListener('click', (e) => { e.stopPropagation(); if (dropdown.parentNode) dropdown.remove(); }); 866 867 dropdown.appendChild(pinItem); 868 dropdown.appendChild(selectItem); 869 dropdown.appendChild(editItem); 870 dropdown.appendChild(deleteItem); 871 dropdown.appendChild(cancelItem); 872 873 menuBtn.addEventListener('click', (e) => { 874 e.stopPropagation(); 875 // Close any other open dropdowns 876 document.querySelectorAll('.memory-item-dropdown').forEach(d => d.remove()); 877 const rect = menuBtn.getBoundingClientRect(); 878 dropdown.style.position = 'fixed'; 879 dropdown.style.top = rect.bottom + 2 + 'px'; 880 dropdown.style.right = (window.innerWidth - rect.right) + 'px'; 881 dropdown.style.left = 'auto'; 882 // Portaled to <body>, so it must outrank the Brain modal it belongs to. 883 // Tool modals get a monotonically increasing z-index from modalManager's 884 // bring-to-front counter, which climbs unbounded over a long session â 885 // once it passed the old hardcoded 10001 the menu rendered behind the 886 // panel (#4720). topPortalZ() derives the value from the live tool-window 887 // stack so the menu always sits just above, however high it has climbed. 888 dropdown.style.zIndex = String(topPortalZ()); 889 dropdown.style.display = 'block'; 890 document.body.appendChild(dropdown); 891 // Keep on-screen (mobile): flip above the button if it overflows the 892 // bottom, clamp the left edge, cap height as a last resort. 893 const dr = dropdown.getBoundingClientRect(); 894 if (dr.bottom > window.innerHeight - 6) { 895 dropdown.style.top = Math.max(6, rect.top - dr.height - 2) + 'px'; 896 } 897 if (dr.left < 6) { 898 dropdown.style.right = Math.max(6, window.innerWidth - 6 - dr.width) + 'px'; 899 } 900 const dr2 = dropdown.getBoundingClientRect(); 901 if (dr2.bottom > window.innerHeight - 6) { 902 dropdown.style.maxHeight = Math.max(80, window.innerHeight - 12 - dr2.top) + 'px'; 903 dropdown.style.overflowY = 'auto'; 904 } 905 906 // Swipe-down-to-dismiss â mirrors the documents library popup gesture. 907 // Drag the popup down past ~60px and release to close; release earlier 908 // and it snaps back. Vertical-only; horizontal flicks fall through. 909 let _sw = null; 910 let _swDy = 0; 911 const _onTS = (ev) => { 912 if (ev.touches.length !== 1) return; 913 _sw = { x: ev.touches[0].clientX, y: ev.touches[0].clientY }; 914 _swDy = 0; 915 dropdown.style.transition = ''; 916 }; 917 const _onTM = (ev) => { 918 if (!_sw || ev.touches.length !== 1) return; 919 const dx = ev.touches[0].clientX - _sw.x; 920 const dy = ev.touches[0].clientY - _sw.y; 921 if (Math.abs(dy) < Math.abs(dx)) { _sw = null; return; } 922 if (dy > 0) { 923 _swDy = dy; 924 dropdown.style.transform = 'translateY(' + dy + 'px)'; 925 dropdown.style.opacity = String(Math.max(0.3, 1 - dy / 240)); 926 } 927 }; 928 const _onTE = () => { 929 if (!_sw) return; 930 _sw = null; 931 if (_swDy > 60) { 932 dropdown.style.transition = 'transform 0.15s ease, opacity 0.15s ease'; 933 dropdown.style.transform = 'translateY(120px)'; 934 dropdown.style.opacity = '0'; 935 setTimeout(() => { if (dropdown.parentNode) dropdown.remove(); }, 160); 936 } else { 937 dropdown.style.transition = 'transform 0.18s ease, opacity 0.18s ease'; 938 dropdown.style.transform = ''; 939 dropdown.style.opacity = ''; 940 } 941 }; 942 dropdown.addEventListener('touchstart', _onTS, { passive: true }); 943 dropdown.addEventListener('touchmove', _onTM, { passive: true }); 944 dropdown.addEventListener('touchend', _onTE); 945 }); 946 947 item.appendChild(menuBtn); 948 949 // Long-press anywhere on the card opens the same dropdown â mirrors the 950 // documents library pattern. Skip when the touch starts on the kebab, 951 // checkbox, or another button (those have their own click handlers). 952 { 953 let hold = null; 954 let start = null; 955 const _lpCancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; }; 956 item.addEventListener('pointerdown', (e) => { 957 if (e.target.closest('.memory-menu-btn, .memory-select-cb, button, input')) return; 958 start = { x: e.clientX, y: e.clientY }; 959 hold = setTimeout(() => { 960 hold = null; 961 item._suppressNextClick = true; 962 setTimeout(() => { item._suppressNextClick = false; }, 400); 963 if (navigator.vibrate) try { navigator.vibrate(15); } catch {} 964 menuBtn.click(); 965 }, 500); 966 }); 967 item.addEventListener('pointermove', (e) => { 968 if (!start) return; 969 if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) _lpCancel(); 970 }); 971 item.addEventListener('pointerup', _lpCancel); 972 item.addEventListener('pointercancel', _lpCancel); 973 } 974 975 // Close dropdown on outside click 976 document.addEventListener('click', () => { if (dropdown.parentNode) dropdown.remove(); }, { once: false }); 977 } 978 979 memoryList.appendChild(item); 980 }); 981 982} 983 984// ---- Inline edit with category picker ---- 985 986function startInlineEdit(item, memory) { 987 item.innerHTML = ''; 988 item.className = 'memory-item memory-item-editing'; 989 990 const editRow = document.createElement('div'); 991 editRow.className = 'memory-edit-row'; 992 993 const input = document.createElement('input'); 994 input.type = 'text'; 995 input.className = 'memory-item-edit-input'; 996 input.value = memory.text; 997 998 const catSelect = document.createElement('select'); 999 catSelect.className = 'memory-edit-cat-select'; 1000 MEMORY_CATEGORIES.forEach(cat => { 1001 const opt = document.createElement('option'); 1002 opt.value = cat; 1003 opt.textContent = cat; 1004 if (cat === (memory.category || 'fact')) opt.selected = true; 1005 catSelect.appendChild(opt); 1006 }); 1007 1008 editRow.appendChild(input); 1009 editRow.appendChild(catSelect); 1010 1011 const actions = document.createElement('div'); 1012 actions.className = 'memory-item-actions'; 1013 actions.style.opacity = '1'; 1014 1015 const saveBtn = document.createElement('button'); 1016 saveBtn.className = 'memory-item-btn save'; 1017 saveBtn.textContent = 'save'; 1018 saveBtn.addEventListener('click', () => saveInlineEdit(memory.id, input.value, catSelect.value)); 1019 1020 const cancelBtn = document.createElement('button'); 1021 cancelBtn.className = 'memory-item-btn'; 1022 cancelBtn.textContent = 'cancel'; 1023 cancelBtn.addEventListener('click', () => renderMemoryList()); 1024 1025 actions.appendChild(saveBtn); 1026 actions.appendChild(cancelBtn); 1027 1028 item.appendChild(editRow); 1029 item.appendChild(actions); 1030 1031 input.focus(); 1032 input.select(); 1033 1034 input.addEventListener('keydown', (e) => { 1035 if (e.key === 'Enter') saveInlineEdit(memory.id, input.value, catSelect.value); 1036 if (e.key === 'Escape') { 1037 e.stopPropagation(); 1038 e.stopImmediatePropagation(); 1039 renderMemoryList(); 1040 } 1041 }); 1042} 1043 1044async function saveInlineEdit(id, newText, newCategory) { 1045 newText = newText.trim(); 1046 if (!newText) return; 1047 1048 const memory = memories.find(m => m.id === id); 1049 const catChanged = newCategory && newCategory !== (memory?.category || 'fact'); 1050 if (!memory || (newText === memory.text && !catChanged)) { 1051 renderMemoryList(); 1052 return; 1053 } 1054 1055 try { 1056 const params = new URLSearchParams({ text: newText }); 1057 if (newCategory) params.append('category', newCategory); 1058 1059 const response = await fetch(`${window.location.origin}/api/memory/${id}`, { 1060 method: 'PUT', 1061 body: params 1062 }); 1063 1064 if (response.ok) { 1065 await loadMemories(); 1066 showToast('Memory updated'); 1067 } else { 1068 const errorData = await response.json(); 1069 throw new Error(errorData.detail || 'Failed to update memory'); 1070 } 1071 } catch (error) { 1072 console.error('Error updating memory:', error); 1073 showError('Failed to update memory'); 1074 } 1075} 1076 1077export function updateMemoryCount() { 1078 const h2Count = document.getElementById('memory-count-h2'); 1079 const tabCount = document.getElementById('memory-count'); // optional (may be absent) 1080 if (!h2Count && !tabCount) return; 1081 if (memoriesLoading) { 1082 if (h2Count) h2Count.textContent = 'loading...'; 1083 if (tabCount) tabCount.textContent = '...'; 1084 return; 1085 } 1086 1087 const searchInput = document.getElementById('memory-search'); 1088 const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ''; 1089 1090 let visible = memories; 1091 const scopeTotal = visible.length; 1092 if (searchTerm) { 1093 visible = visible.filter(m => m.text && m.text.toLowerCase().includes(searchTerm)); 1094 } 1095 if (activeCategory !== 'all') { 1096 visible = visible.filter(m => (m.category || 'fact') === activeCategory); 1097 } 1098 1099 const num = visible.length === scopeTotal ? `${scopeTotal}` : `${visible.length}/${scopeTotal}`; 1100 // Header (next to the "Memories" title) reads "N memories", like the 1101 // Documents header. The bare number still feeds any tab badge if present. 1102 if (h2Count) h2Count.textContent = `${num} ${scopeTotal === 1 && visible.length === scopeTotal ? 'memory' : 'memories'}`; 1103 if (tabCount) tabCount.textContent = num; 1104} 1105
1106export async function addNewMemory() { 1107 const input = document.getElementById('new-memory-input'); 1108 const text = input.value.trim(); 1109 const category = _readNewMemoryCategory(); 1110 1111 if (!text) { 1112 showError('Memory text cannot be empty'); 1113 return; 1114 } 1115 1116 try { 1117 const response = await fetch(`${window.location.origin}/api/memory/add`, { 1118 method: 'POST', 1119 headers: { 1120 'Content-Type': 'application/json', 1121 }, 1122 body: JSON.stringify({ 1123 text: text, 1124 category: category, 1125 }) 1126 }); 1127 1128 if (response.ok) { 1129 input.value = ''; 1130 await loadMemories(); 1131 showToast('Memory added'); 1132 } else { 1133 const errorData = await response.json(); 1134 console.error('Server error details:', errorData); 1135 throw new Error(errorData.detail || 'Failed to add memory'); 1136 } 1137 } catch (error) { 1138 console.error('Error adding memory:', error); 1139 showError('Failed to add memory'); 1140 } 1141} 1142 1143export async function editMemory(id) { 1144 const memory = memories.find(m => m.id === id); 1145 if (!memory) return; 1146 1147 const newText = prompt('Edit memory:', memory.text); 1148 if (!newText || newText === memory.text) return; 1149 1150 await saveInlineEdit(id, newText); 1151} 1152 1153async function togglePin(id, pinned) { 1154 try { 1155 const res = await fetch(`${window.location.origin}/api/memory/${id}/pin`, { 1156 method: 'POST', 1157 body: new URLSearchParams({ pinned: pinned.toString() }) 1158 }); 1159 if (res.ok) { 1160 const mem = memories.find(m => m.id === id); 1161 if (mem) mem.pinned = pinned; 1162 renderMemoryList(); 1163 showToast(pinned ? 'Pinned â always in context' : 'Unpinned â RAG only'); 1164 } 1165 } catch (e) { 1166 console.error('Failed to toggle pin:', e); 1167 showError('Failed to update pin'); 1168 } 1169} 1170 1171export async function deleteMemory(id) { 1172 const memory = memories.find(m => m.id === id); 1173 if (!memory) return; 1174 1175 if (!await uiModule.styledConfirm(`Delete this memory?\n"${memory.text}"`, { confirmText: 'Delete', danger: true })) return; 1176 1177 try { 1178 const response = await fetch(`${window.location.origin}/api/memory/${id}`, { 1179 method: 'DELETE' 1180 }); 1181 1182 if (response.ok) { 1183 await animateMemoryRemoval([id]); 1184 await loadMemories(); 1185 showToast('Memory deleted'); 1186 } else { 1187 throw new Error('Failed to delete'); 1188 } 1189 } catch (error) { 1190 showError('Failed to delete memory'); 1191 } 1192} 1193 1194export async function extractMemory(sessionId) { 1195 const res = await fetch(`${window.location.origin}/api/memory/extract`, { 1196 method: 'POST', 1197 body: new URLSearchParams({ session: sessionId }) 1198 }); 1199 if (!res.ok) { 1200 showError('Failed to extract memory suggestions'); 1201 return; 1202 } 1203 const data = await res.json(); 1204 const suggestions = data.suggestions || []; 1205 1206 const modal = document.getElementById('memory-modal'); 1207 const body = document.getElementById('memory-suggestions-body'); 1208 if (!body) { 1209 console.error('memory-suggestions-body element not found'); 1210 return; 1211 } 1212 1213 body.innerHTML = ''; 1214 body.classList.remove('hidden'); 1215 1216 const memList = document.getElementById('memory-list'); 1217 if (memList) memList.classList.add('hidden'); 1218 1219 if (suggestions.length === 0) { 1220 body.innerHTML = '<div class="memory-empty">No useful information detected.</div>'; 1221 } else { 1222 const header = document.createElement('div'); 1223 header.className = 'memory-suggestions-header'; 1224 header.innerHTML = '<span>Suggested memories</span>'; 1225 const backBtn = document.createElement('button'); 1226 backBtn.className = 'memory-item-btn'; 1227 backBtn.textContent = 'back'; 1228 backBtn.addEventListener('click', () => { 1229 body.classList.add('hidden'); 1230 body.innerHTML = ''; 1231 if (memList) memList.classList.remove('hidden'); 1232 }); 1233 header.appendChild(backBtn); 1234 body.appendChild(header); 1235 1236 suggestions.forEach(s => { 1237 const div = document.createElement('div'); 1238 div.className = 'memory-suggestion-item'; 1239 const txt = document.createElement('span'); 1240 txt.className = 'memory-item-text'; 1241 txt.textContent = s; 1242 const btn = document.createElement('button'); 1243 btn.className = 'memory-item-btn save'; 1244 btn.textContent = 'save'; 1245 btn.addEventListener('click', async () => { 1246 await fetch(`${window.location.origin}/api/memory/add`, { 1247 method: 'POST', 1248 headers: { 'Content-Type': 'application/json' }, 1249 body: JSON.stringify({ text: s }) 1250 }); 1251 btn.disabled = true; 1252 btn.textContent = 'saved'; 1253 showToast('Saved to memory'); 1254 }); 1255 div.appendChild(txt); 1256 div.appendChild(btn); 1257 body.appendChild(div); 1258 }); 1259 } 1260 1261 modal.classList.remove('hidden'); 1262} 1263 1264// ---- Export ---- 1265 1266export function exportMemories() { 1267 if (!memories || memories.length === 0) { 1268 showToast('No memories to export'); 1269 return; 1270 } 1271 const data = JSON.stringify(memories, null, 2); 1272 const blob = new Blob([data], { type: 'application/json' }); 1273 const url = URL.createObjectURL(blob); 1274 const a = document.createElement('a'); 1275 a.href = url; 1276 a.download = 'memories.json'; 1277 a.click(); 1278 URL.revokeObjectURL(url); 1279 showToast(`Exported ${memories.length} memories`); 1280} 1281 1282// ---- Import from file ---- 1283 1284export async function importMemories() { 1285 const fileInput = document.getElementById('memory-import-file'); 1286 if (!fileInput) return; 1287 fileInput.click(); 1288} 1289 1290async function handleImportFile(file) { 1291 if (!file) return; 1292 1293 const sessionId = sessionModule?.getCurrentSessionId?.(); 1294 1295 const importBtn = document.getElementById('memory-import-btn'); 1296 const _origImportHtml = importBtn ? importBtn.innerHTML : ''; 1297 let importSpin = null; 1298 if (importBtn) { 1299 importBtn.disabled = true; 1300 importBtn.innerHTML = ''; 1301 importSpin = spinnerModule.createWhirlpool(12); 1302 importSpin.element.style.cssText = 'width:12px;height:12px;margin:0 5px 0 0;display:inline-flex;vertical-align:-2px;transform:translateY(-1px);'; 1303 importBtn.appendChild(importSpin.element); 1304 importBtn.appendChild(document.createTextNode('Importing')); 1305 } 1306 1307 try { 1308 const formData = new FormData(); 1309 formData.append('file', file); 1310 if (sessionId) { 1311 formData.append('session', sessionId); 1312 } 1313 1314 const res = await fetch(`${window.location.origin}/api/memory/import`, { 1315 method: 'POST', 1316 body: formData 1317 }); 1318 1319 if (!res.ok) { 1320 const err = await res.json().catch(() => ({})); 1321 throw new Error(err.detail || 'Import failed'); 1322 } 1323 1324 const data = await res.json(); 1325 const suggestions = data.suggestions || []; 1326 1327 // Show suggestions using the existing suggestions UI 1328 const modal = document.getElementById('memory-modal'); 1329 const body = document.getElementById('memory-suggestions-body'); 1330 if (!body) return; 1331 1332 body.innerHTML = ''; 1333 body.classList.remove('hidden'); 1334 1335 const memList = document.getElementById('memory-list'); 1336 if (memList) memList.classList.add('hidden'); 1337 1338 if (suggestions.length === 0) { 1339 body.innerHTML = '<div class="memory-empty">No useful information found in file.</div>'; 1340 } else { 1341 const reviewItems = suggestions 1342 .map((s) => ({ 1343 text: typeof s === 'string' ? s : s.text, 1344 category: (typeof s === 'object' && s.category) || 'fact', 1345 active: true, 1346 })) 1347 .filter((s) => s.text); 1348 const header = document.createElement('div'); 1349 header.className = 'memory-suggestions-header'; 1350 const headerTitle = document.createElement('span'); 1351 const updateHeaderTitle = () => { 1352 const remaining = reviewItems.filter((item) => item.active).length; 1353 headerTitle.textContent = `Imported from ${data.filename || file.name} (${remaining}) Review`; 1354 }; 1355 updateHeaderTitle(); 1356 const headerActions = document.createElement('div'); 1357 headerActions.className = 'memory-suggestions-actions'; 1358 const backBtn = document.createElement('button'); 1359 backBtn.className = 'memory-item-btn'; 1360 backBtn.textContent = 'back'; 1361 backBtn.addEventListener('click', () => { 1362 body.classList.add('hidden'); 1363 body.innerHTML = ''; 1364 if (memList) memList.classList.remove('hidden'); 1365 }); 1366 const saveAllBtn = document.createElement('button'); 1367 saveAllBtn.className = 'memory-item-btn save'; 1368 saveAllBtn.textContent = 'save all'; 1369 saveAllBtn.addEventListener('click', async () => { 1370 let saved = 0; 1371 for (const s of reviewItems) { 1372 if (!s.active || !s.text) continue; 1373 try { 1374 await fetch(`${window.location.origin}/api/memory/add`, { 1375 method: 'POST', 1376 headers: { 'Content-Type': 'application/json' }, 1377 body: JSON.stringify({ text: s.text, category: s.category }) 1378 }); 1379 saved++; 1380 } catch (e) { /* skip */ } 1381 } 1382 body.classList.add('hidden'); 1383 body.innerHTML = ''; 1384 if (memList) memList.classList.remove('hidden'); 1385 await loadMemories(); 1386 document.querySelector('.memory-tab[data-memory-tab="browse"]')?.click(); 1387 showToast(`Saved ${saved} memories`); 1388 }); 1389 headerActions.appendChild(saveAllBtn); 1390 headerActions.appendChild(backBtn); 1391 header.appendChild(headerTitle); 1392 header.appendChild(headerActions); 1393 body.appendChild(header); 1394
1395 reviewItems.forEach(item => { 1396 const div = document.createElement('div'); 1397 div.className = 'memory-suggestion-item'; 1398 1399 const content = document.createElement('div'); 1400 content.className = 'memory-item-content'; 1401 const txt = document.createElement('span'); 1402 txt.className = 'memory-item-text'; 1403 txt.textContent = item.text; 1404 const catBadge = document.createElement('span'); 1405 catBadge.className = 'memory-cat-badge memory-cat-' + item.category; 1406 catBadge.textContent = item.category; 1407 content.appendChild(txt); 1408 content.appendChild(catBadge); 1409 1410 const actionWrap = document.createElement('div'); 1411 actionWrap.className = 'memory-suggestion-actions'; 1412 const btn = document.createElement('button'); 1413 btn.className = 'memory-item-btn save'; 1414 btn.textContent = 'save'; 1415 btn.addEventListener('click', async () => { 1416 await fetch(`${window.location.origin}/api/memory/add`, { 1417 method: 'POST', 1418 headers: { 'Content-Type': 'application/json' }, 1419 body: JSON.stringify({ text: item.text, category: item.category }) 1420 }); 1421 item.active = false; 1422 div.remove(); 1423 updateHeaderTitle(); 1424 btn.disabled = true; 1425 btn.textContent = 'saved'; 1426 showToast('Saved to memory'); 1427 }); 1428 const deleteBtn = document.createElement('button'); 1429 deleteBtn.className = 'memory-item-btn delete'; 1430 deleteBtn.textContent = 'delete'; 1431 deleteBtn.addEventListener('click', () => { 1432 item.active = false; 1433 div.remove(); 1434 updateHeaderTitle(); 1435 }); 1436 actionWrap.appendChild(btn); 1437 actionWrap.appendChild(deleteBtn); 1438 1439 div.appendChild(content); 1440 div.appendChild(actionWrap); 1441 body.appendChild(div); 1442 }); 1443 } 1444 1445 modal.classList.remove('hidden'); 1446 document.querySelector('.memory-tab[data-memory-tab="browse"]')?.click(); 1447 } catch (error) { 1448 console.error('Import failed:', error); 1449 showError('Import failed â ' + error.message); 1450 } finally { 1451 if (importSpin) importSpin.destroy(); 1452 if (importBtn) { 1453 importBtn.disabled = false; 1454 importBtn.innerHTML = _origImportHtml; 1455 } 1456 // Reset file input so the same file can be re-selected 1457 const fileInput = document.getElementById('memory-import-file'); 1458 if (fileInput) fileInput.value = ''; 1459 } 1460} 1461 1462// Utility aliases (canonical implementations live in uiModule) 1463var showToast = uiModule.showToast; 1464var showError = uiModule.showError; 1465 1466// Event listeners 1467document.addEventListener('DOMContentLoaded', () => { 1468 _wireMemoryDrag(); 1469 1470 // Memory modal tabs 1471 document.querySelectorAll('.memory-tab[data-memory-tab]').forEach(tab => { 1472 tab.addEventListener('click', () => { 1473 const target = tab.dataset.memoryTab; 1474 document.querySelectorAll('.memory-tab').forEach(t => t.classList.toggle('active', t === tab)); 1475 document.querySelectorAll('.memory-tab-panel[data-memory-panel]').forEach(p => { 1476 p.classList.toggle('hidden', p.dataset.memoryPanel !== target); 1477 }); 1478 // Lazy-load skills tab (cascade=true â play the domino-in entrance) 1479 if (target === 'skills') { 1480 import('./skills.js').then(m => { if (m.loadSkills) m.loadSkills(true); else if (m.default?.loadSkills) m.default.loadSkills(true); }); 1481 } 1482 }); 1483 }); 1484 1485 const sortSelect = document.getElementById('memory-sort'); 1486 if (sortSelect) { 1487 sortSelect.addEventListener('change', () => { 1488 sortOrder = sortSelect.value; 1489 renderMemoryList(); 1490 }); 1491 } 1492 _initMemorySortPicker(); 1493 1494 const tidyBtn = document.getElementById('memory-tidy-btn'); 1495 if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories); 1496 1497 const selectBtn = document.getElementById('memory-select-btn'); 1498 if (selectBtn) selectBtn.addEventListener('click', () => { 1499 if (selectMode) exitSelectMode(); 1500 else enterSelectMode(); 1501 }); 1502 1503 const selectAll = document.getElementById('memory-select-all'); 1504 if (selectAll) selectAll.addEventListener('change', toggleSelectAll); 1505 1506 const bulkBar = document.getElementById('memory-bulk-bar'); 1507 if (bulkBar) bulkBar.addEventListener('click', (e) => { 1508 if (e.target.closest('button') || e.target === selectAll) return; 1509 selectAll.checked = !selectAll.checked; 1510 selectAll.dispatchEvent(new Event('change')); 1511 }); 1512 1513 const bulkDeleteBtn = document.getElementById('memory-bulk-delete'); 1514 if (bulkDeleteBtn) bulkDeleteBtn.addEventListener('click', bulkDelete); 1515 1516 const bulkCancelBtn = document.getElementById('memory-bulk-cancel'); 1517 if (bulkCancelBtn) bulkCancelBtn.addEventListener('click', exitSelectMode); 1518 1519 const exportBtn = document.getElementById('memory-export-btn'); 1520 if (exportBtn) exportBtn.addEventListener('click', exportMemories); 1521 1522 const importBtn = document.getElementById('memory-import-btn'); 1523 if (importBtn) importBtn.addEventListener('click', importMemories); 1524 1525 const importFile = document.getElementById('memory-import-file'); 1526 if (importFile) importFile.addEventListener('change', (e) => { 1527 if (e.target.files[0]) handleImportFile(e.target.files[0]); 1528 }); 1529 1530 window.addEventListener('memory-refresh', () => { 1531 loadMemories(); 1532 }); 1533}); 1534 1535const memoryModule = { 1536 loadMemories, 1537 renderMemoryList, 1538 updateMemoryCount, 1539 addNewMemory, 1540 editMemory, 1541 deleteMemory, 1542 extractMemory, 1543 buildCategoryChips, 1544 tidyMemories, 1545 importMemories, 1546 exportMemories 1547}; 1548 1549export default memoryModule; 1550window.memoryModule = memoryModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.