Kaynağa Gözat

delete memory, knowledge

Lukas Goldschmidt 1 gün önce
ebeveyn
işleme
873581380d
2 değiştirilmiş dosya ile 457 ekleme ve 293 silme
  1. 230 178
      dashboard.html
  2. 227 115
      mem0server.py

+ 230 - 178
dashboard.html

@@ -32,7 +32,6 @@
     font-size: 14px;
     font-weight: 300;
     min-height: 100vh;
-    /* subtle noise texture */
     background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
   }
 
@@ -48,13 +47,7 @@
     top: 0;
     z-index: 100;
   }
-
-  .header-left {
-    display: flex;
-    align-items: baseline;
-    gap: 14px;
-  }
-
+  .header-left { display: flex; align-items: baseline; gap: 14px; }
   .logo {
     font-family: var(--mono);
     font-size: 15px;
@@ -62,22 +55,9 @@
     letter-spacing: 0.08em;
     color: var(--accent);
   }
-
   .logo span { color: var(--muted); font-weight: 300; }
-
-  .tagline {
-    font-family: var(--mono);
-    font-size: 11px;
-    color: var(--muted);
-    letter-spacing: 0.04em;
-  }
-
-  .header-right {
-    display: flex;
-    align-items: center;
-    gap: 20px;
-  }
-
+  .tagline { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 0.04em; }
+  .header-right { display: flex; align-items: center; gap: 20px; }
   .status-pill {
     font-family: var(--mono);
     font-size: 11px;
@@ -89,16 +69,14 @@
     border-radius: 2px;
     background: var(--surface2);
   }
-
   .status-dot {
     width: 6px; height: 6px;
     border-radius: 50%;
     background: var(--muted);
     transition: background 0.3s;
   }
-  .status-dot.ok  { background: var(--ok);     box-shadow: 0 0 6px var(--ok); }
-  .status-dot.err { background: var(--danger);  box-shadow: 0 0 6px var(--danger); }
-
+  .status-dot.ok  { background: var(--ok);    box-shadow: 0 0 6px var(--ok); }
+  .status-dot.err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
   .btn-refresh {
     font-family: var(--mono);
     font-size: 11px;
@@ -121,7 +99,6 @@
     max-width: 1400px;
     margin: 0 auto;
   }
-
   .panel {
     padding: 28px 32px;
     border-right: 1px solid var(--border);
@@ -142,7 +119,6 @@
     padding-bottom: 14px;
     border-bottom: 1px solid var(--border);
   }
-
   .panel-title {
     font-family: var(--mono);
     font-size: 11px;
@@ -151,12 +127,7 @@
     text-transform: uppercase;
     color: var(--accent);
   }
-
-  .panel-count {
-    font-family: var(--mono);
-    font-size: 11px;
-    color: var(--muted);
-  }
+  .panel-count { font-family: var(--mono); font-size: 11px; color: var(--muted); }
 
   /* ── BOOK GROUPS ─────────────────────────────────────────── */
   .book-group {
@@ -167,7 +138,6 @@
     overflow: hidden;
     animation: fadeIn 0.3s ease both;
   }
-
   .book-header {
     display: flex;
     align-items: center;
@@ -178,14 +148,7 @@
     transition: background 0.15s;
   }
   .book-header:hover { background: var(--surface2); }
-
-  .book-header-left {
-    display: flex;
-    align-items: center;
-    gap: 10px;
-    min-width: 0;
-  }
-
+  .book-header-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
   .chevron {
     font-size: 10px;
     color: var(--muted);
@@ -193,7 +156,6 @@
     flex-shrink: 0;
   }
   .book-group.open .chevron { transform: rotate(90deg); }
-
   .book-name {
     font-family: var(--mono);
     font-size: 12px;
@@ -202,7 +164,6 @@
     overflow: hidden;
     text-overflow: ellipsis;
   }
-
   .book-badge {
     font-family: var(--mono);
     font-size: 10px;
@@ -214,29 +175,29 @@
     flex-shrink: 0;
   }
 
