PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/memory.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:43:30 UTC 57,954 bytes, 1,550 lines download raw bytes

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.