superlocalmemory 3.4.30 → 3.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ---
12
12
 
13
+ ## [3.4.31] - 2026-04-24
14
+
15
+ Dashboard truth, memory vs fact clarity, and self-cleaning pending queue.
16
+
17
+ ### Changed
18
+ - **Dashboard now shows both memory counts honestly.** Parent memories
19
+ (what you stored) and atomic facts (what retrieval indexes) appear as
20
+ two distinct cards with their ratio. No more "Total Memories: 6,000"
21
+ when you actually have 2,000 memories decomposed into 6,000 facts.
22
+ - **"Browse atomic facts"** relabeled for clarity — this view lists the
23
+ indexed atomic units.
24
+ - **Visible search box** in the Memories tab — previously hidden behind
25
+ the Recall Lab only. Search now debounces 280 ms on input.
26
+
27
+ ### Added
28
+ - **`/api/memories/{id}/detail`** — full memory + all child atomic facts
29
+ in one call. Powers the click-to-expand modal.
30
+ - **`/api/facts/{id}`** — single atomic fact detail with source memory
31
+ content, entities, and canonical entities.
32
+ - **Pagination UI** — Prev/Next controls show "Showing 1–50 of 6,123".
33
+ Previously hardcoded to 50 with no navigation.
34
+ - **CSV export** — new `format=csv` option on `/api/export` plus a
35
+ dedicated "Export All (CSV)" menu item. JSON and JSONL still work.
36
+ - **Export progress toast** — "Preparing JSON export…" notification
37
+ before the download starts.
38
+ - **`total_facts` + `facts_per_memory`** in `/api/stats` response.
39
+ - **Pending queue auto-cleanup** — the maintenance scheduler now sweeps
40
+ the pending queue every cycle: completed rows > 7 days, failed rows
41
+ over retry limit, and stuck rows > 7 days are removed; a 30-day hard
42
+ cap prevents runaway growth on any status.
43
+
44
+ ### Fixed
45
+ - **Test isolation** — `pending_store` now honors `SLM_DATA_DIR`. Four
46
+ MCP remember tests were writing to the live `~/.superlocalmemory/`
47
+ instead of `tmp_path`. Root conftest now forces `SLM_DATA_DIR=tmp_path`
48
+ for every test unless explicitly opted out.
49
+ - **Fact click popup** — was calling `/api/v3/recall/trace` with a text
50
+ substring (re-query by first 100 chars) and colliding with the memory
51
+ row click handler. Now scoped to `.fact-result-item` only, hits the
52
+ new `/api/facts/{fact_id}` endpoint.
53
+ - **Memory modal ID confusion** — the modal labeled `mem.id` as "ID"
54
+ regardless of whether it was a memory_id or fact_id. Now displays
55
+ both "Memory ID" and "Fact ID" when they differ.
56
+ - **Memory modal hydration** — fetches the full memory + fact list
57
+ asynchronously when opened, so source content and entity data appear
58
+ even for rows that arrived from the search endpoint.
59
+
60
+ ---
61
+
13
62
  ## [3.4.30] - 2026-04-24
14
63
 
15
64
  Multi-IDE shared worker, silent migration, and security hardening.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.30",
3
+ "version": "3.4.31",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.4.30"
3
+ version = "3.4.31"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -1,3 +1,3 @@
1
1
  """SuperLocalMemory — information-geometric agent memory."""
2
2
 
3
- __version__ = "3.4.30"
3
+ __version__ = "3.4.31"
@@ -24,12 +24,21 @@ License: Elastic-2.0
24
24
  from __future__ import annotations
25
25
 
26
26
  import json
27
+ import os
27
28
  import sqlite3
28
29
  import time
29
30
  from pathlib import Path
30
31
 
31
- _DEFAULT_DIR = Path.home() / ".superlocalmemory"
32
+
33
+ def _default_dir() -> Path:
34
+ """Honor SLM_DATA_DIR so tests can isolate via tmp_path."""
35
+ return Path(os.environ.get("SLM_DATA_DIR") or Path.home() / ".superlocalmemory")
36
+
37
+
32
38
  _PENDING_DB = "pending.db"
39
+ _MAX_RETRIES = 3
40
+ _STUCK_DAYS = 7
41
+ _DEAD_LETTER_DAYS = 30
33
42
 