-  .btn-delete-book {
+  /* ── SHARED DELETE BUTTON STYLE ──────────────────────────── */
+  .btn-del {
     font-family: var(--mono);
     font-size: 10px;
     letter-spacing: 0.05em;
     background: none;
     border: 1px solid transparent;
     color: var(--muted);
-    padding: 3px 10px;
+    padding: 3px 8px;
     cursor: pointer;
     border-radius: 2px;
-    transition: color 0.2s, border-color 0.2s;
+    transition: color 0.15s, border-color 0.15s, background 0.15s;
     flex-shrink: 0;
+    white-space: nowrap;
   }
-  .btn-delete-book:hover {
-    color: var(--danger);
-    border-color: var(--danger);
-  }
-  .btn-delete-book.confirming {
+  .btn-del:hover { color: var(--danger); border-color: var(--danger); }
+  .btn-del.confirming {
     color: var(--danger);
     border-color: var(--danger);
     background: rgba(200, 110, 110, 0.08);
   }
 
+  /* ── BOOK ENTRIES ────────────────────────────────────────── */
   .book-entries {
     display: none;
     border-top: 1px solid var(--border);
@@ -247,44 +208,35 @@
     padding: 10px 14px;
     border-bottom: 1px solid var(--border);
     display: grid;
-    grid-template-columns: 1fr auto;
+    grid-template-columns: 1fr auto auto;
     gap: 8px;
     align-items: start;
+    transition: opacity 0.3s;
   }
   .entry:last-child { border-bottom: none; }
-
-  .entry-text {
-    font-size: 12px;
-    line-height: 1.5;
-    color: var(--text);
-  }
-
+  .entry-text { font-size: 12px; line-height: 1.5; color: var(--text); }
   .entry-meta {
     font-family: var(--mono);
     font-size: 10px;
     color: var(--muted);
     text-align: right;
     white-space: nowrap;
+    padding-top: 2px;
   }
 
   /* ── CONVERSATIONAL MEMORIES ─────────────────────────────── */
   .memory-item {
-    padding: 12px 0;
+    padding: 10px 0;
     border-bottom: 1px solid var(--border);
     display: grid;
-    grid-template-columns: 1fr auto;
-    gap: 12px;
+    grid-template-columns: 1fr auto auto;
+    gap: 10px;
     align-items: start;
     animation: fadeIn 0.3s ease both;
+    transition: opacity 0.3s;
   }
   .memory-item:last-child { border-bottom: none; }
-
-  .memory-text {
-    font-size: 13px;
-    line-height: 1.55;
-    color: var(--text);
-  }
-
+  .memory-text { font-size: 13px; line-height: 1.55; color: var(--text); }
   .memory-time {
     font-family: var(--mono);
     font-size: 10px;
@@ -293,10 +245,7 @@
     padding-top: 2px;
   }
 
-  .user-section {
-    margin-bottom: 20px;
-  }
-
+  .user-section { margin-bottom: 20px; }
   .user-label {
     font-family: var(--mono);
     font-size: 10px;
@@ -308,14 +257,9 @@
     align-items: center;
     gap: 8px;
   }
-  .user-label::after {
-    content: '';
-    flex: 1;
-    height: 1px;
-    background: var(--border);
-  }
+  .user-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
 
-  /* ── EMPTY / LOADING STATES ──────────────────────────────── */
+  /* ── EMPTY / LOADING ─────────────────────────────────────── */
   .state-msg {
     font-family: var(--mono);
     font-size: 11px;
@@ -324,13 +268,7 @@
     text-align: center;
     letter-spacing: 0.04em;
   }
-
-  .loading-bar {
-    height: 1px;
-    background: var(--border);
-    overflow: hidden;
-    margin-bottom: 20px;
-  }
+  .loading-bar { height: 1px; background: var(--border); overflow: hidden; margin-bottom: 20px; }
   .loading-bar::after {
     content: '';
     display: block;
@@ -363,10 +301,9 @@
     pointer-events: none;
   }
   #toast.show { opacity: 1; transform: translateY(0); }
-  #toast.ok   { border-color: var(--ok);     color: var(--ok); }
-  #toast.err  { border-color: var(--danger);  color: var(--danger); }
+  #toast.ok   { border-color: var(--ok);    color: var(--ok); }
+  #toast.err  { border-color: var(--danger); color: var(--danger); }
 
-  /* ── RESPONSIVE ──────────────────────────────────────────── */
   @media (max-width: 900px) {
     main { grid-template-columns: 1fr; }
     .panel { border-right: none; border-bottom: 1px solid var(--border); }
@@ -390,7 +327,6 @@
 </header>
 
 <main>
-  <!-- ── KNOWLEDGE PANEL ── -->
   <div class="panel" style="animation-delay:0.05s">
     <div class="panel-header">
       <div class="panel-title">knowledge base</div>
@@ -400,7 +336,6 @@
     <div id="knowledgeContent"></div>
   </div>
 
-  <!-- ── MEMORIES PANEL ── -->
   <div class="panel" style="animation-delay:0.1s">
     <div class="panel-header">
       <div class="panel-title">conversational memory</div>
@@ -429,14 +364,10 @@ function toast(msg, type = 'ok') {
 async function checkHealth() {
   try {
     const r = await fetch(`${BASE}/health`);
-    const dot = document.getElementById('statusDot');
-    const txt = document.getElementById('statusText');
     if (r.ok) {
-      dot.className = 'status-dot ok';
-      txt.textContent = 'online';
-    } else {
-      throw new Error();
-    }
+      document.getElementById('statusDot').className = 'status-dot ok';
+      document.getElementById('statusText').textContent = 'online';
+    } else { throw new Error(); }
   } catch {
     document.getElementById('statusDot').className = 'status-dot err';
     document.getElementById('statusText').textContent = 'unreachable';
@@ -456,23 +387,69 @@ function stopLoading(id) {
   if (el) el.style.display = 'none';
 }
 
-// ── KNOWLEDGE ──────────────────────────────────────────────────
+// Shared confirm-on-first-click pattern for any delete button
+function armDeleteBtn(btn, onConfirm) {
+  btn.addEventListener('click', (e) => {
+    e.stopPropagation();
+    if (!btn.classList.contains('confirming')) {
+      btn.classList.add('confirming');
+      btn.textContent = '✕?';
+      setTimeout(() => {
+        if (btn.classList.contains('confirming')) {
+          btn.classList.remove('confirming');
+          btn.textContent = '✕';
+        }
+      }, 2500);
+    } else {
+      btn.classList.remove('confirming');
+      onConfirm();
+    }
+  });
+}
+
+// Fade out and remove a row element
+function fadeRemove(el, onDone) {
+  el.style.opacity = '0';
+  setTimeout(() => { el.remove(); if (onDone) onDone(); }, 300);
+}
 
-// Phase 1: fetch just enough to build the book list (headers only)
+// ── DELETE A SINGLE MEMORY BY ID (shared for both collections) ──
+async function deleteMemoryById(id, collection, rowEl, onDone) {
+  const endpoint = collection === 'knowledge'
+    ? `${BASE}/knowledge`
+    : `${BASE}/memories`;
+  try {
+    // mem0 delete expects the memory_id directly
+    // We go straight to Chroma + SQLite via the server
+    const r = await fetch(`${BASE}/memory/${id}`, {
+      method: 'DELETE',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ memory_id: id, collection })
+    });
+    if (r.ok) {
+      fadeRemove(rowEl, onDone);
+      toast('entry deleted');
+    } else {
+      toast('delete failed', 'err');
+    }
+  } catch {
+    toast('delete failed — server unreachable', 'err');
+  }
+}
+
+// ── KNOWLEDGE ──────────────────────────────────────────────────
 async function loadKnowledge() {
   document.getElementById('knowledgeLoading').style.display = 'block';
   document.getElementById('knowledgeContent').innerHTML = '';
-
   try {
-    const r = await fetch(`${BASE}/knowledge/recent`, {
+    const r = await fetch(`${BASE}/knowledge/sources`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ user_id: 'knowledge_base', limit: 50 })
+      body: JSON.stringify({ user_id: 'knowledge_base' })
     });
     const data = await r.json();
-    const items = data.results || [];
     stopLoading('knowledgeLoading');
-    renderKnowledge(items);
+    renderKnowledge(data.sources || [], data.total || 0);
   } catch (e) {
     stopLoading('knowledgeLoading');
     document.getElementById('knowledgeContent').innerHTML =
@@ -480,34 +457,21 @@ async function loadKnowledge() {
   }
 }
 
-function renderKnowledge(items) {
+function renderKnowledge(sources, total) {
   const container = document.getElementById('knowledgeContent');
-
-  if (!items.length) {
+  if (!sources.length) {
     container.innerHTML = '<div class="state-msg">no knowledge entries found</div>';
     document.getElementById('knowledgeCount').textContent = '0 entries';
     return;
   }
-
-  // Group by source_file to build headers
-  const groups = {};
-  for (const item of items) {
-    const src = item.metadata?.source_file || '(no source)';
-    if (!groups[src]) groups[src] = 0;
-    groups[src]++;
-  }
-
-  const sorted = Object.entries(groups).sort((a, b) => b[1] - a[1]);
   document.getElementById('knowledgeCount').textContent =
-    `${items.length}+ entries · ${sorted.length} books`;
-
+    `${total} entries · ${sources.length} books`;
   container.innerHTML = '';
-  for (const [src, count] of sorted) {
-    container.appendChild(buildBookGroup(src, count));
+  for (const { source_file, count } of sources) {
+    container.appendChild(buildBookGroup(source_file, count));
   }
 }
 
-// Phase 2: fetch entries for a specific book on expand (lazy)
 async function loadBookEntries(src, entriesDiv) {
   entriesDiv.innerHTML = '<div class="state-msg">loading…</div>';
   try {
@@ -530,22 +494,78 @@ async function loadBookEntries(src, entriesDiv) {
       return;
     }
     for (const entry of entries) {
-      const row = document.createElement('div');
-      row.className = 'entry';
-      const page = entry.metadata?.page    ? `p.${entry.metadata.page}`    : '';
-      const ch   = entry.metadata?.chapter ? `ch.${entry.metadata.chapter}` : '';
-      const loc  = [ch, page].filter(Boolean).join(' · ');
-      row.innerHTML = `
-        <div class="entry-text">${entry.memory || ''}</div>
-        <div class="entry-meta">${loc}<br>${fmtDate(entry.created_at)}</div>
-      `;
-      entriesDiv.appendChild(row);
+      entriesDiv.appendChild(buildEntryRow(entry, entriesDiv));
     }
-  } catch (e) {
+  } catch {
     entriesDiv.innerHTML = '<div class="state-msg">failed to load entries</div>';
   }
 }
 
+function buildEntryRow(entry, parentDiv) {
+  const row = document.createElement('div');
+  row.className = 'entry';
+  row.dataset.id = entry.id;
+
+  const page = entry.metadata?.page    ? `p.${entry.metadata.page}`     : '';
+  const ch   = entry.metadata?.chapter ? `ch.${entry.metadata.chapter}` : '';
+  const loc  = [ch, page].filter(Boolean).join(' · ');
+
+  const text = document.createElement('div');
+  text.className = 'entry-text';
+  text.textContent = entry.memory || '';
+
+  const meta = document.createElement('div');
+  meta.className = 'entry-meta';
+  meta.innerHTML = `${loc}<br>${fmtDate(entry.created_at)}`;
+
+  const delBtn = document.createElement('button');
+  delBtn.className = 'btn-del';
+  delBtn.textContent = '✕';
+  delBtn.title = 'delete this entry';
+
+  armDeleteBtn(delBtn, () => {
+    deleteEntryById(entry.id, row, () => {
+      // Update book badge count
+      const group = parentDiv.closest('.book-group');
+      if (group) {
+        const badge = group.querySelector('.book-badge');
+        if (badge) badge.textContent = Math.max(0, parseInt(badge.textContent) - 1);
+      }
+      // Update panel total
+      const totalEl = document.getElementById('knowledgeCount');
+      const match = totalEl.textContent.match(/(\d+) entries/);
+      if (match) {
+        const newTotal = Math.max(0, parseInt(match[1]) - 1);
+        totalEl.textContent = totalEl.textContent.replace(/\d+ entries/, `${newTotal} entries`);
+      }
+    });
+  });
+
+  row.appendChild(text);
+  row.appendChild(meta);
+  row.appendChild(delBtn);
+  return row;
+}
+
+async function deleteEntryById(id, rowEl, onDone) {
+  try {
+    const r = await fetch(`${BASE}/memory/${id}`, {
+      method: 'DELETE',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ collection: 'knowledge' })
+    });
+    if (r.ok) {
+      fadeRemove(rowEl, onDone);
+      toast('entry deleted');
+    } else {
+      const data = await r.json().catch(() => ({}));
+      toast(data.error || 'delete failed', 'err');
+    }
+  } catch {
+    toast('delete failed — server unreachable', 'err');
+  }
+}
+
 function buildBookGroup(src, count) {
   const group = document.createElement('div');
   group.className = 'book-group';
@@ -559,13 +579,12 @@ function buildBookGroup(src, count) {
       <span class="book-name" title="${src}">${src}</span>
       <span class="book-badge">${count}</span>
     </div>
-    <button class="btn-delete-book" data-src="${src}">delete</button>
+    <button class="btn-del" data-src="${src}">delete book</button>
   `;
 
   const entriesDiv = document.createElement('div');
   entriesDiv.className = 'book-entries';
 
-  // Lazy load on first expand
   header.querySelector('.book-header-left').addEventListener('click', () => {
     group.classList.toggle('open');
     if (group.classList.contains('open') && !group.dataset.loaded) {
@@ -574,21 +593,8 @@ function buildBookGroup(src, count) {
     }
   });
 
-  // Delete — confirm on first click, execute on second
-  const delBtn = header.querySelector('.btn-delete-book');
-  delBtn.addEventListener('click', (e) => {
-    e.stopPropagation();
-    if (!delBtn.classList.contains('confirming')) {
-      delBtn.classList.add('confirming');
-      delBtn.textContent = 'confirm?';
-      setTimeout(() => {
-        delBtn.classList.remove('confirming');
-        delBtn.textContent = 'delete';
-      }, 2500);
-    } else {
-      deleteBook(src, group);
-    }
-  });
+  const delBtn = header.querySelector('.btn-del');
+  armDeleteBtn(delBtn, () => deleteBook(src, group));
 
   group.appendChild(header);
   group.appendChild(entriesDiv);
@@ -597,23 +603,23 @@ function buildBookGroup(src, count) {
 
 async function deleteBook(src, groupEl) {
   try {
-    const r = await fetch(`${BASE}/knowledge`, {
+    const r = await fetch(`${BASE}/knowledge/by-source`, {
       method: 'DELETE',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ filter: { 'metadata.source_file': src } })
+      body: JSON.stringify({ source_file: src, user_id: 'knowledge_base' })
     });
-    if (r.ok) {
-      groupEl.style.transition = 'opacity 0.3s';
-      groupEl.style.opacity = '0';
-      setTimeout(() => {
-        groupEl.remove();
-        // update count
+    const data = await r.json();
+    if (r.ok && data.deleted > 0) {
+      fadeRemove(groupEl, () => {
         const remaining = document.querySelectorAll('.book-group').length;
-        const entries = document.querySelectorAll('.entry').length;
+        const total = [...document.querySelectorAll('.book-badge')]
+          .reduce((sum, el) => sum + parseInt(el.textContent || 0), 0);
         document.getElementById('knowledgeCount').textContent =
-          `${entries} entries · ${remaining} books`;
-      }, 300);
-      toast(`deleted: ${src}`);
+          `${total} entries · ${remaining} books`;
+      });
+      toast(`deleted ${data.deleted} entries — ${src}`);
+    } else if (data.deleted === 0) {
+      toast('nothing to delete — try refreshing first', 'err');
     } else {
       toast('delete failed', 'err');
     }
@@ -627,7 +633,6 @@ async function loadMemories() {
   document.getElementById('memoriesLoading').style.display = 'block';
   document.getElementById('memoriesContent').innerHTML = '';
 
-  // Fetch for known user IDs — extend this array as needed
   const userIds = ['main', 'testuser', 'default'];
   const allResults = {};
 
@@ -642,10 +647,9 @@ async function loadMemories() {
       const items = data.results || [];
       if (items.length) allResults[uid] = items;
     }));
-
     stopLoading('memoriesLoading');
     renderMemories(allResults);
-  } catch (e) {
+  } catch {
     stopLoading('memoriesLoading');
     document.getElementById('memoriesContent').innerHTML =
       '<div class="state-msg">failed to load</div>';
@@ -677,26 +681,74 @@ function renderMemories(byUser) {
     section.appendChild(label);
 
     for (const item of items) {
-      const row = document.createElement('div');
-      row.className = 'memory-item';
-      row.innerHTML = `
-        <div class="memory-text">${item.memory || ''}</div>
-        <div class="memory-time">${fmtDate(item.created_at)}</div>
-      `;
-      section.appendChild(row);
+      section.appendChild(buildMemoryRow(item, section));
     }
 
     container.appendChild(section);
   }
 }
 
+function buildMemoryRow(item, sectionEl) {
+  const row = document.createElement('div');
+  row.className = 'memory-item';
+  row.dataset.id = item.id;
+
+  const text = document.createElement('div');
+  text.className = 'memory-text';
+  text.textContent = item.memory || '';
+
+  const time = document.createElement('div');
+  time.className = 'memory-time';
+  time.textContent = fmtDate(item.created_at);
+
+  const delBtn = document.createElement('button');
+  delBtn.className = 'btn-del';
+  delBtn.textContent = '✕';
+  delBtn.title = 'delete this memory';
+
+  armDeleteBtn(delBtn, () => {
+    deleteConvMemory(item.id, row, () => {
+      // Update panel count
+      const countEl = document.getElementById('memoriesCount');
+      const match = countEl.textContent.match(/(\d+) memories/);
+      if (match) {
+        const newCount = Math.max(0, parseInt(match[1]) - 1);
+        countEl.textContent = countEl.textContent.replace(/\d+ memories/, `${newCount} memories`);
+      }
+    });
+  });
+
+  row.appendChild(text);
+  row.appendChild(time);
+  row.appendChild(delBtn);
+  return row;
+}
+
+async function deleteConvMemory(id, rowEl, onDone) {
+  try {
+    const r = await fetch(`${BASE}/memory/${id}`, {
+      method: 'DELETE',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ collection: 'conversational' })
+    });
+    if (r.ok) {
+      fadeRemove(rowEl, onDone);
+      toast('memory deleted');
+    } else {
+      const data = await r.json().catch(() => ({}));
+      toast(data.error || 'delete failed', 'err');
+    }
+  } catch {
+    toast('delete failed — server unreachable', 'err');
+  }
+}
+
 // ── LOAD ALL ───────────────────────────────────────────────────
 async function loadAll() {
   await checkHealth();
   await Promise.all([loadKnowledge(), loadMemories()]);
 }
 
-// Init
 loadAll();
 </script>
 </body>

+ 227 - 115
mem0server.py

@@ -1,10 +1,10 @@
 import os
 import math
 import json
+import sqlite3
 import httpx
 from fastapi import FastAPI, Request
-from fastapi.responses import JSONResponse
-from fastapi.responses import HTMLResponse
+from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
 from mem0 import Memory
 
 # =============================================================================
@@ -16,8 +16,7 @@ if not GROQ_API_KEY:
     raise RuntimeError("GROQ_API_KEY environment variable is not set.")
 
 RERANKER_URL = os.environ.get("RERANKER_URL", "http://192.168.0.200:5200/rerank")
-
-DASHBOARD_HTML = open("dashboard.html").read()
+SQLITE_PATH  = os.path.expanduser("~/.mem0/history.db")
 
 # =============================================================================
 # SAFE JSON RESPONSE  (handles Infinity / NaN from Chroma / reranker scores)
@@ -36,9 +35,7 @@ def _sanitize(obj):
 
 class SafeJSONResponse(JSONResponse):
     def render(self, content) -> bytes:
-        return json.dumps(
-            _sanitize(content), ensure_ascii=False
-        ).encode("utf-8")
+        return json.dumps(_sanitize(content), ensure_ascii=False).encode("utf-8")
 
 
 # =============================================================================
@@ -50,81 +47,20 @@ PROMPTS = {
     # Used by /memories — conversational, user-centric recall for OpenClaw.
     "conversational": {
         "fact_extraction": """
-You are an intelligent system that extracts useful long-term memory
-from a conversation.
-
-Your goal is to identify information that could help future interactions.
-
-Extract facts that describe:
-
-1. User preferences
-2. Important decisions
-3. Ongoing projects
-4. Tools or technologies being used
-5. Goals or plans
-6. Constraints or requirements
-7. Discoveries or conclusions
-8. Important context about tasks, persons, programs, projects and locations
-
-
-Ignore:
-- greetings
-- casual conversation
-- general world knowledge
-- temporary statements
-- debugging work
-
-Return the result in JSON format:
-
-{
- "facts": [
-   "fact 1",
-   "fact 2"
- ]
-}
-
-Only include information that may be useful later.
-If nothing important is present return:
-
-{"facts": []}
-
+You are a personal memory assistant. Extract concise, standalone facts about
+the user from the conversation below. Write each fact as a single sentence
+starting with "User" — for example:
+  - "User is interested in generative music."
+  - "User is familiar with Python async patterns."
+  - "User prefers dark mode interfaces."
+Only extract facts that are clearly stated or strongly implied. Ignore filler,
+greetings, and opinions the user is uncertain about.
 """.strip(),
         "update_memory": """
-
-You manage a long-term memory database.
-
-You receive:
-1. existing stored memories
-2. new extracted facts
-
-For each fact decide whether to:
-
-ADD
-Create a new memory if it contains useful new information.
-
-UPDATE
-Modify an existing memory if the new fact refines or corrects it.
-
-DELETE
-Remove a memory if it is clearly outdated or incorrect.
-
-NONE
-Ignore the fact if it is redundant or trivial.
-
-Guidelines:
-
-- Prefer updating over adding duplicates
-- Keep memories concise
-- Avoid storing repeated information
-- Preserve important context
-
-Return JSON list:
-
-[
- { "event": "ADD", "text": "..." },
- { "event": "UPDATE", "id": "...", "text": "..." }
-]
-
+You manage a long-term memory database for a personal AI assistant.
+You receive existing memories and new information. Update, merge, or add
+memories as needed. Keep each memory as a single concise sentence starting
+with "User". Remove duplicates and outdated facts.
 """.strip(),
     },
 
@@ -265,7 +201,72 @@ def rerank_results(query: str, items: list, top_k: int) -> list:
     return merged
 
 # =============================================================================
-# SHARED HELPERS
+# SQLITE HELPER
+# =============================================================================
+
+def sqlite_delete_ids(memory_ids: list[str]) -> int:
+    """Delete rows from mem0 SQLite by memory_id. Returns count deleted."""
+    if not memory_ids:
+        return 0
+    try:
+        conn = sqlite3.connect(SQLITE_PATH)
+        cur  = conn.cursor()
+        placeholders = ",".join("?" * len(memory_ids))
+        cur.execute(
+            f"DELETE FROM history WHERE memory_id IN ({placeholders})",
+            memory_ids
+        )
+        deleted = cur.rowcount
+        conn.commit()
+        conn.close()
+        return deleted
+    except Exception as e:
+        print(f"[sqlite] warning: {e}")
+        return 0
+
+# =============================================================================
+# CHROMA PAGINATION HELPER
+# =============================================================================
+
+def chroma_get_all(collection, user_id: str, include: list = None) -> list[dict]:
+    """
+    Page through a Chroma collection in batches, filtering by user_id.
+    Returns list of dicts with 'id' and any included fields.
+    Bypasses mem0's 100-entry cap entirely.
+    """
+    if include is None:
+        include = ["metadatas"]
+
+    results = []
+    batch   = 500
+    offset  = 0
+
+    while True:
+        page = collection.get(
+            where={"user_id": {"$eq": user_id}},
+            limit=batch,
+            offset=offset,
+            include=include,
+        )
+        ids = page.get("ids", [])
+        if not ids:
+            break
+
+        for i, id_ in enumerate(ids):
+            row = {"id": id_}
+            for field in include:
+                values = page.get(field, [])
+                row[field[:-1]] = values[i] if i < len(values) else None
+            results.append(row)
+
+        offset += len(ids)
+        if len(ids) < batch:
+            break
+
+    return results
+
+# =============================================================================
+# SHARED HANDLERS
 # =============================================================================
 
 def extract_user_id(data: dict) -> str:
@@ -276,29 +277,31 @@ async def handle_add(req: Request, mem: Memory, verbatim_allowed: bool = False):
     """
     Shared add handler for /memories and /knowledge.
 
+    /knowledge  (verbatim_allowed=True)  — always stores verbatim (infer=False).
+                                           The ingestor already summarised; skip
+                                           the second LLM pass.
+    /memories   (verbatim_allowed=False) — always uses LLM extraction for
+                                           conversational recall.
+
     Supports:
-      - text        — raw string (legacy)
-      - messages    — list of {role, content} dicts (standard mem0)
-      - infer       — bool, default True. If False and verbatim_allowed=True,
-                      stores content without LLM extraction.
-      - metadata    — dict, passed through to mem0
+      - text      — raw string (legacy)
+      - messages  — list of {role, content} dicts (standard mem0)
+      - metadata  — dict, passed through to mem0
       - user_id / userId
     """
-    data = await req.json()
-    user_id = extract_user_id(data)
+    data     = await req.json()
+    user_id  = extract_user_id(data)
     metadata = data.get("metadata") or {}
-    # infer = data.get("infer", False)
-    infer = False
     messages = data.get("messages")
-    text = data.get("text")
+    text     = data.get("text")
 
     if not messages and not text:
         return SafeJSONResponse(
             content={"error": "Provide 'text' or 'messages'"}, status_code=400
         )
 
-    # infer:false — store verbatim (knowledge collection only)
-    if verbatim_allowed and not infer:
+    if verbatim_allowed:
+        # /knowledge — always verbatim, ingestor already summarised
         content = text or " ".join(
             m["content"] for m in messages if m.get("role") == "user"
         )
@@ -306,21 +309,21 @@ async def handle_add(req: Request, mem: Memory, verbatim_allowed: bool = False):
         print(f"[add verbatim] user={user_id} chars={len(content)} meta={metadata}")
         return SafeJSONResponse(content=result)
 
-    # Normal path — LLM extraction
+    # /memories — always LLM extraction
     if messages:
         result = mem.add(messages, user_id=user_id, metadata=metadata)
     else:
         result = mem.add(text, user_id=user_id, metadata=metadata)
 
-    print(f"[add] user={user_id} infer={infer} meta={metadata}")
+    print(f"[add conversational] user={user_id} meta={metadata}")
     return SafeJSONResponse(content=result)
 
 
 async def handle_search(req: Request, mem: Memory):
-    data = await req.json()
-    query = (data.get("query") or "").strip()
+    data    = await req.json()
+    query   = (data.get("query") or "").strip()
     user_id = extract_user_id(data)
-    limit = int(data.get("limit", 5))
+    limit   = int(data.get("limit", 5))
 
     if not query:
         return SafeJSONResponse(content={"results": []})
@@ -330,12 +333,12 @@ async def handle_search(req: Request, mem: Memory):
         result = mem.search(query, user_id=user_id, limit=fetch_k)
     except Exception:
         all_res = mem.get_all(user_id=user_id)
-        items = (
+        items   = (
             all_res.get("results", [])
             if isinstance(all_res, dict)
             else (all_res if isinstance(all_res, list) else [])
         )
-        q = query.lower()
+        q     = query.lower()
         items = [r for r in items if q in r.get("memory", "").lower()]
         result = {"results": items}
 
@@ -346,7 +349,7 @@ async def handle_search(req: Request, mem: Memory):
 
 
 async def handle_recent(req: Request, mem: Memory):
-    data = await req.json()
+    data    = await req.json()
     user_id = extract_user_id(data)
     if not user_id:
         return SafeJSONResponse(content={"error": "Missing userId"}, status_code=400)
@@ -368,6 +371,21 @@ async def handle_recent(req: Request, mem: Memory):
 app = FastAPI(title="mem0 server")
 
 
+# ---------------------------------------------------------------------------
+# DASHBOARD
+# ---------------------------------------------------------------------------
+
+DASHBOARD_HTML = open("dashboard.html").read()
+
+@app.get("/dashboard")
+async def dashboard():
+    return HTMLResponse(content=DASHBOARD_HTML)
+
+
+# ---------------------------------------------------------------------------
+# HEALTH
+# ---------------------------------------------------------------------------
+
 @app.get("/health")
 async def health():
     return SafeJSONResponse(content={
@@ -375,7 +393,7 @@ async def health():
         "reranker_url": RERANKER_URL,
         "collections": {
             "conversational": "openclaw_mem",
-            "knowledge": "knowledge_mem",
+            "knowledge":      "knowledge_mem",
         },
         "prompts": {
             k: {pk: pv[:80] + "…" for pk, pv in pv_dict.items()}
@@ -434,6 +452,106 @@ async def delete_knowledge(req: Request):
     return SafeJSONResponse(content=memory_know.delete(data.get("filter", {})))
 
 
+@app.post("/knowledge/sources")
+async def knowledge_sources(req: Request):
+    """
+    Return distinct source_file values with entry counts.
+    Pages through Chroma directly — no mem0 100-entry cap.
+    """
+    data    = await req.json()
+    user_id = extract_user_id(data) or "knowledge_base"
+
+    rows   = chroma_get_all(memory_know.vector_store.collection, user_id)
+    counts = {}
+    for row in rows:
+        src = (row.get("metadata") or {}).get("source_file", "(no source)")
+        counts[src] = counts.get(src, 0) + 1
+
+    sources = [
+        {"source_file": k, "count": v}
+        for k, v in sorted(counts.items(), key=lambda x: -x[1])
+    ]
+    print(f"[sources] user={user_id} total={len(rows)} books={len(sources)}")
+    return SafeJSONResponse(content={"sources": sources, "total": len(rows)})
+
+
+@app.delete("/knowledge/by-source")
+async def delete_knowledge_by_source(req: Request):
+    """
+    Delete all knowledge entries for a given source_file.
+    Pages through Chroma directly, then cleans SQLite.
+    """
+    data        = await req.json()
+    source_file = data.get("source_file")
+    user_id     = extract_user_id(data) or "knowledge_base"
+
+    if not source_file:
+        return SafeJSONResponse(
+            content={"error": "Missing source_file"}, status_code=400
+        )
+
+    rows = chroma_get_all(memory_know.vector_store.collection, user_id)
+    to_delete = [
+        row["id"] for row in rows
+        if (row.get("metadata") or {}).get("source_file") == source_file
+    ]
+
+    if not to_delete:
+        return SafeJSONResponse(
+            content={"deleted": 0, "message": "no entries found for that source"}
+        )
+
+    # 1. Chroma bulk delete
+    try:
+        memory_know.vector_store.collection.delete(ids=to_delete)
+    except Exception as e:
+        return SafeJSONResponse(
+            content={"error": f"chroma delete failed: {e}"}, status_code=500
+        )
+
+    # 2. SQLite cleanup
+    sqlite_deleted = sqlite_delete_ids(to_delete)
+
+    print(f"[delete by-source] source={source_file} "
+          f"chroma={len(to_delete)} sqlite={sqlite_deleted}")
+
+    return SafeJSONResponse(content={
+        "deleted":        len(to_delete),
+        "sqlite_deleted": sqlite_deleted,
+        "source_file":    source_file,
+    })
+
+
+# ---------------------------------------------------------------------------
+# /memory/{id}  — single entry delete (knowledge or conversational)
+# ---------------------------------------------------------------------------
+
+@app.delete("/memory/{memory_id}")
+async def delete_single_memory(memory_id: str, req: Request):
+    """
+    Delete a single memory by ID from either collection.
+    Body: { "collection": "knowledge" | "conversational" }
+    Cleans both Chroma and SQLite.
+    """
+    data       = await req.json()
+    collection = data.get("collection", "knowledge")
+    mem        = memory_know if collection == "knowledge" else memory_conv
+
+    # 1. Chroma delete
+    try:
+        mem.vector_store.collection.delete(ids=[memory_id])
+    except Exception as e:
+        return SafeJSONResponse(
+            content={"error": f"chroma delete failed: {e}"}, status_code=500
+        )
+
+    # 2. SQLite cleanup
+    sqlite_delete_ids([memory_id])
+
+    print(f"[delete single] id={memory_id} collection={collection}")
+    return SafeJSONResponse(content={"deleted": memory_id})
+
+
 # ---------------------------------------------------------------------------
 # /search  — merged results from both collections (OpenClaw autorecall)
 # ---------------------------------------------------------------------------
@@ -442,13 +560,13 @@ async def delete_knowledge(req: Request):
 async def search_all(req: Request):
     """
     Query both collections and merge results.
-    Results are tagged with _source: conversational | knowledge.
+    Results tagged with _source: conversational | knowledge.
     Accepts same payload as /memories/search.
     """
-    data = await req.json()
-    query = (data.get("query") or "").strip()
+    data    = await req.json()
+    query   = (data.get("query") or "").strip()
     user_id = extract_user_id(data)
-    limit = int(data.get("limit", 5))
+    limit   = int(data.get("limit", 5))
 
     if not query:
         return SafeJSONResponse(content={"results": []})
@@ -457,7 +575,7 @@ async def search_all(req: Request):
 
     def fetch(mem: Memory, tag: str):
         try:
-            r = mem.search(query, user_id=user_id, limit=fetch_k)
+            r     = mem.search(query, user_id=user_id, limit=fetch_k)
             items = r.get("results", [])
         except Exception:
             items = []
@@ -467,16 +585,10 @@ async def search_all(req: Request):
 
     conv_items = fetch(memory_conv, "conversational")
     know_items = fetch(memory_know, "knowledge")
-
-    merged = conv_items + know_items
-    merged = rerank_results(query, merged, top_k=limit)
+    merged     = rerank_results(query, conv_items + know_items, top_k=limit)
 
     print(
         f"[search/all] user={user_id} query={query!r} "
         f"conv={len(conv_items)} know={len(know_items)} merged={len(merged)}"
     )
-    return SafeJSONResponse(content={"results": merged})
-
-@app.get("/dashboard")
-async def dashboard():
-    return HTMLResponse(content=DASHBOARD_HTML)
+    return SafeJSONResponse(content={"results": merged})