superlocalmemory 3.4.25 → 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 +92 -0
- package/README.md +8 -1
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/daemon.py +90 -16
- package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
- package/src/superlocalmemory/cli/main.py +28 -0
- package/src/superlocalmemory/cli/pending_store.py +55 -3
- package/src/superlocalmemory/cli/post_install.py +15 -0
- package/src/superlocalmemory/cli/setup_wizard.py +20 -0
- package/src/superlocalmemory/cli/version_banner.py +183 -0
- package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
- package/src/superlocalmemory/core/clock_monitor.py +45 -0
- package/src/superlocalmemory/core/db_pool.py +80 -0
- package/src/superlocalmemory/core/engine.py +75 -30
- package/src/superlocalmemory/core/engine_capabilities.py +24 -0
- package/src/superlocalmemory/core/engine_lock.py +75 -0
- package/src/superlocalmemory/core/error_catalog.py +113 -0
- package/src/superlocalmemory/core/error_envelope.py +60 -0
- package/src/superlocalmemory/core/file_lock.py +92 -0
- package/src/superlocalmemory/core/loop_watchdog.py +56 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +8 -0
- package/src/superlocalmemory/core/priority_queue.py +61 -0
- package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
- package/src/superlocalmemory/core/rate_limit.py +151 -0
- package/src/superlocalmemory/core/recall_queue.py +370 -0
- package/src/superlocalmemory/core/recall_worker.py +10 -0
- package/src/superlocalmemory/core/safe_fs.py +108 -0
- package/src/superlocalmemory/hooks/auto_capture.py +34 -12
- package/src/superlocalmemory/hooks/auto_recall.py +36 -9
- package/src/superlocalmemory/learning/signals.py +7 -1
- package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
- package/src/superlocalmemory/mcp/resources.py +8 -5
- package/src/superlocalmemory/mcp/server.py +38 -9
- package/src/superlocalmemory/mcp/tools_active.py +21 -9
- package/src/superlocalmemory/mcp/tools_core.py +13 -9
- package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
- package/src/superlocalmemory/mcp/tools_learning.py +5 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
- package/src/superlocalmemory/mcp/tools_v3.py +18 -22
- package/src/superlocalmemory/mcp/tools_v33.py +65 -2
- package/src/superlocalmemory/migrations/__init__.py +5 -0
- package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
- package/src/superlocalmemory/server/routes/data_io.py +21 -2
- package/src/superlocalmemory/server/routes/memories.py +91 -0
- package/src/superlocalmemory/server/routes/stats.py +16 -2
- package/src/superlocalmemory/server/unified_daemon.py +128 -12
- 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
|
@@ -18,6 +18,8 @@ import logging
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Callable
|
|
20
20
|
|
|
21
|
+
from mcp.types import ToolAnnotations
|
|
22
|
+
|
|
21
23
|
logger = logging.getLogger(__name__)
|
|
22
24
|
|
|
23
25
|
_DB_PATH = str(Path.home() / ".superlocalmemory" / "memory.db")
|
|
@@ -98,7 +100,7 @@ def _record_recall_hits(
|
|
|
98
100
|
def register_core_tools(server, get_engine: Callable) -> None:
|
|
99
101
|
"""Register the 13 core MCP tools on *server*."""
|
|
100
102
|
|
|
101
|
-
@server.tool()
|
|
103
|
+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
|
|
102
104
|
async def remember(
|
|
103
105
|
content: str, tags: str = "", project: str = "",
|
|
104
106
|
importance: int = 5, session_id: str = "",
|
|
@@ -163,7 +165,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
163
165
|
logger.exception("remember failed")
|
|
164
166
|
return {"success": False, "error": str(exc)}
|
|
165
167
|
|
|
166
|
-
@server.tool()
|
|
168
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
167
169
|
async def recall(
|
|
168
170
|
query: str, limit: int = 10, agent_id: str = "mcp_client",
|
|
169
171
|
session_id: str = "",
|
|
@@ -219,7 +221,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
219
221
|
pass
|
|
220
222
|
if not effective_sid:
|
|
221
223
|
effective_sid = f"mcp:{agent_id}"
|
|
222
|
-
# V3.3.19: Run in thread pool to avoid blocking MCP event loop
|
|
224
|
+
# V3.3.19: Run in thread pool to avoid blocking MCP event loop.
|
|
225
|
+
# V3.4.26: WorkerPool now concurrent — parallel calls no longer
|
|
226
|
+
# block behind a single threading.Lock. See worker_pool.py.
|
|
223
227
|
result = await asyncio.to_thread(
|
|
224
228
|
pool.recall, query, limit=limit, session_id=effective_sid,
|
|
225
229
|
)
|
|
@@ -248,7 +252,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
248
252
|
logger.exception("recall failed")
|
|
249
253
|
return {"success": False, "error": str(exc)}
|
|
250
254
|
|
|
251
|
-
@server.tool()
|
|
255
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
252
256
|
async def search(query: str, limit: int = 10, profile_id: str = "") -> dict:
|
|
253
257
|
"""Full-text search across memories using FTS5 with BM25 ranking."""
|
|
254
258
|
try:
|
|
@@ -269,7 +273,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
269
273
|
logger.exception("search failed")
|
|
270
274
|
return {"success": False, "error": str(exc)}
|
|
271
275
|
|
|
272
|
-
@server.tool()
|
|
276
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
273
277
|
async def fetch(fact_ids: str) -> dict:
|
|
274
278
|
"""Fetch full details for specific fact IDs (comma-separated)."""
|
|
275
279
|
try:
|
|
@@ -295,7 +299,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
295
299
|
logger.exception("fetch failed")
|
|
296
300
|
return {"success": False, "error": str(exc)}
|
|
297
301
|
|
|
298
|
-
@server.tool()
|
|
302
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
299
303
|
async def list_recent(limit: int = 20, profile_id: str = "") -> dict:
|
|
300
304
|
"""List most recently stored memories, newest first."""
|
|
301
305
|
try:
|
|
@@ -316,7 +320,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
316
320
|
logger.exception("list_recent failed")
|
|
317
321
|
return {"success": False, "error": str(exc)}
|
|
318
322
|
|
|
319
|
-
@server.tool()
|
|
323
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
320
324
|
async def get_status() -> dict:
|
|
321
325
|
"""Get memory system status: fact count, entity count, mode, profile, db size."""
|
|
322
326
|
try:
|
|
@@ -473,7 +477,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
473
477
|
logger.exception("correct_pattern failed")
|
|
474
478
|
return {"success": False, "error": str(exc)}
|
|
475
479
|
|
|
476
|
-
@server.tool()
|
|
480
|
+
@server.tool(annotations=ToolAnnotations(destructiveHint=True))
|
|
477
481
|
async def delete_memory(fact_id: str, agent_id: str = "mcp_client") -> dict:
|
|
478
482
|
"""Delete a specific memory by exact fact ID.
|
|
479
483
|
|
|
@@ -505,7 +509,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
505
509
|
logger.exception("delete_memory failed")
|
|
506
510
|
return {"success": False, "error": str(exc)}
|
|
507
511
|
|
|
508
|
-
@server.tool()
|
|
512
|
+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
|
|
509
513
|
async def update_memory(
|
|
510
514
|
fact_id: str, content: str, agent_id: str = "mcp_client",
|
|
511
515
|
) -> dict:
|
|
@@ -21,6 +21,8 @@ from datetime import datetime, timezone
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Callable
|
|
23
23
|
|
|
24
|
+
from mcp.types import ToolAnnotations
|
|
25
|
+
|
|
24
26
|
logger = logging.getLogger(__name__)
|
|
25
27
|
|
|
26
28
|
MEMORY_DB = Path.home() / ".superlocalmemory" / "memory.db"
|
|
@@ -125,7 +127,7 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
|
|
|
125
127
|
logger.debug("evolve_skill failed: %s", exc)
|
|
126
128
|
return {"success": False, "error": str(exc)}
|
|
127
129
|
|
|
128
|
-
@server.tool()
|
|
130
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
129
131
|
async def skill_health(
|
|
130
132
|
skill_name: str = "",
|
|
131
133
|
include_history: bool = False,
|
|
@@ -258,7 +260,7 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
|
|
|
258
260
|
logger.debug("skill_health failed: %s", exc)
|
|
259
261
|
return {"skills": [], "skill_count": 0, "error": str(exc)}
|
|
260
262
|
|
|
261
|
-
@server.tool()
|
|
263
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
262
264
|
async def skill_lineage(
|
|
263
265
|
skill_name: str = "",
|
|
264
266
|
) -> dict:
|
|
@@ -22,6 +22,8 @@ import uuid
|
|
|
22
22
|
from datetime import datetime, timezone
|
|
23
23
|
from typing import Callable
|
|
24
24
|
|
|
25
|
+
from mcp.types import ToolAnnotations
|
|
26
|
+
|
|
25
27
|
logger = logging.getLogger(__name__)
|
|
26
28
|
|
|
27
29
|
_MAX_SUMMARY_LEN = 500 # Truncate input/output summaries
|
|
@@ -80,7 +82,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
|
|
|
80
82
|
logger.debug("log_tool_event failed: %s", exc)
|
|
81
83
|
return {"success": False, "error": str(exc)}
|
|
82
84
|
|
|
83
|
-
@server.tool()
|
|
85
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
84
86
|
async def get_assertions(
|
|
85
87
|
min_confidence: float = 0.0,
|
|
86
88
|
category: str = "",
|
|
@@ -131,7 +133,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
|
|
|
131
133
|
logger.debug("get_assertions failed: %s", exc)
|
|
132
134
|
return {"assertions": [], "count": 0, "error": str(exc)}
|
|
133
135
|
|
|
134
|
-
@server.tool()
|
|
136
|
+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
|
|
135
137
|
async def reinforce_assertion(assertion_id: str) -> dict:
|
|
136
138
|
"""Reinforce a behavioral assertion (increase confidence).
|
|
137
139
|
|
|
@@ -144,7 +146,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
|
|
|
144
146
|
engine = get_engine()
|
|
145
147
|
return _update_assertion_confidence(engine._db, assertion_id, reinforce=True)
|
|
146
148
|
|
|
147
|
-
@server.tool()
|
|
149
|
+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
|
|
148
150
|
async def contradict_assertion(assertion_id: str) -> dict:
|
|
149
151
|
"""Contradict a behavioral assertion (decrease confidence).
|
|
150
152
|
|
|
@@ -24,6 +24,8 @@ import time
|
|
|
24
24
|
import uuid
|
|
25
25
|
from typing import Callable
|
|
26
26
|
|
|
27
|
+
from mcp.types import ToolAnnotations
|
|
28
|
+
|
|
27
29
|
logger = logging.getLogger(__name__)
|
|
28
30
|
|
|
29
31
|
# Unique peer ID for this MCP server session
|
|
@@ -156,7 +158,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
156
158
|
"broker_response": result,
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
@server.tool()
|
|
161
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
160
162
|
async def mesh_peers() -> dict:
|
|
161
163
|
"""List all active peer sessions on this machine.
|
|
162
164
|
|
|
@@ -267,7 +269,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
267
269
|
})
|
|
268
270
|
return result or {"error": "Lock operation failed"}
|
|
269
271
|
|
|
270
|
-
@server.tool()
|
|
272
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
271
273
|
async def mesh_events() -> dict:
|
|
272
274
|
"""Get recent mesh events (peer joins, leaves, messages, state changes).
|
|
273
275
|
|
|
@@ -276,7 +278,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
276
278
|
result = _mesh_request("GET", "/events")
|
|
277
279
|
return result or {"events": []}
|
|
278
280
|
|
|
279
|
-
@server.tool()
|
|
281
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
280
282
|
async def mesh_status() -> dict:
|
|
281
283
|
"""Get mesh broker health and statistics.
|
|
282
284
|
|
|
@@ -273,35 +273,31 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
273
273
|
limit: Maximum results (default 10).
|
|
274
274
|
"""
|
|
275
275
|
try:
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
from superlocalmemory.core.worker_pool import WorkerPool
|
|
277
|
+
raw = WorkerPool.shared().recall(query=query, limit=limit)
|
|
278
|
+
items = raw.get("results", []) if isinstance(raw, dict) else []
|
|
278
279
|
results = []
|
|
279
|
-
for
|
|
280
|
+
for item in items[:limit]:
|
|
280
281
|
results.append({
|
|
281
|
-
"fact_id":
|
|
282
|
-
"content":
|
|
283
|
-
"final_score": round(
|
|
284
|
-
"confidence": round(
|
|
285
|
-
"trust_score": round(
|
|
286
|
-
"channel_scores": {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
"
|
|
290
|
-
"
|
|
291
|
-
"lifecycle": r.fact.lifecycle.value,
|
|
292
|
-
"access_count": r.fact.access_count,
|
|
282
|
+
"fact_id": item.get("fact_id", ""),
|
|
283
|
+
"content": item.get("content", ""),
|
|
284
|
+
"final_score": round(float(item.get("score", 0.0)), 4),
|
|
285
|
+
"confidence": round(float(item.get("confidence", 0.0)), 3),
|
|
286
|
+
"trust_score": round(float(item.get("trust_score", 0.0)), 3),
|
|
287
|
+
"channel_scores": item.get("channel_scores", {}) or {},
|
|
288
|
+
"evidence_chain": item.get("evidence_chain", []) or [],
|
|
289
|
+
"fact_type": item.get("fact_type", ""),
|
|
290
|
+
"lifecycle": item.get("lifecycle", ""),
|
|
291
|
+
"access_count": int(item.get("access_count", 0)),
|
|
293
292
|
})
|
|
294
293
|
return {
|
|
295
294
|
"success": True,
|
|
296
295
|
"results": results,
|
|
297
296
|
"count": len(results),
|
|
298
|
-
"query_type":
|
|
299
|
-
"channel_weights": {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
},
|
|
303
|
-
"total_candidates": response.total_candidates,
|
|
304
|
-
"retrieval_time_ms": round(response.retrieval_time_ms, 1),
|
|
297
|
+
"query_type": raw.get("query_type", "") if isinstance(raw, dict) else "",
|
|
298
|
+
"channel_weights": raw.get("channel_weights", {}) if isinstance(raw, dict) else {},
|
|
299
|
+
"total_candidates": raw.get("total_candidates", 0) if isinstance(raw, dict) else 0,
|
|
300
|
+
"retrieval_time_ms": round(float(raw.get("retrieval_time_ms", 0.0)) if isinstance(raw, dict) else 0.0, 1),
|
|
305
301
|
}
|
|
306
302
|
except Exception as exc:
|
|
307
303
|
logger.exception("recall_trace failed")
|
|
@@ -20,12 +20,43 @@ import logging
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Callable
|
|
22
22
|
|
|
23
|
+
from mcp.types import ToolAnnotations
|
|
24
|
+
|
|
23
25
|
logger = logging.getLogger(__name__)
|
|
24
26
|
|
|
25
27
|
MEMORY_DIR = Path.home() / ".superlocalmemory"
|
|
26
28
|
DB_PATH = MEMORY_DIR / "memory.db"
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
def _try_daemon_post(path: str, body: dict, timeout_s: float = 60.0) -> dict | None:
|
|
32
|
+
"""POST to the daemon, return parsed dict or None if daemon unavailable.
|
|
33
|
+
|
|
34
|
+
Keeps heavy imports (CognitiveConsolidator, ForgettingScheduler,
|
|
35
|
+
ConsolidationWorker) out of the MCP process when a daemon is up.
|
|
36
|
+
Returns None on any failure so the caller can fall back to local
|
|
37
|
+
execution.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
from superlocalmemory.cli.daemon import _get_port, is_daemon_running
|
|
41
|
+
if not is_daemon_running():
|
|
42
|
+
return None
|
|
43
|
+
import json as _json
|
|
44
|
+
import urllib.request as _urq
|
|
45
|
+
port = _get_port()
|
|
46
|
+
req = _urq.Request(
|
|
47
|
+
f"http://127.0.0.1:{port}{path}",
|
|
48
|
+
data=_json.dumps(body).encode(),
|
|
49
|
+
headers={"Content-Type": "application/json"},
|
|
50
|
+
method="POST",
|
|
51
|
+
)
|
|
52
|
+
with _urq.urlopen(req, timeout=timeout_s) as resp:
|
|
53
|
+
raw = resp.read().decode() or "{}"
|
|
54
|
+
return _json.loads(raw)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.warning("daemon POST %s failed: %s", path, exc)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
29
60
|
def _emit_event(event_type: str, payload: dict | None = None,
|
|
30
61
|
source_agent: str = "mcp_client") -> None: # V3.3.12: see also mcp/shared.py
|
|
31
62
|
"""Emit an event to the EventBus (best-effort, never raises)."""
|
|
@@ -44,7 +75,7 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
44
75
|
# ------------------------------------------------------------------
|
|
45
76
|
# 1. forget — Ebbinghaus forgetting decay cycle
|
|
46
77
|
# ------------------------------------------------------------------
|
|
47
|
-
@server.tool()
|
|
78
|
+
@server.tool(annotations=ToolAnnotations(destructiveHint=True))
|
|
48
79
|
async def forget(
|
|
49
80
|
profile_id: str = "",
|
|
50
81
|
dry_run: bool = True,
|
|
@@ -188,6 +219,26 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
188
219
|
engine = get_engine()
|
|
189
220
|
pid = profile_id or engine.profile_id
|
|
190
221
|
|
|
222
|
+
# v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
|
|
223
|
+
# so the heavy CognitiveConsolidator import stays out of the
|
|
224
|
+
# MCP process. Fall back to local import only if no daemon.
|
|
225
|
+
daemon_result = _try_daemon_post(
|
|
226
|
+
"/consolidate/cognitive", {"profile_id": pid},
|
|
227
|
+
)
|
|
228
|
+
if daemon_result is not None:
|
|
229
|
+
_emit_event("ccq.consolidation_complete", {
|
|
230
|
+
"profile_id": pid,
|
|
231
|
+
"clusters_processed": daemon_result.get("clusters_processed", 0),
|
|
232
|
+
"blocks_created": daemon_result.get("blocks_created", 0),
|
|
233
|
+
})
|
|
234
|
+
return {
|
|
235
|
+
"success": True,
|
|
236
|
+
"clusters_processed": daemon_result.get("clusters_processed", 0),
|
|
237
|
+
"blocks_created": daemon_result.get("blocks_created", 0),
|
|
238
|
+
"profile_id": pid,
|
|
239
|
+
"via": "daemon",
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
from superlocalmemory.encoding.cognitive_consolidator import (
|
|
192
243
|
CognitiveConsolidator,
|
|
193
244
|
)
|
|
@@ -216,7 +267,7 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
216
267
|
# ------------------------------------------------------------------
|
|
217
268
|
# 4. get_soft_prompts — Retrieve active soft prompts
|
|
218
269
|
# ------------------------------------------------------------------
|
|
219
|
-
@server.tool()
|
|
270
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
220
271
|
async def get_soft_prompts(
|
|
221
272
|
profile_id: str = "",
|
|
222
273
|
) -> dict:
|
|
@@ -379,6 +430,18 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
379
430
|
try:
|
|
380
431
|
engine = get_engine()
|
|
381
432
|
pid = profile_id or engine.profile_id
|
|
433
|
+
|
|
434
|
+
# v3.4.26: prefer the daemon so ForgettingScheduler /
|
|
435
|
+
# ConsolidationWorker / EbbinghausCurve don't load inside
|
|
436
|
+
# the MCP process.
|
|
437
|
+
daemon_result = _try_daemon_post(
|
|
438
|
+
"/maintenance/run", {"profile_id": pid},
|
|
439
|
+
)
|
|
440
|
+
if daemon_result is not None:
|
|
441
|
+
daemon_result.setdefault("success", True)
|
|
442
|
+
daemon_result["via"] = "daemon"
|
|
443
|
+
return daemon_result
|
|
444
|
+
|
|
382
445
|
results = {}
|
|
383
446
|
|
|
384
447
|
# 1. Langevin dynamics step (lifecycle evolution)
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
"""Idempotent migration from v3.4.25 to v3.4.26 data layout.
|
|
6
|
+
|
|
7
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _daemon_running() -> bool:
|
|
19
|
+
"""Return True if an SLM daemon is holding the data dir.
|
|
20
|
+
|
|
21
|
+
Defined as a module-level indirection so tests can monkey-patch it
|
|
22
|
+
without reaching into ``cli.daemon``.
|
|
23
|
+
"""
|
|
24
|
+
from superlocalmemory.cli.daemon import is_daemon_running
|
|
25
|
+
return bool(is_daemon_running())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def migrate_if_safe(data_dir: Path) -> dict[str, object]:
|
|
29
|
+
"""Run :func:`migrate` only when it's safe to touch the data dir.
|
|
30
|
+
|
|
31
|
+
Safety guarantees:
|
|
32
|
+
|
|
33
|
+
1. A live daemon means defer — the daemon is the authoritative DB
|
|
34
|
+
holder and will apply the migration on its next start.
|
|
35
|
+
2. If the daemon probe itself fails, err on the safe side and defer.
|
|
36
|
+
3. Concurrent CLI + daemon-start + npm-postinstall invocations on
|
|
37
|
+
the same data dir serialize through a file lock, so no two
|
|
38
|
+
processes can race into ``migrate()`` at the same moment.
|
|
39
|
+
|
|
40
|
+
Returns a dict with a ``status`` in
|
|
41
|
+
``{applied, already_applied, deferred}``.
|
|
42
|
+
"""
|
|
43
|
+
data_dir = Path(data_dir)
|
|
44
|
+
|
|
45
|
+
if is_ready(data_dir):
|
|
46
|
+
return {"status": "already_applied", "data_dir": str(data_dir)}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
daemon_up = _daemon_running()
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"daemon probe failed, deferring migrate_if_safe: %s", exc,
|
|
53
|
+
)
|
|
54
|
+
daemon_up = True
|
|
55
|
+
|
|
56
|
+
if daemon_up:
|
|
57
|
+
return {
|
|
58
|
+
"status": "deferred",
|
|
59
|
+
"data_dir": str(data_dir),
|
|
60
|
+
"reason": "daemon is running — migration will apply on next daemon start",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Serialize concurrent CLI / postinstall / daemon-boot callers.
|
|
64
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
lock_path = data_dir / ".migrate-v3.4.26.lock"
|
|
66
|
+
lock_fd = None
|
|
67
|
+
try:
|
|
68
|
+
import os
|
|
69
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
70
|
+
import sys
|
|
71
|
+
if sys.platform == "win32":
|
|
72
|
+
try:
|
|
73
|
+
import msvcrt
|
|
74
|
+
msvcrt.locking(lock_fd, msvcrt.LK_LOCK, 1)
|
|
75
|
+
except (IOError, OSError):
|
|
76
|
+
# Another process is mid-migrate — they will apply it.
|
|
77
|
+
return {
|
|
78
|
+
"status": "deferred",
|
|
79
|
+
"data_dir": str(data_dir),
|
|
80
|
+
"reason": "concurrent migrate_if_safe in progress",
|
|
81
|
+
}
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
import fcntl
|
|
85
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
86
|
+
except (IOError, OSError):
|
|
87
|
+
return {
|
|
88
|
+
"status": "deferred",
|
|
89
|
+
"data_dir": str(data_dir),
|
|
90
|
+
"reason": "concurrent migrate_if_safe in progress",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Re-check the sentinel now that we own the lock — another
|
|
94
|
+
# process may have applied the migration while we waited.
|
|
95
|
+
if is_ready(data_dir):
|
|
96
|
+
return {"status": "already_applied", "data_dir": str(data_dir)}
|
|
97
|
+
|
|
98
|
+
result = migrate(data_dir)
|
|
99
|
+
result["status"] = "applied"
|
|
100
|
+
return result
|
|
101
|
+
finally:
|
|
102
|
+
if lock_fd is not None:
|
|
103
|
+
try:
|
|
104
|
+
import os as _os
|
|
105
|
+
_os.close(lock_fd)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def migrate(data_dir: Path) -> dict[str, object]:
|
|
111
|
+
"""Prepare v3.4.26 data directory. Safe to run any number of times.
|
|
112
|
+
|
|
113
|
+
memory.db is untouched; the migration only provisions the new
|
|
114
|
+
recall_queue.db and marks readiness with a sentinel file.
|
|
115
|
+
"""
|
|
116
|
+
result: dict[str, object] = {
|
|
117
|
+
"data_dir": str(data_dir),
|
|
118
|
+
"created": [],
|
|
119
|
+
"already_present": [],
|
|
120
|
+
}
|
|
121
|
+
data_dir = Path(data_dir)
|
|
122
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
from superlocalmemory.core.recall_queue import RecallQueue
|
|
125
|
+
|
|
126
|
+
queue_path = data_dir / "recall_queue.db"
|
|
127
|
+
if queue_path.exists():
|
|
128
|
+
result["already_present"].append(str(queue_path))
|
|
129
|
+
else:
|
|
130
|
+
result["created"].append(str(queue_path))
|
|
131
|
+
q = RecallQueue(db_path=queue_path)
|
|
132
|
+
q.close()
|
|
133
|
+
|
|
134
|
+
marker = data_dir / ".slm-v3.4.26-ready"
|
|
135
|
+
if marker.exists():
|
|
136
|
+
result["already_present"].append(str(marker))
|
|
137
|
+
else:
|
|
138
|
+
marker.write_text("3.4.26\n", encoding="utf-8")
|
|
139
|
+
result["created"].append(str(marker))
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_ready(data_dir: Path) -> bool:
|
|
144
|
+
return (Path(data_dir) / ".slm-v3.4.26-ready").exists()
|
|
@@ -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",
|
|
@@ -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."""
|