34
43
  _SCHEMA = """
35
44
  CREATE TABLE IF NOT EXISTS pending_memories (
@@ -47,7 +56,7 @@ CREATE TABLE IF NOT EXISTS pending_memories (
47
56
 
48
57
  def _get_db(base_dir: Path | None = None) -> sqlite3.Connection:
49
58
  """Open pending.db with WAL mode. Creates if needed."""
50
- d = base_dir or _DEFAULT_DIR
59
+ d = base_dir or _default_dir()
51
60
  d.mkdir(parents=True, exist_ok=True)
52
61
  db_path = d / _PENDING_DB
53
62
  conn = sqlite3.connect(str(db_path), timeout=5)
@@ -128,7 +137,7 @@ def mark_failed(row_id: int, error: str, base_dir: Path | None = None) -> None:
128
137
 
129
138
  def pending_count(base_dir: Path | None = None) -> int:
130
139
  """Count unprocessed pending memories."""
131
- d = base_dir or _DEFAULT_DIR
140
+ d = base_dir or _default_dir()
132
141
  db_path = d / _PENDING_DB
133
142
  if not db_path.exists():
134
143
  return 0
@@ -156,3 +165,46 @@ def cleanup_done(days: int = 7, base_dir: Path | None = None) -> int:
156
165
  return cur.rowcount
157
166
  finally:
158
167
  conn.close()
168
+
169
+
170
+ def cleanup_stale(base_dir: Path | None = None) -> dict[str, int]:
171
+ """Sweep stale rows from pending.db. Runs periodically from the daemon.
172
+
173
+ Removes:
174
+ - `done` rows older than 7 days (already processed)
175
+ - `failed` rows that exceeded max retries (moved to dead-letter via deletion)
176
+ - `pending` rows stuck more than 7 days (test pollution, crashed workers)
177
+ - Everything older than 30 days regardless of status (hard cap)
178
+ """
179
+ conn = _get_db(base_dir)
180
+ try:
181
+ done = conn.execute(
182
+ "DELETE FROM pending_memories WHERE status = 'done' "
183
+ "AND created_at < datetime('now', ?)",
184
+ (f"-{_STUCK_DAYS} days",),
185
+ ).rowcount
186
+ failed = conn.execute(
187
+ "DELETE FROM pending_memories WHERE status = 'failed' "
188
+ "AND retry_count >= ?",
189
+ (_MAX_RETRIES,),
190
+ ).rowcount
191
+ stuck = conn.execute(
192
+ "DELETE FROM pending_memories WHERE status = 'pending' "
193
+ "AND created_at < datetime('now', ?)",
194
+ (f"-{_STUCK_DAYS} days",),
195
+ ).rowcount
196
+ hard_cap = conn.execute(
197
+ "DELETE FROM pending_memories "
198
+ "WHERE created_at < datetime('now', ?)",
199
+ (f"-{_DEAD_LETTER_DAYS} days",),
200
+ ).rowcount
201
+ conn.commit()
202
+ return {
203
+ "done": done,
204
+ "failed_over_retries": failed,
205
+ "stuck_pending": stuck,
206
+ "hard_cap_expired": hard_cap,
207
+ "total": done + failed + stuck + hard_cap,
208
+ }
209
+ finally:
210
+ conn.close()
@@ -116,6 +116,14 @@ class MaintenanceScheduler:
116
116
  except Exception as exc:
117
117
  logger.debug("Auto-backup check skipped: %s", exc)
118
118
 
119
+ try:
120
+ from superlocalmemory.cli.pending_store import cleanup_stale
121
+ stats = cleanup_stale()
122
+ if stats["total"] > 0:
123
+ logger.info("Pending cleanup: %s", stats)
124
+ except Exception as exc:
125
+ logger.debug("Pending cleanup skipped: %s", exc)
126
+
119
127
  self._schedule_next()
120
128
 
121
129
  def _sync_cloud_destinations(self, manager: object) -> None:
@@ -141,12 +141,18 @@ def reset_counters() -> None:
141
141
 
142
142
 
143
143
  def _drain_queue_for_tests() -> None:
144
- """Drain the module queue — TEST-ONLY."""
144
+ """Drain the module queue — TEST-ONLY.
145
+
146
+ Swallows AttributeError so test harnesses that swap in a minimal
147
+ stub queue (e.g. exploding-queue fixtures) can still clean up.
148
+ """
145
149
  while True:
146
150
  try:
147
151
  _Q.get_nowait()
148
152
  except queue.Empty:
149
153
  return
154
+ except AttributeError:
155
+ return
150
156
 
151
157
 
152
158
  def queue_size() -> int:
@@ -28,11 +28,11 @@ router = APIRouter()
28
28
 
29
29
  @router.get("/api/export")
30
30
  async def export_memories(
31
- format: str = Query("json", pattern="^(json|jsonl)$"),
31
+ format: str = Query("json", pattern="^(json|jsonl|csv)$"),
32
32
  category: Optional[str] = None,
33
33
  project_name: Optional[str] = None,
34
34
  ):
35
- """Export memories as JSON or JSONL."""
35
+ """Export memories as JSON, JSONL, or CSV."""
36
36
  try:
37
37
  conn = get_db_connection()
38
38
  conn.row_factory = dict_factory
@@ -76,6 +76,25 @@ async def export_memories(
76
76
  if format == "jsonl":
77
77
  content = "\n".join(json.dumps(m) for m in memories)
78
78
  media_type = "application/x-ndjson"
79
+ elif format == "csv":
80
+ import csv
81
+ import io as _io
82
+ if memories:
83
+ buf = _io.StringIO()
84
+ fieldnames = list(memories[0].keys())
85
+ writer = csv.DictWriter(
86
+ buf, fieldnames=fieldnames, extrasaction="ignore",
87
+ )
88
+ writer.writeheader()
89
+ for m in memories:
90
+ writer.writerow({
91
+ k: (json.dumps(v) if isinstance(v, (dict, list)) else v)
92
+ for k, v in m.items()
93
+ })
94
+ content = buf.getvalue()
95
+ else:
96
+ content = ""
97
+ media_type = "text/csv"
79
98
  else:
80
99
  content = json.dumps({
81
100
  "version": "3.0.0",
@@ -607,6 +607,97 @@ async def get_memory_facts(request: Request, memory_id: str):
607
607
  raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
608
608
 
609
609
 
610
+ @router.get("/api/memories/{memory_id}/detail")
611
+ async def get_memory_detail(request: Request, memory_id: str):
612
+ """Full memory row + all child atomic facts (for dashboard modal)."""
613
+ try:
614
+ conn = get_db_connection()
615
+ conn.row_factory = dict_factory
616
+ cursor = conn.cursor()
617
+ active_profile = get_active_profile()
618
+
619
+ cursor.execute(
620
+ "SELECT memory_id, content, session_id, speaker, role, "
621
+ "session_date, created_at, metadata_json "
622
+ "FROM memories WHERE memory_id = ? AND profile_id = ?",
623
+ (memory_id, active_profile),
624
+ )
625
+ mem = cursor.fetchone()
626
+ if not mem:
627
+ conn.close()
628
+ raise HTTPException(status_code=404, detail="Memory not found")
629
+
630
+ cursor.execute(
631
+ "SELECT fact_id, content, fact_type, confidence, importance, "
632
+ "access_count, created_at, entities_json "
633
+ "FROM atomic_facts WHERE memory_id = ? AND profile_id = ? "
634
+ "ORDER BY created_at ASC",
635
+ (memory_id, active_profile),
636
+ )
637
+ facts = cursor.fetchall()
638
+ conn.close()
639
+
640
+ try:
641
+ mem["metadata"] = json.loads(mem.pop("metadata_json") or "{}")
642
+ except Exception:
643
+ mem["metadata"] = {}
644
+ for f in facts:
645
+ try:
646
+ f["entities"] = json.loads(f.pop("entities_json") or "[]")
647
+ except Exception:
648
+ f["entities"] = []
649
+
650
+ return {
651
+ "memory": mem,
652
+ "facts": facts,
653
+ "fact_count": len(facts),
654
+ }
655
+ except HTTPException:
656
+ raise
657
+ except Exception as e:
658
+ raise HTTPException(status_code=500, detail=f"Detail error: {str(e)}")
659
+
660
+
661
+ @router.get("/api/facts/{fact_id}")
662
+ async def get_fact_detail(request: Request, fact_id: str):
663
+ """Single atomic fact detail (for fact popup)."""
664
+ try:
665
+ conn = get_db_connection()
666
+ conn.row_factory = dict_factory
667
+ cursor = conn.cursor()
668
+ active_profile = get_active_profile()
669
+
670
+ cursor.execute(
671
+ "SELECT f.fact_id, f.memory_id, f.content, f.fact_type, "
672
+ "f.confidence, f.importance, f.access_count, f.created_at, "
673
+ "f.entities_json, f.canonical_entities_json, f.session_id, "
674
+ "m.content AS source_memory_content "
675
+ "FROM atomic_facts f "
676
+ "LEFT JOIN memories m ON f.memory_id = m.memory_id "
677
+ "WHERE f.fact_id = ? AND f.profile_id = ?",
678
+ (fact_id, active_profile),
679
+ )
680
+ row = cursor.fetchone()
681
+ conn.close()
682
+ if not row:
683
+ raise HTTPException(status_code=404, detail="Fact not found")
684
+ try:
685
+ row["entities"] = json.loads(row.pop("entities_json") or "[]")
686
+ except Exception:
687
+ row["entities"] = []
688
+ try:
689
+ row["canonical_entities"] = json.loads(
690
+ row.pop("canonical_entities_json") or "[]"
691
+ )
692
+ except Exception:
693
+ row["canonical_entities"] = []
694
+ return row
695
+ except HTTPException:
696
+ raise
697
+ except Exception as e:
698
+ raise HTTPException(status_code=500, detail=f"Fact detail error: {str(e)}")
699
+
700
+
610
701
  @router.delete("/api/memories/{fact_id}")
611
702
  async def delete_memory(request: Request, fact_id: str):
612
703
  """Delete a specific memory (atomic fact) by ID."""
@@ -40,6 +40,12 @@ async def get_stats():
40
40
  "SELECT COUNT(*) as total FROM atomic_facts WHERE profile_id = ?",
41
41
  (active_profile,),
42
42
  )
43
+ total_facts = cursor.fetchone()['total']
44
+
45
+ cursor.execute(
46
+ "SELECT COUNT(*) as total FROM memories WHERE profile_id = ?",
47
+ (active_profile,),
48
+ )
43
49
  total_memories = cursor.fetchone()['total']
44
50
 
45
51
  total_sessions = 0
@@ -52,7 +58,7 @@ async def get_stats():
52
58
  except Exception:
53
59
  pass
54
60
 
55
- total_graph_nodes = total_memories
61
+ total_graph_nodes = total_facts
56
62
  total_graph_edges = 0
57
63
  try:
58
64
  cursor.execute(
@@ -121,12 +127,13 @@ async def get_stats():
121
127
  importance_dist = []
122
128
 
123
129
  else:
124
- # V2 fallback
130
+ # V2 fallback — no atomic_facts; facts == memories
125
131
  cursor.execute(
126
132
  "SELECT COUNT(*) as total FROM memories WHERE profile = ?",
127
133
  (active_profile,),
128
134
  )
129
135
  total_memories = cursor.fetchone()['total']
136
+ total_facts = total_memories
130
137
 
131
138
  try:
132
139
  cursor.execute("SELECT COUNT(*) as total FROM sessions")
@@ -201,9 +208,16 @@ async def get_stats():
201
208
 
202
209
  conn.close()
203
210
 
211
+ facts_per_memory = (
212
+ round(total_facts / total_memories, 1)
213
+ if total_memories > 0 else 0.0
214
+ )
215
+
204
216
  return {
205
217
  "overview": {
206
218
  "total_memories": total_memories,
219
+ "total_facts": total_facts,
220
+ "facts_per_memory": facts_per_memory,
207
221
  "total_sessions": total_sessions,
208
222
  "total_clusters": total_clusters,
209
223
  "graph_nodes": total_graph_nodes,
@@ -53,15 +53,17 @@
53
53
  <div class="stat-bg"></div>
54
54
  <i class="bi bi-journal-text stat-icon text-primary"></i>
55
55
  <h3 class="mt-2 mb-0 stat-value" id="stat-memories">-</h3>
56
- <small class="text-muted">Total Memories</small>
56
+ <small class="text-muted">Memories</small>
57
+ <small class="text-muted d-block mt-1" id="stat-memories-sub" style="font-size: 0.75rem;">&nbsp;</small>
57
58
  </div>
58
59
  </div>
59
60
  <div class="col-md-3 col-6 mb-3">
60
- <div class="card stat-card stat-card-clusters text-center p-3">
61
+ <div class="card stat-card stat-card-facts text-center p-3">
61
62
  <div class="stat-bg"></div>
62
- <i class="bi bi-collection stat-icon text-success"></i>
63
- <h3 class="mt-2 mb-0 stat-value" id="stat-clusters">-</h3>
64
- <small class="text-muted">Clusters</small>
63
+ <i class="bi bi-diagram-3 stat-icon text-info"></i>
64
+ <h3 class="mt-2 mb-0 stat-value" id="stat-facts">-</h3>
65
+ <small class="text-muted">Atomic Facts</small>
66
+ <small class="text-muted d-block mt-1" id="stat-facts-sub" style="font-size: 0.75rem;">&nbsp;</small>
65
67
  </div>
66
68
  </div>
67
69
  <div class="col-md-3 col-6 mb-3">
@@ -477,27 +479,34 @@
477
479
  <div id="recall-lab-results"></div>
478
480
  </div>
479
481
 
480
- <input type="hidden" id="search-query" value="" aria-hidden="true">
481
-
482
482
  <div class="card p-3 mb-3">
483
- <div class="d-flex justify-content-between align-items-center mb-3">
484
- <h5 class="mb-0"><i class="bi bi-list-ul"></i> Browse all memories</h5>
485
- <div class="dropdown export-dropdown">
486
- <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
487
- <i class="bi bi-download"></i> Export
488
- </button>
489
- <ul class="dropdown-menu dropdown-menu-end">
490
- <li><a class="dropdown-item" href="#" onclick="exportAll('json'); return false;">
491
- <i class="bi bi-filetype-json"></i> Export All (JSON)
492
- </a></li>
493
- <li><a class="dropdown-item" href="#" onclick="exportAll('jsonl'); return false;">
494
- <i class="bi bi-file-text"></i> Export All (JSONL)
495
- </a></li>
496
- <li><hr class="dropdown-divider"></li>
497
- <li><a class="dropdown-item" href="#" id="export-search-btn" onclick="exportSearchResults(); return false;" style="display:none;">
498
- <i class="bi bi-funnel"></i> Export Search Results
499
- </a></li>
500
- </ul>
483
+ <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
484
+ <h5 class="mb-0"><i class="bi bi-list-ul"></i> Browse atomic facts</h5>
485
+ <div class="d-flex align-items-center gap-2 flex-wrap">
486
+ <div class="input-group input-group-sm" style="width: 280px;">
487
+ <span class="input-group-text"><i class="bi bi-search"></i></span>
488
+ <input type="search" class="form-control" id="search-query" placeholder="Search memories..." autocomplete="off">
489
+ </div>
490
+ <div class="dropdown export-dropdown">
491
+ <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
492
+ <i class="bi bi-download"></i> Export
493
+ </button>
494
+ <ul class="dropdown-menu dropdown-menu-end">
495
+ <li><a class="dropdown-item" href="#" onclick="exportAll('json'); return false;">
496
+ <i class="bi bi-filetype-json"></i> Export All (JSON)
497
+ </a></li>
498
+ <li><a class="dropdown-item" href="#" onclick="exportAll('jsonl'); return false;">
499
+ <i class="bi bi-file-text"></i> Export All (JSONL)
500
+ </a></li>
501
+ <li><a class="dropdown-item" href="#" onclick="exportAll('csv'); return false;">
502
+ <i class="bi bi-filetype-csv"></i> Export All (CSV)
503
+ </a></li>
504
+ <li><hr class="dropdown-divider"></li>
505
+ <li><a class="dropdown-item" href="#" id="export-search-btn" onclick="exportSearchResults(); return false;" style="display:none;">
506
+ <i class="bi bi-funnel"></i> Export Search Results
507
+ </a></li>
508
+ </ul>
509
+ </div>
501
510
  </div>
502
511
  </div>
503
512
  <div class="row g-2 mb-3">
@@ -543,6 +552,7 @@
543
552
  <div>Loading memories...</div>
544
553
  </div>
545
554
  </div>
555
+ <div id="memories-pagination"></div>
546
556
  </div>
547
557
 
548
558
  <div class="card p-3 mt-3">
@@ -274,16 +274,32 @@ async function loadStats() {
274
274
  var response = await slmFetch('/api/stats');
275
275
  var data = await response.json();
276
276
  var ov = data.overview || {};
277
- animateCounter('stat-memories', ov.total_memories || 0);
278
- animateCounter('stat-clusters', ov.total_clusters || 0);
277
+ var memories = ov.total_memories || 0;
278
+ var facts = ov.total_facts || 0;
279
+ var ratio = ov.facts_per_memory || 0;
280
+
281
+ animateCounter('stat-memories', memories);
282
+ animateCounter('stat-facts', facts);
279
283
  animateCounter('stat-nodes', ov.graph_nodes || 0);
280
284
  animateCounter('stat-edges', ov.graph_edges || 0);
285
+
286
+ var mSub = document.getElementById('stat-memories-sub');
287
+ if (mSub) {
288
+ mSub.textContent = memories > 0
289
+ ? 'what you stored'
290
+ : '\u00a0';
291
+ }
292
+ var fSub = document.getElementById('stat-facts-sub');
293
+ if (fSub) {
294
+ fSub.textContent = memories > 0
295
+ ? 'avg ' + ratio + ' per memory'
296
+ : '\u00a0';
297
+ }
281
298
  populateFilters(data.categories || [], data.projects || []);
282
299
  } catch (error) {
283
300
  console.error('Error loading stats:', error);
284
- // On error (fresh install, server starting), show 0 instead of "-"
285
301
  animateCounter('stat-memories', 0);
286
- animateCounter('stat-clusters', 0);
302
+ animateCounter('stat-facts', 0);
287
303
  animateCounter('stat-nodes', 0);
288
304
  animateCounter('stat-edges', 0);
289
305
  }
@@ -1,92 +1,81 @@
1
1
  // SuperLocalMemory V3 — Fact Detail View
2
- // Adds click-to-expand on memory list items to show channel scores and trust data.
2
+ // v3.4.31: scoped click listener, real fact_id lookup, no text-based re-query.
3
+ //
4
+ // Scope: only `.fact-result-item` elements (search results view), NEVER
5
+ // fires on the main memories table rows (those use openMemoryDetail via
6
+ // memories.js). This prevents the two listeners from colliding.
3
7
 
4
8
  document.addEventListener('click', function(e) {
5
- var item = e.target.closest('[data-fact-id]');
9
+ var item = e.target.closest('.fact-result-item[data-fact-id]');
6
10
  if (!item) return;
7
11
 
8
- // Toggle: if detail panel already exists, remove it
12
+ // Don't interfere if the click was on an action button/link inside the row
13
+ if (e.target.closest('button, a, [data-bs-toggle]')) return;
14
+
9
15
  var existingDetail = item.querySelector('.fact-detail-panel');
10
16
  if (existingDetail) {
11
17
  existingDetail.remove();
12
18
  return;
13
19
  }
14
20
 
15
- // Extract query text from the item (first 100 chars)
16
- var queryText = (item.textContent || '').substring(0, 100).trim();
17
- if (!queryText) return;
18
-
19
- fetch('/api/v3/recall/trace', {
20
- method: 'POST',
21
- headers: { 'Content-Type': 'application/json' },
22
- body: JSON.stringify({ query: queryText, limit: 1 })
23
- }).then(function(r) {
24
- return r.json();
25
- }).then(function(data) {
26
- var result = (data.results || [])[0];
27
- if (!result) return;
28
-
29
- var panel = document.createElement('div');
30
- panel.className = 'fact-detail-panel card mt-2 mb-2 border-info';
31
-
32
- var cardBody = document.createElement('div');
33
- cardBody.className = 'card-body small';
34
-
35
- // Score / Trust / Confidence row
36
- var row1 = document.createElement('div');
37
- row1.className = 'row';
21
+ var factId = item.getAttribute('data-fact-id');
22
+ if (!factId) return;
38
23
 
39
- var col1 = document.createElement('div');
40
- col1.className = 'col-md-4';
41
- var col1Label = document.createElement('strong');
42
- col1Label.textContent = 'Score: ';
43
- col1.appendChild(col1Label);
44
- col1.appendChild(document.createTextNode(result.score || 0));
45
- row1.appendChild(col1);
24
+ fetch('/api/facts/' + encodeURIComponent(factId))
25
+ .then(function(r) { return r.ok ? r.json() : null; })
26
+ .then(function(data) {
27
+ if (!data || !data.fact_id) return;
46
28
 
47
- var col2 = document.createElement('div');
48
- col2.className = 'col-md-4';
49
- var col2Label = document.createElement('strong');
50
- col2Label.textContent = 'Trust: ';
51
- col2.appendChild(col2Label);
52
- col2.appendChild(document.createTextNode(result.trust_score || 0));
53
- row1.appendChild(col2);
29
+ var panel = document.createElement('div');
30
+ panel.className = 'fact-detail-panel card mt-2 mb-2 border-info';
31
+ var body = document.createElement('div');
32
+ body.className = 'card-body small';
54
33
 
55
- var col3 = document.createElement('div');
56
- col3.className = 'col-md-4';
57
- var col3Label = document.createElement('strong');
58
- col3Label.textContent = 'Confidence: ';
59
- col3.appendChild(col3Label);
60
- col3.appendChild(document.createTextNode(result.confidence || 0));
61
- row1.appendChild(col3);
34
+ var head = document.createElement('div');
35
+ head.className = 'mb-2';
36
+ var h = document.createElement('strong');
37
+ h.textContent = 'Atomic fact';
38
+ head.appendChild(h);
39
+ head.appendChild(document.createTextNode(
40
+ ' · ' + (data.fact_type || '-') +
41
+ ' · confidence ' + (data.confidence || 0) +
42
+ ' · importance ' + (data.importance || 0)
43
+ ));
44
+ body.appendChild(head);
62
45
 
63
- cardBody.appendChild(row1);
46
+ if (data.source_memory_content) {
47
+ var src = document.createElement('div');
48
+ src.className = 'text-muted small mt-2';
49
+ var srcLabel = document.createElement('strong');
50
+ srcLabel.textContent = 'From memory: ';
51
+ src.appendChild(srcLabel);
52
+ var srcText = String(data.source_memory_content);
53
+ src.appendChild(document.createTextNode(
54
+ srcText.length > 200 ? srcText.substring(0, 200) + '...' : srcText
55
+ ));
56
+ body.appendChild(src);
57
+ }
64
58
 
65
- // Channel scores section
66
- var channels = result.channel_scores || {};
67
- var channelKeys = Object.keys(channels);
68
- if (channelKeys.length > 0) {
69
- var label = document.createElement('div');
70
- label.className = 'mt-2';
71
- var labelStrong = document.createElement('strong');
72
- labelStrong.textContent = 'Channel Scores:';
73
- label.appendChild(labelStrong);
74
- cardBody.appendChild(label);
59
+ var ids = document.createElement('div');
60
+ ids.className = 'text-muted mt-2';
61
+ ids.style.fontSize = '0.75rem';
62
+ ids.textContent = 'Fact ID: ' + data.fact_id + ' · Memory ID: ' + (data.memory_id || '-');
63
+ body.appendChild(ids);
75
64
 
76
- var row2 = document.createElement('div');
77
- row2.className = 'row text-muted';
78
- channelKeys.forEach(function(chKey) {
79
- var chCol = document.createElement('div');
80
- chCol.className = 'col-md-3';
81
- chCol.textContent = chKey + ': ' + channels[chKey];
82
- row2.appendChild(chCol);
83
- });
84
- cardBody.appendChild(row2);
85
- }
65
+ if (data.entities && data.entities.length > 0) {
66
+ var ent = document.createElement('div');
67
+ ent.className = 'mt-2';
68
+ var entLabel = document.createElement('strong');
69
+ entLabel.textContent = 'Entities: ';
70
+ ent.appendChild(entLabel);
71
+ ent.appendChild(document.createTextNode(data.entities.join(', ')));
72
+ body.appendChild(ent);
73
+ }
86
74
 
87
- panel.appendChild(cardBody);
88
- item.appendChild(panel);
89
- }).catch(function(e) {
90
- console.log('Fact detail error:', e);
91
- });
75
+ panel.appendChild(body);
76
+ item.appendChild(panel);
77
+ })
78
+ .catch(function(err) {
79
+ console.warn('Fact detail error:', err);
80
+ });
92
81
  });
@@ -20,10 +20,18 @@ function setMemoryFilter(name) {
20
20
  loadMemories();
21
21
  }
22
22
 
23
- async function loadMemories() {
23
+ // v3.4.31 pagination state
24
+ var _slmPage = 0;
25
+ var _slmPageSize = 50;
26
+ var _slmLastTotal = 0;
27
+
28
+ async function loadMemories(page) {
29
+ if (typeof page === 'number') _slmPage = Math.max(0, page);
24
30
  var category = document.getElementById('filter-category').value;
25
31
  var project = document.getElementById('filter-project').value;
26
- var url = '/api/memories?limit=50';
32
+ var limit = _slmPageSize;
33
+ var offset = _slmPage * limit;
34
+ var url = '/api/memories?limit=' + limit + '&offset=' + offset;
27
35
  if (category) url += '&category=' + encodeURIComponent(category);
28
36
  if (project) url += '&project_name=' + encodeURIComponent(project);
29
37
  if (_slmMemoryFilter) {
@@ -37,13 +45,37 @@ async function loadMemories() {
37
45
  lastSearchResults = null;
38
46
  var exportBtn = document.getElementById('export-search-btn');
39
47
  if (exportBtn) exportBtn.style.display = 'none';
48
+ _slmLastTotal = data.total || 0;
40
49
  renderMemoriesTable(data.memories, false);
50
+ renderPaginationControls(data);
41
51
  } catch (error) {
42
52
  console.error('Error loading memories:', error);
43
53
  showEmpty('memories-list', 'exclamation-triangle', 'Failed to load memories');
44
54
  }
45
55
  }
46
56
 
57
+ function renderPaginationControls(data) {
58
+ var el = document.getElementById('memories-pagination');
59
+ if (!el) return;
60
+ var total = data.total || 0;
61
+ var limit = data.limit || _slmPageSize;
62
+ var offset = data.offset || 0;
63
+ var showing = Math.min(offset + limit, total);
64
+ var firstIdx = total === 0 ? 0 : offset + 1;
65
+ var lastPage = Math.max(0, Math.ceil(total / limit) - 1);
66
+ var prevDisabled = _slmPage <= 0 ? 'disabled' : '';
67
+ var nextDisabled = _slmPage >= lastPage ? 'disabled' : '';
68
+ el.innerHTML =
69
+ '<div class="d-flex justify-content-between align-items-center mt-3 small">' +
70
+ '<span class="text-muted">Showing ' + firstIdx + '\u2013' + showing + ' of ' + total + ' memories</span>' +
71
+ '<div class="btn-group btn-group-sm">' +
72
+ '<button type="button" class="btn btn-outline-secondary" ' + prevDisabled + ' onclick="loadMemories(' + (_slmPage - 1) + ')"><i class="bi bi-chevron-left"></i> Prev</button>' +
73
+ '<button type="button" class="btn btn-outline-secondary disabled">Page ' + (_slmPage + 1) + ' / ' + (lastPage + 1) + '</button>' +
74
+ '<button type="button" class="btn btn-outline-secondary" ' + nextDisabled + ' onclick="loadMemories(' + (_slmPage + 1) + ')">Next <i class="bi bi-chevron-right"></i></button>' +
75
+ '</div>' +
76
+ '</div>';
77
+ }
78
+
47
79
  function renderMemoriesTable(memories, showScores) {
48
80
  var container = document.getElementById('memories-list');
49
81
  if (!memories || memories.length === 0) {
@@ -40,11 +40,18 @@ function openMemoryDetail(mem, source) {
40
40
  var dl = document.createElement('dl');
41
41
  dl.className = 'memory-detail-meta row';
42
42
 
43
+ // v3.4.31: disambiguate Fact ID (the atomic unit) from Memory ID (the parent).
44
+ var factId = mem.fact_id || mem.id || '';
45
+ var memoryId = mem.memory_id || mem.id || '';
46
+
43
47
  // Left column
44
48
  var col1 = document.createElement('div');
45
49
  col1.className = 'col-md-6';
46
- addDetailRow(col1, 'ID', String(mem.id || '-'));
47
- addDetailBadgeRow(col1, 'Category', mem.category || 'None', 'bg-primary');
50
+ addDetailRow(col1, 'Memory ID', String(memoryId || '-'));
51
+ if (factId && factId !== memoryId) {
52
+ addDetailRow(col1, 'Fact ID', String(factId));
53
+ }
54
+ addDetailBadgeRow(col1, 'Category', mem.category || mem.fact_type || 'None', 'bg-primary');
48
55
  addDetailRow(col1, 'Project', mem.project_name || '-');
49
56
  addDetailTagsRow(col1, 'Tags', tags);
50
57
  dl.appendChild(col1);
@@ -65,6 +72,38 @@ function openMemoryDetail(mem, source) {
65
72
 
66
73
  body.appendChild(dl);
67
74
 
75
+ // v3.4.31: hydrate with full memory + fact list from /api/memories/{id}/detail
76
+ if (memoryId) {
77
+ fetch('/api/memories/' + encodeURIComponent(memoryId) + '/detail')
78
+ .then(function(r) { return r.ok ? r.json() : null; })
79
+ .then(function(data) {
80
+ if (!data || !data.memory) return;
81
+ var hydration = document.getElementById('memory-detail-hydration');
82
+ if (hydration) hydration.remove();
83
+ var block = document.createElement('div');
84
+ block.id = 'memory-detail-hydration';
85
+ block.className = 'mt-3';
86
+ var h = document.createElement('h6');
87
+ h.innerHTML = '<i class="bi bi-diagram-3"></i> Atomic facts extracted from this memory (' + (data.fact_count || 0) + ')';
88
+ block.appendChild(h);
89
+ var list = document.createElement('div');
90
+ list.className = 'list-group list-group-flush';
91
+ (data.facts || []).forEach(function(f) {
92
+ var row = document.createElement('div');
93
+ row.className = 'list-group-item list-group-item-action small fact-result-item';
94
+ row.setAttribute('data-fact-id', f.fact_id);
95
+ row.style.cursor = 'pointer';
96
+ var badge = '<span class="badge bg-secondary me-2">' + (f.fact_type || '-') + '</span>';
97
+ var confText = ' · confidence ' + (f.confidence || 0).toFixed(2);
98
+ row.innerHTML = badge + escapeHtml(String(f.content || '')) + '<small class="text-muted">' + escapeHtml(confText) + '</small>';
99
+ list.appendChild(row);
100
+ });
101
+ block.appendChild(list);
102
+ body.appendChild(block);
103
+ })
104
+ .catch(function(err) { console.warn('Hydration error:', err); });
105
+ }
106
+
68
107
  // Context-aware action buttons
69
108
  if (mem.id) {
70
109
  body.appendChild(document.createElement('hr'));
@@ -41,9 +41,36 @@ function exportAll(format) {
41
41
  var project = document.getElementById('filter-project').value;
42
42
  if (category) url += '&category=' + encodeURIComponent(category);
43
43
  if (project) url += '&project_name=' + encodeURIComponent(project);
44
+ if (typeof showToast === 'function') {
45
+ showToast('Preparing ' + format.toUpperCase() + ' export...');
46
+ }
44
47
  window.location.href = url;
45
48
  }
46
49
 
50
+ (function wireSearchInput() {
51
+ function init() {
52
+ var el = document.getElementById('search-query');
53
+ if (!el || el._slmWired) return;
54
+ el._slmWired = true;
55
+ var timer = null;
56
+ el.addEventListener('input', function() {
57
+ clearTimeout(timer);
58
+ timer = setTimeout(searchMemories, 280);
59
+ });
60
+ el.addEventListener('keydown', function(e) {
61
+ if (e.key === 'Enter') {
62
+ clearTimeout(timer);
63
+ searchMemories();
64
+ }
65
+ });
66
+ }
67
+ if (document.readyState === 'loading') {
68
+ document.addEventListener('DOMContentLoaded', init);
69
+ } else {
70
+ init();
71
+ }
72
+ })();
73
+
47
74
  function exportSearchResults() {
48
75
  if (!lastSearchResults || lastSearchResults.length === 0) {
49
76
  showToast('No search results to export');