diff --git a/admin/index.html b/admin/index.html
index 57f6d17..908082b 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -216,6 +216,7 @@
+
@@ -253,7 +254,7 @@
users() { return this.get('/admin/users'); },
sessions(qs='') { return this.get('/admin/sessions' + (qs ? '?' + qs : '')); },
- memory() { return this.get('/admin/memory'); },
+ memory(qs='') { return this.get('/admin/memory' + (qs ? '?' + qs : '')); },
profiles() { return this.get('/admin/profiles'); },
session(id){ return this.get(`/admin/sessions/${id}`); },
delSession(id){ return this.del(`/admin/sessions/${id}`); },
@@ -452,61 +453,107 @@
};
// ── Memory ─────────────────────────────────────────────────────────────────
+const memState = { limit: 50, offset: 0, search: '', sortBy: 'updated_at', sortOrder: 'desc', total: 0, items: [] };
+
async function loadMemory() {
const el = $('#memory-content');
+ const countEl = $('#mem-count');
try {
- const data = await api.memory();
+ const params = new URLSearchParams();
+ params.set('limit', String(memState.limit));
+ params.set('offset', String(memState.offset));
+ if (memState.search) params.set('search', memState.search);
+ params.set('sort_by', memState.sortBy);
+ params.set('sort_order', memState.sortOrder);
+ const data = await api.memory(params.toString());
state.memoryLoaded = true;
- state.memoryFacts = data.facts || [];
- renderMemory(state.memoryFacts);
+ memState.total = data.total || 0;
+ memState.items = data.items || [];
+ el.innerHTML = renderMemoryTable(memState.items);
+ countEl.textContent = `${memState.total} total`;
+ renderMemoryPagination();
} catch (err) {
el.innerHTML = `${esc(err.message)}
`;
+ countEl.textContent = '';
}
}
-function renderMemory(facts) {
- const el = $('#memory-content');
- const q = ($('#mem-search').value || '').trim().toLowerCase();
- let filtered = facts;
- if (q) {
- filtered = facts.filter(f =>
- (f.key||'').toLowerCase().includes(q) ||
- (f.value||'').toLowerCase().includes(q) ||
- (f.category||'').toLowerCase().includes(q)
- );
- }
- $('#mem-count').textContent = `${filtered.length} / ${facts.length} facts`;
+const MEMORY_COLUMNS = [
+ { key: 'category', label: 'Category' },
+ { key: 'key', label: 'Key' },
+ { key: 'value', label: 'Value', sortable: false },
+ { key: 'source', label: 'Source' },
+ { key: 'confidence', label: 'Confidence' },
+ { key: 'updated_at', label: 'Updated' },
+];
- if (!filtered.length) { el.innerHTML = 'No facts.
'; return; }
-
- const groups = {};
- for (const f of filtered) {
- const cat = f.category || 'other';
- if (!groups[cat]) groups[cat] = [];
- groups[cat].push(f);
+function renderMemoryTable(items) {
+ if (!items.length) return 'No facts.
';
+ let html = '';
+ for (const col of MEMORY_COLUMNS) {
+ const isSorted = memState.sortBy === col.key;
+ const cls = col.sortable !== false ? `sortable ${isSorted ? 'sort-' + memState.sortOrder : ''}` : '';
+ html += `| ${esc(col.label)} | `;
}
-
- let html = '';
- for (const cat of Object.keys(groups).sort()) {
- html += `${esc(cat)} (${groups[cat].length})
`;
- html += '';
- ['Key','Value','Source','Confidence','Updated'].forEach(h => html += `| ${h} | `);
- html += '
';
- for (const f of groups[cat]) {
- html += `
- | ${esc(f.key)} |
- ${esc(f.value)} |
- ${esc(f.source)} |
- ${f.confidence} |
- ${fmtDate(f.updated_at)} |
-
`;
- }
- html += '
';
+ html += '
';
+ for (const f of items) {
+ html += `
+ | ${esc(f.category)} |
+ ${esc(f.key)} |
+ ${esc(f.value)} |
+ ${esc(f.source)} |
+ ${f.confidence} |
+ ${fmtDate(f.updated_at)} |
+
`;
}
- el.innerHTML = html;
+ html += '
';
+ return html;
}
-$('#mem-search').addEventListener('input', () => renderMemory(state.memoryFacts));
+function renderMemoryPagination() {
+ const el = $('#mem-pagination');
+ const start = memState.total ? memState.offset + 1 : 0;
+ const end = Math.min(memState.offset + memState.limit, memState.total);
+ const hasPrev = memState.offset > 0;
+ const hasNext = memState.offset + memState.limit < memState.total;
+ el.innerHTML = `
+
+
+
+
+ `;
+}
+
+window.changeMemoryPage = (delta) => {
+ memState.offset = Math.max(0, memState.offset + delta * memState.limit);
+ loadMemory();
+};
+window.setMemoryLimit = (val) => {
+ memState.limit = parseInt(val, 10);
+ memState.offset = 0;
+ loadMemory();
+};
+window.onMemorySort = (col) => {
+ if (memState.sortBy === col) {
+ memState.sortOrder = memState.sortOrder === 'asc' ? 'desc' : 'asc';
+ } else {
+ memState.sortBy = col;
+ memState.sortOrder = 'desc';
+ }
+ memState.offset = 0;
+ loadMemory();
+};
+
+$('#mem-search').addEventListener('input', debounce(() => {
+ memState.search = $('#mem-search').value.trim();
+ memState.offset = 0;
+ loadMemory();
+}, 300));
// ── Profiles ──────────────────────────────────────────────────────────────
async function loadProfiles() {
diff --git a/navi/api/routes/admin.py b/navi/api/routes/admin.py
index 4fa7b85..059ae62 100644
--- a/navi/api/routes/admin.py
+++ b/navi/api/routes/admin.py
@@ -178,13 +178,26 @@
async def admin_list_memory(
store: Annotated[SessionStore, Depends(get_session_store)],
user: Annotated[User, Depends(require_permission("navi.memory.read_all"))],
+ limit: int = 50,
+ offset: int = 0,
+ search: str | None = None,
+ sort_by: str = "updated_at",
+ sort_order: str = "desc",
):
- """Return all memory facts (global view)."""
+ """Return memory facts with pagination, search and sorting."""
from navi.api.deps import get_memory_store
memory = get_memory_store()
- facts = await memory.get_all_facts(limit=500)
- return {"facts": facts, "count": len(facts)}
+ facts = await memory.get_all_facts(
+ limit=limit,
+ offset=offset,
+ search=search or None,
+ sort_by=sort_by,
+ sort_order=sort_order,
+ all_users=True,
+ )
+ total = await memory.fact_count(all_users=True, search=search or None)
+ return {"total": total, "limit": limit, "offset": offset, "items": facts}
@router.patch("/users/{user_id}/role")
diff --git a/navi/memory/_facts.py b/navi/memory/_facts.py
index b75bea8..2e71e6c 100644
--- a/navi/memory/_facts.py
+++ b/navi/memory/_facts.py
@@ -210,32 +210,88 @@
)
return int(result.split()[1])
- async def get_all_facts(self, user_id: str | None = None, limit: int | None = None) -> list[dict]:
+ async def get_all_facts(
+ self,
+ user_id: str | None = None,
+ limit: int | None = None,
+ offset: int = 0,
+ search: str | None = None,
+ sort_by: str = "category",
+ sort_order: str = "desc",
+ all_users: bool = False,
+ ) -> list[dict]:
q = (
"SELECT id, category, key, value, updated_at, source, confidence, "
"expires_at, source_context FROM memory_facts "
)
params: list = []
- if user_id is None:
- q += "WHERE user_id IS NULL "
+ param_idx = 0
+
+ def add_param(value):
+ nonlocal param_idx
+ param_idx += 1
+ params.append(value)
+ return f"${param_idx}"
+
+ conditions = []
+ if not all_users:
+ if user_id is None:
+ conditions.append("user_id IS NULL")
+ else:
+ conditions.append(f"user_id = {add_param(user_id)}")
+ if search:
+ like = f"%{search}%"
+ conditions.append(
+ f"(key ILIKE {add_param(like)} OR value ILIKE {add_param(like)} OR category ILIKE {add_param(like)})"
+ )
+
+ if conditions:
+ q += "WHERE " + " AND ".join(conditions) + " "
+
+ allowed_cols = {"category", "key", "updated_at", "confidence", "source"}
+ col = sort_by if sort_by in allowed_cols else "category"
+ order = "DESC" if sort_order == "desc" else "ASC"
+ if col == "category":
+ q += f"ORDER BY {col} {order}, updated_at DESC"
else:
- q += "WHERE user_id = $1 "
- params.append(user_id)
- q += "ORDER BY category, updated_at DESC"
- if limit:
- q += f" LIMIT ${len(params) + 1}"
- params.append(limit)
+ q += f"ORDER BY {col} {order}"
+
+ if limit is not None:
+ q += f" LIMIT {add_param(limit)}"
+ if offset:
+ q += f" OFFSET {add_param(offset)}"
+
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(q, *params)
return [_row_to_dict(r) for r in rows]
- async def fact_count(self, user_id: str | None = None) -> int:
+ async def fact_count(
+ self,
+ user_id: str | None = None,
+ all_users: bool = False,
+ search: str | None = None,
+ ) -> int:
+ q = "SELECT COUNT(*) FROM memory_facts"
+ params: list = []
+ conditions = []
+ if not all_users:
+ if user_id is None:
+ conditions.append("user_id IS NULL")
+ else:
+ conditions.append(f"user_id = ${len(params) + 1}")
+ params.append(user_id)
+ if search:
+ like = f"%{search}%"
+ conditions.append(
+ f"(key ILIKE ${len(params) + 1} OR value ILIKE ${len(params) + 2} OR category ILIKE ${len(params) + 3})"
+ )
+ params.extend([like, like, like])
+ if conditions:
+ q += " WHERE " + " AND ".join(conditions)
pool = await self._get_pool()
async with pool.acquire() as conn:
- if user_id is None:
- return await conn.fetchval("SELECT COUNT(*) FROM memory_facts WHERE user_id IS NULL") or 0
- return await conn.fetchval("SELECT COUNT(*) FROM memory_facts WHERE user_id = $1", user_id) or 0
+ return await conn.fetchval(q, *params) or 0
def _row_to_dict(row: asyncpg.Record) -> dict:
diff --git a/tests/unit/memory/test_store.py b/tests/unit/memory/test_store.py
index d01c4d1..3982f1b 100644
--- a/tests/unit/memory/test_store.py
+++ b/tests/unit/memory/test_store.py
@@ -128,6 +128,17 @@
await store.get_all_facts(limit=5)
assert "LIMIT $1" in conn.calls[0][1]
+ async def test_all_users(self):
+ conn = FakeConnection()
+ conn.enqueue([FakeRecord(id="1", category="profile", key="name", value="Eugene",
+ updated_at=None, source="conversation", confidence=90,
+ expires_at=None, source_context="")])
+ store = make_store_with_pool(conn)
+ results = await store.get_all_facts(all_users=True)
+ assert len(results) == 1
+ # Should not filter by user_id at all
+ assert "user_id" not in conn.calls[0][1]
+
class TestFactCount:
async def test_returns_count(self):
@@ -136,6 +147,13 @@
store = make_store_with_pool(conn)
assert await store.fact_count() == 42
+ async def test_all_users(self):
+ conn = FakeConnection()
+ conn.enqueue(100)
+ store = make_store_with_pool(conn)
+ assert await store.fact_count(all_users=True) == 100
+ assert "WHERE" not in conn.calls[0][1]
+
class TestSummary:
async def test_get_summary(self):