superlocalmemory 3.4.30 → 3.4.32
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 +85 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/pending_store.py +55 -3
- package/src/superlocalmemory/core/maintenance_scheduler.py +8 -0
- package/src/superlocalmemory/core/recall_gate.py +36 -0
- package/src/superlocalmemory/learning/signals.py +7 -1
- package/src/superlocalmemory/mcp/_pool_adapter.py +16 -4
- package/src/superlocalmemory/mcp/tools_core.py +7 -34
- package/src/superlocalmemory/server/routes/data_io.py +21 -2
- package/src/superlocalmemory/server/routes/memories.py +99 -1
- package/src/superlocalmemory/server/routes/stats.py +16 -2
- package/src/superlocalmemory/server/unified_daemon.py +117 -5
- package/src/superlocalmemory/ui/index.html +35 -25
- package/src/superlocalmemory/ui/js/core.js +20 -4
- package/src/superlocalmemory/ui/js/fact-detail.js +62 -73
- package/src/superlocalmemory/ui/js/memories.js +34 -2
- package/src/superlocalmemory/ui/js/modal.js +41 -2
- package/src/superlocalmemory/ui/js/search.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [3.4.32] - 2026-04-24
|
|
14
|
+
|
|
15
|
+
Fix: concurrent remembers no longer block recalls on the shared embedder.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Daemon `/remember` is now async by default.** Writes to the pending
|
|
19
|
+
queue in under 100 ms and returns a `pending_id`; a background thread
|
|
20
|
+
drains the queue in the background. Previously, the synchronous
|
|
21
|
+
`engine.store()` on the FastAPI event loop could block `/search` and
|
|
22
|
+
`/health` for 30+ seconds while the single embedder worker processed a
|
|
23
|
+
large write. Under concurrent load the daemon could appear hung.
|
|
24
|
+
- **Materializer yields to active recalls.** While any `/search` is in
|
|
25
|
+
flight the drainer sleeps between items, so user-initiated recalls
|
|
26
|
+
always get the embedder first.
|
|
27
|
+
- **MCP remember tool simplified.** Writes to `pending.db` and returns;
|
|
28
|
+
the daemon's materializer completes the pipeline. Removes the
|
|
29
|
+
redundant in-process `pool.store` background task that previously
|
|
30
|
+
contended with `/search`.
|
|
31
|
+
- **`pool_store` returns `["pending:<id>"]`** when the daemon is async,
|
|
32
|
+
keeping a stable identifier for callers without blocking on the
|
|
33
|
+
embedder.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- `?wait=true` query parameter on `POST /remember` for callers that
|
|
37
|
+
need synchronous behaviour and real `fact_ids` in the response.
|
|
38
|
+
- `superlocalmemory.core.recall_gate` module — shared counter that lets
|
|
39
|
+
the materializer detect in-flight recalls and yield priority.
|
|
40
|
+
|
|
41
|
+
### Migration notes
|
|
42
|
+
- **No action required.** Existing clients continue to work; the
|
|
43
|
+
response shape is compatible (`ok`, `count` still present). Scripts
|
|
44
|
+
that depended on `fact_ids` to validate the write should switch to
|
|
45
|
+
`pending_id` or pass `?wait=true` to opt in to the legacy behaviour.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## [3.4.31] - 2026-04-24
|
|
50
|
+
|
|
51
|
+
Dashboard truth, memory vs fact clarity, and self-cleaning pending queue.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **Dashboard now shows both memory counts honestly.** Parent memories
|
|
55
|
+
(what you stored) and atomic facts (what retrieval indexes) appear as
|
|
56
|
+
two distinct cards with their ratio. No more "Total Memories: 6,000"
|
|
57
|
+
when you actually have 2,000 memories decomposed into 6,000 facts.
|
|
58
|
+
- **"Browse atomic facts"** relabeled for clarity — this view lists the
|
|
59
|
+
indexed atomic units.
|
|
60
|
+
- **Visible search box** in the Memories tab — previously hidden behind
|
|
61
|
+
the Recall Lab only. Search now debounces 280 ms on input.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- **`/api/memories/{id}/detail`** — full memory + all child atomic facts
|
|
65
|
+
in one call. Powers the click-to-expand modal.
|
|
66
|
+
- **`/api/facts/{id}`** — single atomic fact detail with source memory
|
|
67
|
+
content, entities, and canonical entities.
|
|
68
|
+
- **Pagination UI** — Prev/Next controls show "Showing 1–50 of 6,123".
|
|
69
|
+
Previously hardcoded to 50 with no navigation.
|
|
70
|
+
- **CSV export** — new `format=csv` option on `/api/export` plus a
|
|
71
|
+
dedicated "Export All (CSV)" menu item. JSON and JSONL still work.
|
|
72
|
+
- **Export progress toast** — "Preparing JSON export…" notification
|
|
73
|
+
before the download starts.
|
|
74
|
+
- **`total_facts` + `facts_per_memory`** in `/api/stats` response.
|
|
75
|
+
- **Pending queue auto-cleanup** — the maintenance scheduler now sweeps
|
|
76
|
+
the pending queue every cycle: completed rows > 7 days, failed rows
|
|
77
|
+
over retry limit, and stuck rows > 7 days are removed; a 30-day hard
|
|
78
|
+
cap prevents runaway growth on any status.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
- **Test isolation** — `pending_store` now honors `SLM_DATA_DIR`. Four
|
|
82
|
+
MCP remember tests were writing to the live `~/.superlocalmemory/`
|
|
83
|
+
instead of `tmp_path`. Root conftest now forces `SLM_DATA_DIR=tmp_path`
|
|
84
|
+
for every test unless explicitly opted out.
|
|
85
|
+
- **Fact click popup** — was calling `/api/v3/recall/trace` with a text
|
|
86
|
+
substring (re-query by first 100 chars) and colliding with the memory
|
|
87
|
+
row click handler. Now scoped to `.fact-result-item` only, hits the
|
|
88
|
+
new `/api/facts/{fact_id}` endpoint.
|
|
89
|
+
- **Memory modal ID confusion** — the modal labeled `mem.id` as "ID"
|
|
90
|
+
regardless of whether it was a memory_id or fact_id. Now displays
|
|
91
|
+
both "Memory ID" and "Fact ID" when they differ.
|
|
92
|
+
- **Memory modal hydration** — fetches the full memory + fact list
|
|
93
|
+
asynchronously when opened, so source content and entity data appear
|
|
94
|
+
even for rows that arrived from the search endpoint.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
13
98
|
## [3.4.30] - 2026-04-24
|
|
14
99
|
|
|
15
100
|
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.
|
|
3
|
+
"version": "3.4.32",
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""v3.4.32: Recall-in-flight counter used to give /search priority over the
|
|
6
|
+
pending materializer.
|
|
7
|
+
|
|
8
|
+
Every recall handler calls ``begin_recall()`` on entry and ``end_recall()``
|
|
9
|
+
in a finally block. The pending-memory materializer thread polls
|
|
10
|
+
``in_flight()`` and sleeps while any recall is active, so the shared
|
|
11
|
+
embedder worker never serves a materialization ahead of a user-initiated
|
|
12
|
+
recall.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
_lock = threading.Lock()
|
|
19
|
+
_active = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def begin_recall() -> None:
|
|
23
|
+
global _active
|
|
24
|
+
with _lock:
|
|
25
|
+
_active += 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def end_recall() -> None:
|
|
29
|
+
global _active
|
|
30
|
+
with _lock:
|
|
31
|
+
_active = max(0, _active - 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def in_flight() -> int:
|
|
35
|
+
with _lock:
|
|
36
|
+
return _active
|
|
@@ -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:
|
|
@@ -110,12 +110,24 @@ def pool_recall(query: str, limit: int = 10, **_: Any) -> PoolRecallResponse:
|
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
def pool_store(content: str, metadata: dict | None = None) -> list[str]:
|
|
113
|
-
"""Call pool.store and return
|
|
113
|
+
"""Call pool.store and return fact id list (or pending tracker).
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
v3.4.32: the daemon /remember endpoint is async by default — it
|
|
116
|
+
returns ``pending_id`` and queues the write. We surface this to
|
|
117
|
+
callers as ``["pending:<id>"]`` so they have a stable identifier
|
|
118
|
+
without blocking the remember on the embedder worker.
|
|
119
|
+
|
|
120
|
+
Legacy synchronous path (``?wait=true``) still returns real
|
|
121
|
+
``fact_ids``. Worker death raises :class:`PoolError`.
|
|
116
122
|
"""
|
|
117
123
|
raw = _pool().store(content=content, metadata=metadata or {})
|
|
118
124
|
_unwrap_error(raw, "store")
|
|
119
|
-
if isinstance(raw, dict):
|
|
120
|
-
return
|
|
125
|
+
if not isinstance(raw, dict):
|
|
126
|
+
return []
|
|
127
|
+
fact_ids = raw.get("fact_ids")
|
|
128
|
+
if fact_ids:
|
|
129
|
+
return list(fact_ids)
|
|
130
|
+
pending_id = raw.get("pending_id")
|
|
131
|
+
if pending_id is not None:
|
|
132
|
+
return [f"pending:{pending_id}"]
|
|
121
133
|
return []
|
|
@@ -113,12 +113,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
113
113
|
"""
|
|
114
114
|
import asyncio
|
|
115
115
|
try:
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
from superlocalmemory.cli.pending_store import store_pending, mark_done
|
|
116
|
+
# v3.4.32: Store-first pattern. Write to pending.db and return
|
|
117
|
+
# immediately. The daemon's pending-materializer thread drains
|
|
118
|
+
# the queue with recall priority, so concurrent MCP remembers
|
|
119
|
+
# no longer contend with /search on the shared embedder.
|
|
120
|
+
from superlocalmemory.cli.pending_store import store_pending
|
|
122
121
|
|
|
123
122
|
pending_id = store_pending(content, tags=tags, metadata={
|
|
124
123
|
"project": project,
|
|
@@ -127,39 +126,13 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
127
126
|
"session_id": session_id,
|
|
128
127
|
})
|
|
129
128
|
|
|
130
|
-
# Fire-and-forget: process in background thread
|
|
131
|
-
async def _process_in_background():
|
|
132
|
-
try:
|
|
133
|
-
from superlocalmemory.core.worker_pool import WorkerPool
|
|
134
|
-
pool = WorkerPool.shared()
|
|
135
|
-
result = await asyncio.to_thread(
|
|
136
|
-
pool.store, content, metadata={
|
|
137
|
-
"tags": tags, "project": project,
|
|
138
|
-
"importance": importance, "agent_id": agent_id,
|
|
139
|
-
"session_id": session_id,
|
|
140
|
-
},
|
|
141
|
-
)
|
|
142
|
-
if result.get("ok"):
|
|
143
|
-
mark_done(pending_id)
|
|
144
|
-
_emit_event("memory.created", {
|
|
145
|
-
"content_preview": content[:80],
|
|
146
|
-
"agent_id": agent_id,
|
|
147
|
-
"fact_count": result.get("count", 0),
|
|
148
|
-
}, source_agent=agent_id)
|
|
149
|
-
except Exception as _bg_exc:
|
|
150
|
-
logger.warning(
|
|
151
|
-
"Background store failed (pending_id=%s): %s",
|
|
152
|
-
pending_id, _bg_exc,
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
asyncio.create_task(_process_in_background())
|
|
156
|
-
|
|
157
129
|
return {
|
|
158
130
|
"success": True,
|
|
159
131
|
"fact_ids": [f"pending:{pending_id}"],
|
|
160
132
|
"count": 1,
|
|
161
133
|
"pending": True,
|
|
162
|
-
"
|
|
134
|
+
"pending_id": pending_id,
|
|
135
|
+
"message": "Stored — facts will appear in the dashboard shortly.",
|
|
163
136
|
}
|
|
164
137
|
except Exception as exc:
|
|
165
138
|
logger.exception("remember failed")
|
|
@@ -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
|
|
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",
|
|
@@ -398,7 +398,12 @@ async def get_graph(
|
|
|
398
398
|
|
|
399
399
|
@router.post("/api/search")
|
|
400
400
|
async def search_memories(request: Request, body: SearchRequest):
|
|
401
|
-
"""Semantic search via subprocess worker pool (memory-isolated).
|
|
401
|
+
"""Semantic search via subprocess worker pool (memory-isolated).
|
|
402
|
+
|
|
403
|
+
v3.4.32: marks recall in-flight so the pending materializer yields.
|
|
404
|
+
"""
|
|
405
|
+
from superlocalmemory.core.recall_gate import begin_recall, end_recall
|
|
406
|
+
begin_recall()
|
|
402
407
|
try:
|
|
403
408
|
from superlocalmemory.core.worker_pool import WorkerPool
|
|
404
409
|
pool = WorkerPool.shared()
|
|
@@ -435,6 +440,8 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
435
440
|
|
|
436
441
|
except Exception as e:
|
|
437
442
|
raise HTTPException(status_code=500, detail=f"Search error: {str(e)}")
|
|
443
|
+
finally:
|
|
444
|
+
end_recall()
|
|
438
445
|
|
|
439
446
|
|
|
440
447
|
@router.get("/api/clusters")
|
|
@@ -607,6 +614,97 @@ async def get_memory_facts(request: Request, memory_id: str):
|
|
|
607
614
|
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
|
608
615
|
|
|
609
616
|
|
|
617
|
+
@router.get("/api/memories/{memory_id}/detail")
|
|
618
|
+
async def get_memory_detail(request: Request, memory_id: str):
|
|
619
|
+
"""Full memory row + all child atomic facts (for dashboard modal)."""
|
|
620
|
+
try:
|
|
621
|
+
conn = get_db_connection()
|
|
622
|
+
conn.row_factory = dict_factory
|
|
623
|
+
cursor = conn.cursor()
|
|
624
|
+
active_profile = get_active_profile()
|
|
625
|
+
|
|
626
|
+
cursor.execute(
|
|
627
|
+
"SELECT memory_id, content, session_id, speaker, role, "
|
|
628
|
+
"session_date, created_at, metadata_json "
|
|
629
|
+
"FROM memories WHERE memory_id = ? AND profile_id = ?",
|
|
630
|
+
(memory_id, active_profile),
|
|
631
|
+
)
|
|
632
|
+
mem = cursor.fetchone()
|
|
633
|
+
if not mem:
|
|
634
|
+
conn.close()
|
|
635
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
636
|
+
|
|
637
|
+
cursor.execute(
|
|
638
|
+
"SELECT fact_id, content, fact_type, confidence, importance, "
|
|
639
|
+
"access_count, created_at, entities_json "
|
|
640
|
+
"FROM atomic_facts WHERE memory_id = ? AND profile_id = ? "
|
|
641
|
+
"ORDER BY created_at ASC",
|
|
642
|
+
(memory_id, active_profile),
|
|
643
|
+
)
|
|
644
|
+
facts = cursor.fetchall()
|
|
645
|
+
conn.close()
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
mem["metadata"] = json.loads(mem.pop("metadata_json") or "{}")
|
|
649
|
+
except Exception:
|
|
650
|
+
mem["metadata"] = {}
|
|
651
|
+
for f in facts:
|
|
652
|
+
try:
|
|
653
|
+
f["entities"] = json.loads(f.pop("entities_json") or "[]")
|
|
654
|
+
except Exception:
|
|
655
|
+
f["entities"] = []
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
"memory": mem,
|
|
659
|
+
"facts": facts,
|
|
660
|
+
"fact_count": len(facts),
|
|
661
|
+
}
|
|
662
|
+
except HTTPException:
|
|
663
|
+
raise
|
|
664
|
+
except Exception as e:
|
|
665
|
+
raise HTTPException(status_code=500, detail=f"Detail error: {str(e)}")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@router.get("/api/facts/{fact_id}")
|
|
669
|
+
async def get_fact_detail(request: Request, fact_id: str):
|
|
670
|
+
"""Single atomic fact detail (for fact popup)."""
|
|
671
|
+
try:
|
|
672
|
+
conn = get_db_connection()
|
|
673
|
+
conn.row_factory = dict_factory
|
|
674
|
+
cursor = conn.cursor()
|
|
675
|
+
active_profile = get_active_profile()
|
|
676
|
+
|
|
677
|
+
cursor.execute(
|
|
678
|
+
"SELECT f.fact_id, f.memory_id, f.content, f.fact_type, "
|
|
679
|
+
"f.confidence, f.importance, f.access_count, f.created_at, "
|
|
680
|
+
"f.entities_json, f.canonical_entities_json, f.session_id, "
|
|
681
|
+
"m.content AS source_memory_content "
|
|
682
|
+
"FROM atomic_facts f "
|
|
683
|
+
"LEFT JOIN memories m ON f.memory_id = m.memory_id "
|
|
684
|
+
"WHERE f.fact_id = ? AND f.profile_id = ?",
|
|
685
|
+
(fact_id, active_profile),
|
|
686
|
+
)
|
|
687
|
+
row = cursor.fetchone()
|
|
688
|
+
conn.close()
|
|
689
|
+
if not row:
|
|
690
|
+
raise HTTPException(status_code=404, detail="Fact not found")
|
|
691
|
+
try:
|
|
692
|
+
row["entities"] = json.loads(row.pop("entities_json") or "[]")
|
|
693
|
+
except Exception:
|
|
694
|
+
row["entities"] = []
|
|
695
|
+
try:
|
|
696
|
+
row["canonical_entities"] = json.loads(
|
|
697
|
+
row.pop("canonical_entities_json") or "[]"
|
|
698
|
+
)
|
|
699
|
+
except Exception:
|
|
700
|
+
row["canonical_entities"] = []
|
|
701
|
+
return row
|
|
702
|
+
except HTTPException:
|
|
703
|
+
raise
|
|
704
|
+
except Exception as e:
|
|
705
|
+
raise HTTPException(status_code=500, detail=f"Fact detail error: {str(e)}")
|
|
706
|
+
|
|
707
|
+
|
|
610
708
|
@router.delete("/api/memories/{fact_id}")
|
|
611
709
|
async def delete_memory(request: Request, fact_id: str):
|
|
612
710
|
"""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 =
|
|
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,
|
|
@@ -66,6 +66,20 @@ class ObserveRequest(BaseModel):
|
|
|
66
66
|
content: str
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# v3.4.32: Recall-priority gate for the pending materializer.
|
|
71
|
+
# All /remember writes go to pending.db and return fast; a background
|
|
72
|
+
# thread drains pending while yielding to any in-flight /search.
|
|
73
|
+
# See ``superlocalmemory.core.recall_gate``.
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
from superlocalmemory.core.recall_gate import (
|
|
77
|
+
begin_recall as _begin_recall,
|
|
78
|
+
end_recall as _end_recall,
|
|
79
|
+
in_flight as _recalls_in_flight,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
69
83
|
# ---------------------------------------------------------------------------
|
|
70
84
|
# Observation debounce buffer (migrated from daemon.py)
|
|
71
85
|
# ---------------------------------------------------------------------------
|
|
@@ -949,6 +963,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
949
963
|
if not effective_sid:
|
|
950
964
|
import time as _t
|
|
951
965
|
effective_sid = f"http:{int(_t.time() * 1000)}"
|
|
966
|
+
# v3.4.32: mark recall in-flight so the pending materializer pauses
|
|
967
|
+
_begin_recall()
|
|
952
968
|
try:
|
|
953
969
|
response = engine.recall(
|
|
954
970
|
search_query, limit=limit, session_id=effective_sid,
|
|
@@ -1006,18 +1022,47 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1006
1022
|
}
|
|
1007
1023
|
except Exception as exc:
|
|
1008
1024
|
raise HTTPException(500, detail=str(exc))
|
|
1025
|
+
finally:
|
|
1026
|
+
_end_recall()
|
|
1009
1027
|
|
|
1010
1028
|
@application.post("/remember")
|
|
1011
|
-
async def remember(req: RememberRequest):
|
|
1029
|
+
async def remember(req: RememberRequest, wait: bool = False):
|
|
1030
|
+
"""v3.4.32: Async by default — writes to pending.db, returns pending_id
|
|
1031
|
+
in <100ms. Materializer thread drains at low priority, yielding to
|
|
1032
|
+
/search. Pass ``?wait=true`` for legacy synchronous behavior (blocks
|
|
1033
|
+
on the embedder until facts are written).
|
|
1034
|
+
"""
|
|
1012
1035
|
_update_activity()
|
|
1013
1036
|
engine = _get_engine_or_503()
|
|
1037
|
+
|
|
1038
|
+
if wait:
|
|
1039
|
+
try:
|
|
1040
|
+
metadata = {"tags": req.tags} if req.tags else {}
|
|
1041
|
+
extra = getattr(req, "metadata", None)
|
|
1042
|
+
if isinstance(extra, dict):
|
|
1043
|
+
metadata.update(extra)
|
|
1044
|
+
fact_ids = engine.store(req.content, metadata=metadata)
|
|
1045
|
+
return {"ok": True, "fact_ids": fact_ids, "count": len(fact_ids)}
|
|
1046
|
+
except Exception as exc:
|
|
1047
|
+
raise HTTPException(500, detail=str(exc))
|
|
1048
|
+
|
|
1014
1049
|
try:
|
|
1015
|
-
|
|
1050
|
+
from superlocalmemory.cli.pending_store import store_pending
|
|
1051
|
+
meta = {}
|
|
1052
|
+
if req.tags:
|
|
1053
|
+
meta["tags"] = req.tags
|
|
1016
1054
|
extra = getattr(req, "metadata", None)
|
|
1017
1055
|
if isinstance(extra, dict):
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1056
|
+
meta.update(extra)
|
|
1057
|
+
pending_id = store_pending(
|
|
1058
|
+
req.content, tags=req.tags or "", metadata=meta,
|
|
1059
|
+
)
|
|
1060
|
+
return {
|
|
1061
|
+
"ok": True,
|
|
1062
|
+
"pending_id": pending_id,
|
|
1063
|
+
"status": "queued",
|
|
1064
|
+
"note": "materialized async; pass ?wait=true for legacy sync",
|
|
1065
|
+
}
|
|
1021
1066
|
except Exception as exc:
|
|
1022
1067
|
raise HTTPException(500, detail=str(exc))
|
|
1023
1068
|
|
|
@@ -1189,6 +1234,70 @@ def _start_memory_watchdog() -> None:
|
|
|
1189
1234
|
logger.info("Memory watchdog started (limit: %d MB per worker)", MAX_WORKER_MB)
|
|
1190
1235
|
|
|
1191
1236
|
|
|
1237
|
+
_materializer_stop = threading.Event()
|
|
1238
|
+
_materializer_thread: threading.Thread | None = None
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _start_pending_materializer() -> None:
|
|
1242
|
+
"""Background thread: drains pending.db, yields to active /search calls.
|
|
1243
|
+
|
|
1244
|
+
Poll loop:
|
|
1245
|
+
1. Fetch up to 5 pending rows.
|
|
1246
|
+
2. For each row: if any /search is in flight, sleep 500ms (yield priority).
|
|
1247
|
+
3. Call engine.store(), mark_done or mark_failed.
|
|
1248
|
+
4. Sleep 2s between polls when idle (empty queue).
|
|
1249
|
+
"""
|
|
1250
|
+
global _materializer_thread
|
|
1251
|
+
|
|
1252
|
+
def _loop():
|
|
1253
|
+
from superlocalmemory.cli.pending_store import (
|
|
1254
|
+
get_pending, mark_done, mark_failed,
|
|
1255
|
+
)
|
|
1256
|
+
while not _materializer_stop.is_set():
|
|
1257
|
+
try:
|
|
1258
|
+
engine = _engine # may be None briefly at startup
|
|
1259
|
+
if engine is None:
|
|
1260
|
+
time.sleep(2.0)
|
|
1261
|
+
continue
|
|
1262
|
+
pending = get_pending(limit=5)
|
|
1263
|
+
if not pending:
|
|
1264
|
+
time.sleep(2.0)
|
|
1265
|
+
continue
|
|
1266
|
+
for item in pending:
|
|
1267
|
+
if _materializer_stop.is_set():
|
|
1268
|
+
break
|
|
1269
|
+
# Yield to recalls: wait until none in flight
|
|
1270
|
+
waits = 0
|
|
1271
|
+
while _recalls_in_flight() > 0 and waits < 60:
|
|
1272
|
+
time.sleep(0.5)
|
|
1273
|
+
waits += 1
|
|
1274
|
+
try:
|
|
1275
|
+
import json as _json
|
|
1276
|
+
md_str = item.get("metadata") or "{}"
|
|
1277
|
+
try:
|
|
1278
|
+
md = _json.loads(md_str)
|
|
1279
|
+
except Exception:
|
|
1280
|
+
md = {}
|
|
1281
|
+
if item.get("tags"):
|
|
1282
|
+
md.setdefault("tags", item["tags"])
|
|
1283
|
+
engine.store(item["content"], metadata=md)
|
|
1284
|
+
mark_done(item["id"])
|
|
1285
|
+
except Exception as exc:
|
|
1286
|
+
logger.warning(
|
|
1287
|
+
"Pending %d failed: %s", item["id"], exc,
|
|
1288
|
+
)
|
|
1289
|
+
mark_failed(item["id"], str(exc))
|
|
1290
|
+
except Exception as exc:
|
|
1291
|
+
logger.warning("materializer loop error: %s", exc)
|
|
1292
|
+
time.sleep(5.0)
|
|
1293
|
+
|
|
1294
|
+
_materializer_thread = threading.Thread(
|
|
1295
|
+
target=_loop, daemon=True, name="pending-materializer",
|
|
1296
|
+
)
|
|
1297
|
+
_materializer_thread.start()
|
|
1298
|
+
logger.info("Pending materializer started (recall-priority)")
|
|
1299
|
+
|
|
1300
|
+
|
|
1192
1301
|
def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
1193
1302
|
"""Start the unified daemon. Blocks until stopped."""
|
|
1194
1303
|
global _start_time
|
|
@@ -1223,6 +1332,9 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
1223
1332
|
# v3.4.7: Start memory watchdog to prevent runaway workers
|
|
1224
1333
|
_start_memory_watchdog()
|
|
1225
1334
|
|
|
1335
|
+
# v3.4.32: Continuous pending-queue materializer with recall priority.
|
|
1336
|
+
_start_pending_materializer()
|
|
1337
|
+
|
|
1226
1338
|
log_dir = Path.home() / ".superlocalmemory" / "logs"
|
|
1227
1339
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1228
1340
|
|
|
@@ -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">
|
|
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;"> </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-
|
|
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-
|
|
63
|
-
<h3 class="mt-2 mb-0 stat-value" id="stat-
|
|
64
|
-
<small class="text-muted">
|
|
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;"> </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
|
|
485
|
-
<div class="
|
|
486
|
-
<
|
|
487
|
-
<i class="bi bi-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
var
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
47
|
-
|
|
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');
|