feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
+244
-61
@@ -13,6 +13,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
import os as _os
|
||||
import platform as _platform
|
||||
import sys as _sys
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException, Body, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -28,8 +29,9 @@ from .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
||||
from . import chat as _chat
|
||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||
from . import frontend_manager as _frontend_manager
|
||||
from .playbook_manager import PlaybookManager
|
||||
from . import playbook_manager
|
||||
from . import tools as _tools
|
||||
from .memory.curator import extract_for_conversation
|
||||
|
||||
def _render_memory_block(facts) -> str:
|
||||
"""Render memory items as grouped ## Section / - bullet markdown.
|
||||
@@ -61,8 +63,11 @@ def _render_memory_block(facts) -> str:
|
||||
# the user. (A minimal header is kept so the facts have context; drop it entirely
|
||||
# to inject the raw facts block with no framing.)
|
||||
_MEMORY_PREAMBLE = (
|
||||
"\n\n---\nWhat you know about the user — use these facts freely and naturally to "
|
||||
"inform and personalize your replies:\n\n"
|
||||
"\n\n---\nThe user is the person you are talking to right now — every user turn in "
|
||||
"this conversation is his. The facts below are about him, written in the third "
|
||||
"person only because that is how they are stored; address him as \"you\", never "
|
||||
"discuss him as an absent third party. Use them freely and naturally to inform "
|
||||
"and personalize your replies:\n\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -179,7 +184,6 @@ from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
from .search import needs_web_search, web_search
|
||||
|
||||
MEMORY_SERVICE = "http://localhost:8001"
|
||||
|
||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||
|
||||
@@ -205,6 +209,13 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- Modules ---
|
||||
# Installed feature modules (mail, and whatever comes next) live under the
|
||||
# repo-root modules/ package and mount their own APIRouter here.
|
||||
from modules.registry import ROUTERS as _MODULE_ROUTERS
|
||||
for _module_router in _MODULE_ROUTERS:
|
||||
app.include_router(_module_router)
|
||||
|
||||
|
||||
# --- Request-size cap ---
|
||||
# Reject oversized bodies before they are buffered/decoded (a base64 upload or a
|
||||
@@ -263,7 +274,6 @@ _CHAT_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_CHATS, "chat")
|
||||
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
|
||||
|
||||
# --- GLOBALS ---
|
||||
playbooks = PlaybookManager()
|
||||
ollama = None
|
||||
|
||||
|
||||
@@ -284,6 +294,17 @@ async def startup_event():
|
||||
print("[Synapse] Ready. Ollama not auto-started (manual control).")
|
||||
|
||||
|
||||
async def _ollama_status_async() -> Optional[str]:
|
||||
"""to_thread, not a direct call: get_status() does blocking IO (a sync httpx
|
||||
request and, once, a subprocess). Awaiting it inline stalls the whole event
|
||||
loop for its duration - and both callers below are polled continuously by
|
||||
the UI, so an inline call froze the entire server in lockstep with its own
|
||||
health check."""
|
||||
if ollama is None or not hasattr(ollama, "get_status"):
|
||||
return None
|
||||
return await _asyncio.to_thread(ollama.get_status)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Status
|
||||
# -------------------------
|
||||
@@ -291,17 +312,150 @@ async def startup_event():
|
||||
@app.get("/status")
|
||||
async def root():
|
||||
try:
|
||||
# to_thread, not a direct call: get_status() does blocking IO (an httpx
|
||||
# request and, once, a subprocess). Awaiting it inline stalled the whole
|
||||
# event loop on every poll - and the UI polls /status continuously, so
|
||||
# the server froze in lockstep with its own health check.
|
||||
status = (await _asyncio.to_thread(ollama.get_status)
|
||||
if (ollama is not None and hasattr(ollama, "get_status")) else None)
|
||||
status = await _ollama_status_async()
|
||||
except Exception:
|
||||
status = None
|
||||
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Update check
|
||||
# -------------------------
|
||||
# The update mechanism is git: `git pull` + rebuild, i.e. `python bin/sync.py
|
||||
# restore` (./install.sh on Linux). So "is a newer build out there" is just
|
||||
# "how many commits is origin/main ahead of HEAD" — no version server needed.
|
||||
def _git(*args: str, timeout: int = 30) -> str:
|
||||
import subprocess
|
||||
out = subprocess.run(
|
||||
["git", *args], cwd=str(settings.project_root),
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
if out.returncode:
|
||||
raise RuntimeError((out.stderr or out.stdout).strip() or "git failed")
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def _check_update() -> Dict[str, Any]:
|
||||
_git("fetch", "--quiet", "origin", timeout=60)
|
||||
behind = int(_git("rev-list", "--count", "HEAD..origin/main") or 0)
|
||||
remote_version = VERSION
|
||||
if behind:
|
||||
with _contextlib.suppress(Exception):
|
||||
remote_version = _git("show", "origin/main:VERSION").strip() or VERSION
|
||||
return {
|
||||
"version": VERSION,
|
||||
"remote_version": remote_version,
|
||||
"behind": behind,
|
||||
"latest": _git("log", "-1", "--format=%h %s", "origin/main") if behind else "",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/update/check")
|
||||
async def update_check():
|
||||
"""Compare this checkout against origin/main. Network call + subprocess, so
|
||||
it runs off the event loop like the Ollama health check does."""
|
||||
try:
|
||||
return await _asyncio.to_thread(_check_update)
|
||||
except Exception as e:
|
||||
return {"version": VERSION, "behind": 0, "error": str(e)[:300]}
|
||||
|
||||
|
||||
_update_running = False
|
||||
|
||||
|
||||
@app.post("/update/apply")
|
||||
async def update_apply():
|
||||
"""Run `ncp upgrade` (git pull, rebuild, restart) fully detached.
|
||||
|
||||
It stops this very process, so it cannot be a child of it: a child would be
|
||||
killed halfway through its own upgrade. Output goes to runtime/logs/update.log
|
||||
because the UI cannot read /logs while the backend is down - that file is the
|
||||
only record if the restart fails.
|
||||
"""
|
||||
global _update_running
|
||||
import subprocess
|
||||
if _update_running:
|
||||
return {"started": False, "error": "An update is already running."}
|
||||
log_path = settings.logs_dir / "update.log"
|
||||
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
||||
if _os.name == "nt" else {"start_new_session": True})
|
||||
try:
|
||||
log = open(log_path, "wb")
|
||||
subprocess.Popen(
|
||||
[_sys.executable, str(settings.project_root / "management" / "ncp.py"), "upgrade"],
|
||||
cwd=str(settings.project_root), stdout=log, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL, **kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"started": False, "error": str(e)[:300]}
|
||||
_update_running = True
|
||||
return {"started": True, "log": str(log_path)}
|
||||
|
||||
|
||||
# --- Deferred memory extraction ------------------------------------------
|
||||
# The curator reads a conversation when it has been quiet for a while, rather
|
||||
# than after every exchange. Each new message reschedules, so "idle" means the
|
||||
# user actually stopped — which is also the only reliable signal that a
|
||||
# half-said fact is now complete.
|
||||
_pending_extractions: Dict[str, _asyncio.Task] = {}
|
||||
|
||||
|
||||
def _extract_idle_seconds() -> float:
|
||||
try:
|
||||
return max(5.0, float(store.get_settings().get("memory_extract_idle", 120)))
|
||||
except (TypeError, ValueError):
|
||||
return 120.0
|
||||
|
||||
|
||||
async def _extract_after_idle(conversation_id: str, project_id: str, delay: float) -> None:
|
||||
try:
|
||||
await _asyncio.sleep(delay)
|
||||
saved = await extract_for_conversation(conversation_id, project_id)
|
||||
if saved:
|
||||
_synapse_trace(
|
||||
f"\n◆ CURATOR (idle sweep): {len(saved)} fact(s) — "
|
||||
+ "; ".join(f"[{i['section']}] {i['text']}" for i in saved) + "\n"
|
||||
)
|
||||
except _asyncio.CancelledError:
|
||||
raise # rescheduled by a newer message; the watermark still has the work
|
||||
except Exception as e:
|
||||
_synapse_trace(f"\n⚠ idle extraction failed: {e}\n")
|
||||
finally:
|
||||
_pending_extractions.pop(conversation_id, None)
|
||||
|
||||
|
||||
def _schedule_extraction(conversation_id: str, project_id: str) -> None:
|
||||
"""(Re)arm the idle sweep for one conversation."""
|
||||
previous = _pending_extractions.pop(conversation_id, None)
|
||||
if previous:
|
||||
previous.cancel()
|
||||
_pending_extractions[conversation_id] = _asyncio.create_task(
|
||||
_extract_after_idle(conversation_id, project_id, _extract_idle_seconds())
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _resume_dropped_extractions() -> None:
|
||||
"""Pick up conversations whose sweep was lost to a restart. The watermark
|
||||
makes this idempotent, so a conversation already read is skipped without
|
||||
ever reaching the model."""
|
||||
async def _bg():
|
||||
await _asyncio.sleep(5) # let the app finish coming up first
|
||||
try:
|
||||
stale = store.conversations_awaiting_extraction(_extract_idle_seconds())
|
||||
except Exception:
|
||||
return
|
||||
for cid in stale:
|
||||
try:
|
||||
# One at a time: Ollama serialises anyway, and a burst here would
|
||||
# sit in front of the user's first message of the session.
|
||||
await extract_for_conversation(cid, store.conversation_project(cid) or "")
|
||||
except Exception:
|
||||
continue
|
||||
_asyncio.create_task(_bg())
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Chat (streaming)
|
||||
# -------------------------
|
||||
@@ -316,7 +470,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
message = payload.get("message", "")
|
||||
app_settings = store.get_settings()
|
||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||
_active_pb = playbooks.get_main_playbook()
|
||||
_active_pb = playbook_manager.get_main_playbook()
|
||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
||||
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||
context = payload.get("context", {})
|
||||
@@ -331,14 +485,28 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||
|
||||
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||
# Resolve the project scope: an existing conversation keeps its bound project;
|
||||
# a brand-new one inherits the current workspace (active_project setting).
|
||||
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
||||
_conv_proj = store.conversation_project(conversation_id)
|
||||
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
||||
|
||||
# Fetch memory facts once — used for both playbook routing and system prompt injection
|
||||
memory_facts = store.all()
|
||||
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
||||
system_prompt = playbook_manager.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||
|
||||
# Fetch memory facts once — used for both playbook routing and system prompt
|
||||
# injection. Global facts ("") always apply; the rest only inside their project.
|
||||
memory_facts = [m for m in store.all() if m.project_id in ("", rag_scope)]
|
||||
|
||||
# Per-project instructions sit right under the playbook: they say how the
|
||||
# assistant should behave for this project specifically.
|
||||
project_instructions = store.project_instructions(rag_scope)
|
||||
if project_instructions:
|
||||
separator = "\n\n---\nProject instructions (follow these for this project):\n\n"
|
||||
system_prompt = (system_prompt + separator + project_instructions) if system_prompt else project_instructions
|
||||
|
||||
# Append the best-matching reference playbook(s) to the system prompt
|
||||
context_pbs = _route_playbooks(rendered_message, playbooks.get_context_playbooks())
|
||||
context_pbs = _route_playbooks(rendered_message, playbook_manager.get_context_playbooks())
|
||||
if context_pbs:
|
||||
refs = "\n\n".join(
|
||||
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
||||
@@ -369,11 +537,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
||||
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
||||
|
||||
# Resolve the RAG scope: an existing conversation keeps its bound project;
|
||||
# a brand-new one inherits the current workspace (active_project setting).
|
||||
_conv_proj = store.conversation_project(conversation_id)
|
||||
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
||||
|
||||
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
||||
doc_hits = await store.search_documents(
|
||||
message, get_ollama_manager().embed,
|
||||
@@ -414,7 +577,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
else:
|
||||
_synapse_trace(f" INTENT: chat\n")
|
||||
|
||||
_main_pb = playbooks.get_main_playbook()
|
||||
_main_pb = playbook_manager.get_main_playbook()
|
||||
if _main_pb:
|
||||
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
|
||||
if _main_pb.goal:
|
||||
@@ -442,6 +605,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if past_context:
|
||||
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
|
||||
|
||||
if rag_scope:
|
||||
_synapse_trace(f" PROJECT : {rag_scope}{' [+instructions]' if project_instructions else ''}\n")
|
||||
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
|
||||
_synapse_trace(f"{'─' * 55}\n")
|
||||
# ── end MindTrace pre-flight ──────────────────────────────────────
|
||||
@@ -453,19 +618,26 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if images:
|
||||
metadata["images"] = images
|
||||
|
||||
# Tool-using playbook: advertise the active playbook's allowlisted tools.
|
||||
# Action tools follow action_tool_policy: off (withheld) / ask (per-call
|
||||
# approval, handled in the tool loop) / allow (run freely).
|
||||
# Tool-using playbook: advertise the allowlisted tools of the active
|
||||
# playbook AND of the reference playbooks _route_playbooks picked for
|
||||
# this message — a routed playbook's instructions are already in the
|
||||
# prompt, so its abilities have to come with them or the model narrates
|
||||
# tools it was never given. Action tools follow action_tool_policy:
|
||||
# off (withheld) / ask (per-call approval, in the tool loop) / allow.
|
||||
_policy = app_settings.get("action_tool_policy", "off")
|
||||
if _main_pb and getattr(_main_pb, "tools", None):
|
||||
_pb_tools = list(dict.fromkeys(
|
||||
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
||||
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
|
||||
))
|
||||
if _pb_tools:
|
||||
allow_actions = _policy != "off"
|
||||
schemas = _tools.schemas_for(_main_pb.tools, allow_actions)
|
||||
schemas = _tools.schemas_for(_pb_tools, allow_actions)
|
||||
if schemas:
|
||||
metadata["tools"] = schemas
|
||||
metadata["action_tool_policy"] = _policy
|
||||
metadata["conversation_id"] = conversation_id
|
||||
_granted = [t for t in _main_pb.tools if not _tools.is_action(t) or allow_actions]
|
||||
_withheld = [t for t in _main_pb.tools if _tools.is_action(t) and not allow_actions]
|
||||
_granted = [t for t in _pb_tools if not _tools.is_action(t) or allow_actions]
|
||||
_withheld = [t for t in _pb_tools if _tools.is_action(t) and not allow_actions]
|
||||
_synapse_trace(f" TOOLS : {', '.join(_granted)} [actions: {_policy}]\n")
|
||||
if _withheld:
|
||||
_synapse_trace(f" WITHHELD: {', '.join(_withheld)} (action tools off)\n")
|
||||
@@ -546,24 +718,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ask the memory service curator to evaluate this exchange
|
||||
# Hand the conversation to the curator once it goes quiet. Not now:
|
||||
# the curator is the chat model, and Ollama runs one request at a
|
||||
# time (OLLAMA_NUM_PARALLEL=1), so extracting here would put the
|
||||
# next message in a queue behind it.
|
||||
if response_chunks:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=310.0) as _mc:
|
||||
r = await _mc.post(
|
||||
f"{MEMORY_SERVICE}/memories/extract",
|
||||
json={
|
||||
"user_message": rendered_message,
|
||||
"assistant_response": "".join(response_chunks),
|
||||
},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
for it in data.get("items", []):
|
||||
mem_result = {"section": it["section"], "text": it["text"]}
|
||||
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
|
||||
except Exception as e:
|
||||
_synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
|
||||
_schedule_extraction(conversation_id, rag_scope)
|
||||
|
||||
# Hold the concurrency slot for the stream's lifetime, then release it
|
||||
# exactly once when the generator is exhausted or closed (client
|
||||
@@ -671,8 +831,13 @@ async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
|
||||
# Memory
|
||||
# -------------------------
|
||||
@app.get("/memory")
|
||||
async def get_memory():
|
||||
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags} for m in store.all()]}
|
||||
async def get_memory(project: Optional[str] = None):
|
||||
"""All facts, or — with ?project=<id> — just that scope's ('' = global)."""
|
||||
items = store.all()
|
||||
if project is not None:
|
||||
items = [m for m in items if m.project_id == project]
|
||||
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags,
|
||||
"project_id": m.project_id} for m in items]}
|
||||
|
||||
|
||||
@app.post("/memory")
|
||||
@@ -687,9 +852,11 @@ async def add_memory(payload: Dict[str, Any] = Body(...)):
|
||||
section=(payload.get("section") or "General").strip(),
|
||||
text=text,
|
||||
tags=payload.get("tags", []),
|
||||
project_id=payload.get("project_id") or "",
|
||||
)
|
||||
store.add(item)
|
||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags,
|
||||
"project_id": item.project_id}
|
||||
|
||||
|
||||
@app.patch("/memory/{item_id}")
|
||||
@@ -703,9 +870,11 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
||||
section=(payload.get("section") or existing.section or "General").strip(),
|
||||
text=(payload.get("text") or existing.text).rstrip(),
|
||||
tags=payload.get("tags", existing.tags),
|
||||
project_id=payload.get("project_id", existing.project_id),
|
||||
)
|
||||
store.update(updated)
|
||||
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
||||
return {"id": updated.id, "section": updated.section, "text": updated.text,
|
||||
"tags": updated.tags, "project_id": updated.project_id}
|
||||
|
||||
|
||||
@app.delete("/memory/{item_id}")
|
||||
@@ -818,10 +987,7 @@ async def delete_model(name: str):
|
||||
@app.get("/ollama/status")
|
||||
async def ollama_status_endpoint():
|
||||
try:
|
||||
if ollama is not None and hasattr(ollama, "get_status"):
|
||||
status = ollama.get_status()
|
||||
else:
|
||||
status = None
|
||||
status = await _ollama_status_async()
|
||||
return {"status": status}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1148,6 +1314,8 @@ async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
||||
return {"text": text}
|
||||
|
||||
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Projects / workspaces
|
||||
# -------------------------
|
||||
@@ -1169,6 +1337,15 @@ async def create_project(payload: Dict[str, Any] = Body(...)):
|
||||
return store.create_project(name)
|
||||
|
||||
|
||||
@app.patch("/projects/{project_id}")
|
||||
async def update_project(project_id: str, payload: Dict[str, Any] = Body(...)):
|
||||
"""Set the project's instructions — layered into the system prompt of every
|
||||
chat bound to this project."""
|
||||
if not store.set_project_instructions(project_id, payload.get("instructions") or ""):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return {"id": project_id, "instructions": payload.get("instructions") or ""}
|
||||
|
||||
|
||||
@app.delete("/projects/{project_id}")
|
||||
async def delete_project(project_id: str):
|
||||
if not store.delete_project(project_id):
|
||||
@@ -1281,9 +1458,11 @@ async def delete_document(doc_id: str):
|
||||
# Conversations
|
||||
# -------------------------
|
||||
@app.get("/conversations")
|
||||
async def get_conversations(q: Optional[str] = None):
|
||||
async def get_conversations(q: Optional[str] = None, project: Optional[str] = None):
|
||||
try:
|
||||
conversations = store.all_conversations()
|
||||
if project is not None:
|
||||
conversations = [c for c in conversations if c.project_id == project]
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
conversations = [
|
||||
@@ -1299,6 +1478,7 @@ async def get_conversations(q: Optional[str] = None):
|
||||
"updated_at": c.updated_at,
|
||||
"preview": c.preview,
|
||||
"title": c.title,
|
||||
"project_id": c.project_id,
|
||||
}
|
||||
for c in conversations
|
||||
]
|
||||
@@ -1376,17 +1556,20 @@ async def get_conversation(conversation_id: str):
|
||||
|
||||
@app.patch("/conversations/{conversation_id}")
|
||||
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
|
||||
"""Manually set a conversation's title."""
|
||||
"""Set a conversation's title and/or move it into a project."""
|
||||
try:
|
||||
conv = store.get_conversation(conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
if "project_id" in payload:
|
||||
store.set_conversation_project(conversation_id, payload.get("project_id") or "")
|
||||
title = (payload.get("title") or "").strip()
|
||||
if not title:
|
||||
if title:
|
||||
title = " ".join(title.split())[:120]
|
||||
store.set_conversation_title(conversation_id, title)
|
||||
elif "project_id" not in payload:
|
||||
raise HTTPException(status_code=400, detail="Missing 'title'")
|
||||
title = " ".join(title.split())[:120]
|
||||
store.set_conversation_title(conversation_id, title)
|
||||
return {"id": conversation_id, "title": title}
|
||||
return {"id": conversation_id, "title": title or conv.title}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Curated memory extraction — read a finished conversation, save what's new.
|
||||
|
||||
This is the whole memory-writing path: pick up the messages the curator has not
|
||||
read yet, ask the model which permanent facts they contain, merge each result
|
||||
against what is already stored, and move the conversation's watermark.
|
||||
|
||||
It runs in-process. Extraction used to sit behind an HTTP call to a separate
|
||||
service on :8001 holding a second, smaller model, because that model could not
|
||||
share the GPU with the chat model. The curator is the chat model now — already
|
||||
resident, already warm — so the extra process bought nothing but a hop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
|
||||
from .store import store, MemoryItem
|
||||
from .extractor import extract_memory
|
||||
from ..ollama_manager import get_ollama_manager
|
||||
|
||||
_EXTRACT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _cosine(a: list, b: list) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
async def extract_for_conversation(conversation_id: str, project_id: str = "") -> list[dict]:
|
||||
"""Extract and save facts from a conversation's unread messages.
|
||||
|
||||
Returns the saved/updated items. Safe to call more than once: the watermark
|
||||
means a second run with no new messages does nothing and never reaches the
|
||||
model. Never raises — memory extraction must not take the caller down.
|
||||
"""
|
||||
messages, last_id = store.pending_extraction(conversation_id)
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
# Only global facts and this project's are in scope: another project's facts
|
||||
# must not be shown to the curator as "already known", or as a merge target.
|
||||
existing = [m for m in store.all() if m.project_id in ("", project_id)]
|
||||
existing_sections = list({i.section for i in existing})
|
||||
existing_texts = [i.text for i in existing]
|
||||
|
||||
settings = store.get_settings()
|
||||
mgr = get_ollama_manager()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", -1), model)
|
||||
try:
|
||||
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||
except (TypeError, ValueError):
|
||||
merge_threshold = 0.88
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
extract_memory(
|
||||
messages, existing_sections, existing_texts, mgr,
|
||||
model=model, num_gpu=num_gpu,
|
||||
),
|
||||
timeout=_EXTRACT_TIMEOUT,
|
||||
)
|
||||
except Exception:
|
||||
# Leave the watermark alone so the next sweep retries these messages.
|
||||
return []
|
||||
|
||||
saved = []
|
||||
try:
|
||||
# Embed existing facts once so each new fact can be matched against them.
|
||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||
# rather than appending a copy. Best effort: if embeddings are down we
|
||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||
existing_embeds: dict = {}
|
||||
if results and 0 < merge_threshold < 1:
|
||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||
|
||||
for result in results:
|
||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||
match_id, best = None, 0.0
|
||||
if new_vec:
|
||||
for eid, ev in existing_embeds.items():
|
||||
sim = _cosine(new_vec, ev)
|
||||
if sim > best:
|
||||
best, match_id = sim, eid
|
||||
if best < merge_threshold:
|
||||
match_id = None
|
||||
|
||||
target = store.get(match_id) if match_id else None
|
||||
if target:
|
||||
# Near-duplicate of an existing fact — overwrite with the newer
|
||||
# statement, keeping the original id/section/position.
|
||||
updated = MemoryItem(id=target.id, section=target.section,
|
||||
text=result["text"], tags=target.tags,
|
||||
project_id=target.project_id)
|
||||
store.update(updated)
|
||||
if new_vec:
|
||||
existing_embeds[updated.id] = new_vec
|
||||
saved.append({"id": updated.id, "section": updated.section,
|
||||
"text": updated.text, "updated": True})
|
||||
else:
|
||||
item = MemoryItem(id=str(uuid.uuid4()), section=result["section"],
|
||||
text=result["text"], project_id=project_id)
|
||||
store.add(item)
|
||||
if new_vec:
|
||||
existing_embeds[item.id] = new_vec
|
||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Watermark even when nothing was saved — the model has read these messages
|
||||
# and re-reading them would just spend another call to reach the same "no".
|
||||
store.set_extracted_through(conversation_id, last_id)
|
||||
return saved
|
||||
+55
-20
@@ -1,5 +1,12 @@
|
||||
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
|
||||
decide if it contains a new permanent personal fact worth saving."""
|
||||
"""Memory extraction — asks the curator model to read a finished conversation
|
||||
and decide which new permanent personal facts it contains.
|
||||
|
||||
Reads the whole transcript, not a single exchange. A fact is rarely complete in
|
||||
the turn that introduces it ("I have a Honda" at turn 3 becomes a 2006 Accord
|
||||
with 312k miles by turn 7), and the store is append-only for the curator, so
|
||||
extracting per-exchange could only ever accumulate fragments of the same fact.
|
||||
Waiting until the conversation is idle is also the only reliable signal that a
|
||||
statement is finished rather than half-said."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,13 +24,19 @@ _TR = "┅" * 55
|
||||
_PROMPT = """\
|
||||
You are a memory curator for a personal AI assistant named Nexus.
|
||||
|
||||
Extract EVERY new, permanent personal fact the USER stated in this exchange.
|
||||
There may be SEVERAL facts in one message — output one JSON object for each.
|
||||
Extract EVERY new, permanent personal fact the USER stated in this conversation.
|
||||
There may be SEVERAL facts across the conversation — output one JSON object for
|
||||
each.
|
||||
|
||||
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
|
||||
memory below are context to help you understand the USER line — never extract
|
||||
ONLY the USER lines are a source of facts. The ASSISTANT lines and the existing
|
||||
memory below are context to help you understand the USER lines — never extract
|
||||
anything from them. If the assistant said it and the user did not, it is NOT a
|
||||
fact. Copy what the user actually said; do not infer, embellish, or add a
|
||||
fact.
|
||||
|
||||
You are reading the WHOLE conversation, so record the FINAL, most complete form
|
||||
of each fact. If the user gives a detail early and refines it later, output one
|
||||
object with the refined version — never one per stage. If the user corrects or
|
||||
retracts something, keep only what they ended up saying. Copy what the user actually said; do not infer, embellish, or add a
|
||||
judgement the user did not make (never call something their "favorite",
|
||||
"main", or "best" unless the user used that word).
|
||||
|
||||
@@ -56,8 +69,7 @@ Only invent a new section if none fit, and make it a SHORT single word
|
||||
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
||||
|
||||
---
|
||||
USER: {user_message}
|
||||
ASSISTANT: {assistant_response}
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
||||
@@ -95,11 +107,11 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
prompt is worded (verified against mistral:7b):
|
||||
|
||||
1. Absence claims. It reads the existing-memory block and writes things like
|
||||
"Jon does not have any pets" - which contradicted four cats already on
|
||||
"the user does not have any pets" - which contradicted four cats already on
|
||||
file. Silence is not a fact.
|
||||
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
|
||||
attributes them to the user: a reply that echoed a stale memory row
|
||||
produced "Jon's main development machine is a MacBook Pro" off the user
|
||||
produced "the user's main development machine is a MacBook Pro" off the user
|
||||
message "What am I developing on?".
|
||||
|
||||
The grounding test only fires when a fact carries distinctive tokens and
|
||||
@@ -114,20 +126,44 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def render_transcript(messages: list[dict]) -> str:
|
||||
"""The conversation as the curator sees it. Assistant turns are truncated
|
||||
hard: they are context for reading the user's lines, never a fact source,
|
||||
and a long reply would otherwise crowd out the lines that matter."""
|
||||
lines = []
|
||||
for m in messages:
|
||||
role = (m.get("role") or "").upper()
|
||||
if role not in ("USER", "ASSISTANT"):
|
||||
continue
|
||||
text = (m.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
lines.append(f"{role}: {text[:3000] if role == 'USER' else text[:600]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def extract_memory(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
messages: list[dict],
|
||||
existing_sections: list[str],
|
||||
existing_texts: list[str],
|
||||
ollama_manager,
|
||||
model: str = DEFAULT_MEMORY_MODEL,
|
||||
num_gpu: int | None = 0,
|
||||
) -> list[dict]:
|
||||
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
||||
"""Extract saveable memory facts from a whole conversation.
|
||||
|
||||
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
||||
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
||||
`messages` is the transcript as [{role, content}]. Returns a list of
|
||||
{"section": ..., "text": ...} — possibly empty. One conversation can hold
|
||||
several facts, and the model emits one JSON object per fact.
|
||||
"""
|
||||
transcript = render_transcript(messages)
|
||||
if not transcript:
|
||||
return []
|
||||
# Grounding is checked against everything the user actually typed, so a fact
|
||||
# assembled from details spread across several of their turns still passes.
|
||||
user_text = "\n".join(
|
||||
(m.get("content") or "") for m in messages if (m.get("role") or "") == "user"
|
||||
)
|
||||
if existing_texts:
|
||||
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
||||
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
||||
@@ -148,8 +184,7 @@ async def extract_memory(
|
||||
prompt = _PROMPT.format(
|
||||
existing_texts=texts_block,
|
||||
existing_sections=sections_line,
|
||||
user_message=user_message[:3000],
|
||||
assistant_response=assistant_response[:800],
|
||||
transcript=transcript,
|
||||
)
|
||||
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
||||
# the same console as the frontline model, not just Python warnings on failure.
|
||||
@@ -194,7 +229,7 @@ async def extract_memory(
|
||||
def _keep(o):
|
||||
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
||||
fact = str(o["text"]).strip()
|
||||
reason = _reject_reason(fact, user_message)
|
||||
reason = _reject_reason(fact, user_text)
|
||||
if reason:
|
||||
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
||||
return
|
||||
@@ -217,7 +252,7 @@ async def extract_memory(
|
||||
else:
|
||||
_keep(obj)
|
||||
if not results:
|
||||
_log.warning("memory: nothing saved. mistral said: %.300r", text)
|
||||
_log.warning("memory: nothing saved. curator said: %.300r", text)
|
||||
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
||||
else:
|
||||
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Dedicated memory curator service — run alongside Synapse on port 8001.
|
||||
|
||||
Endpoints:
|
||||
GET / health check
|
||||
GET /memories list all memory items (optional ?section= filter)
|
||||
POST /memories direct write — no LLM, saves immediately
|
||||
PATCH /memories/{id} update a memory item
|
||||
DELETE /memories/{id} delete a memory item
|
||||
POST /memories/extract LLM-curated: evaluate a conversation exchange and
|
||||
optionally save a new permanent fact
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .store import store, MemoryItem
|
||||
from .extractor import extract_memory
|
||||
from ..ollama_manager import get_ollama_manager
|
||||
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
|
||||
|
||||
app = FastAPI(title="Nexus Memory Service", version="1.0")
|
||||
|
||||
# Same unauthenticated-local-only posture as the main backend: reject foreign
|
||||
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
|
||||
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
|
||||
if "*" not in ALLOWED_HOSTS:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _warm_curator():
|
||||
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
|
||||
extraction isn't a cold load that blows the timeout. Runs in the background
|
||||
so it never delays startup. keep_alive then holds it warm between messages."""
|
||||
async def _bg():
|
||||
try:
|
||||
mgr = get_ollama_manager()
|
||||
# Ollama is started by the Synapse backend (a separate process), so
|
||||
# at our startup it usually isn't reachable yet. Wait for it before
|
||||
# warming instead of failing with "All connection attempts failed" —
|
||||
# which leaves the curator cold and makes the first extraction slow.
|
||||
for _ in range(60): # up to ~2 min
|
||||
if await asyncio.to_thread(mgr.is_running):
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
return
|
||||
settings = store.get_settings()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||
await mgr.warm(model, num_gpu=num_gpu)
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(_bg())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def health():
|
||||
return {"status": "ok", "count": len(store.all())}
|
||||
|
||||
|
||||
@app.get("/memories")
|
||||
async def list_memories(section: Optional[str] = None):
|
||||
items = store.all()
|
||||
if section:
|
||||
items = [i for i in items if i.section.lower() == section.lower()]
|
||||
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
|
||||
|
||||
|
||||
@app.post("/memories")
|
||||
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
||||
text = (payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Missing 'text'")
|
||||
item = MemoryItem(
|
||||
id=str(uuid.uuid4()),
|
||||
section=(payload.get("section") or "General").strip(),
|
||||
text=text,
|
||||
tags=payload.get("tags", []),
|
||||
)
|
||||
store.add(item)
|
||||
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
||||
|
||||
|
||||
@app.patch("/memories/{item_id}")
|
||||
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
||||
existing = store.get(item_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
updated = MemoryItem(
|
||||
id=item_id,
|
||||
section=(payload.get("section") or existing.section).strip(),
|
||||
text=(payload.get("text") or existing.text).strip(),
|
||||
tags=payload.get("tags", existing.tags),
|
||||
)
|
||||
store.update(updated)
|
||||
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
||||
|
||||
|
||||
@app.delete("/memories/{item_id}")
|
||||
async def delete_memory(item_id: str):
|
||||
if not store.get(item_id):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
store.delete(item_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _cosine(a: list, b: list) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
class ExtractRequest(BaseModel):
|
||||
user_message: str
|
||||
assistant_response: str
|
||||
|
||||
|
||||
@app.post("/memories/extract")
|
||||
async def extract_and_save(req: ExtractRequest):
|
||||
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
|
||||
existing memory items and save only new, permanent personal facts."""
|
||||
existing = store.all()
|
||||
existing_sections = list({i.section for i in existing})
|
||||
existing_texts = [i.text for i in existing]
|
||||
|
||||
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
|
||||
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
|
||||
settings = store.get_settings()
|
||||
mgr = get_ollama_manager()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||
try:
|
||||
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||
except (TypeError, ValueError):
|
||||
merge_threshold = 0.88
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
extract_memory(
|
||||
req.user_message,
|
||||
req.assistant_response,
|
||||
existing_sections,
|
||||
existing_texts,
|
||||
mgr,
|
||||
model=model,
|
||||
num_gpu=num_gpu,
|
||||
),
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
# Embed existing facts once so each new fact can be matched against them.
|
||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||
# rather than appending a copy. Best effort: if embeddings are down we
|
||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||
existing_embeds: dict = {}
|
||||
if results and 0 < merge_threshold < 1:
|
||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||
|
||||
saved = []
|
||||
for result in results:
|
||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||
match_id, best = None, 0.0
|
||||
if new_vec:
|
||||
for eid, ev in existing_embeds.items():
|
||||
sim = _cosine(new_vec, ev)
|
||||
if sim > best:
|
||||
best, match_id = sim, eid
|
||||
if best < merge_threshold:
|
||||
match_id = None
|
||||
|
||||
target = store.get(match_id) if match_id else None
|
||||
if target:
|
||||
# Near-duplicate of an existing fact — overwrite with the newer
|
||||
# statement, keeping the original id/section/position.
|
||||
updated = MemoryItem(id=target.id, section=target.section,
|
||||
text=result["text"], tags=target.tags)
|
||||
store.update(updated)
|
||||
if new_vec:
|
||||
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
||||
saved.append({"id": updated.id, "section": updated.section,
|
||||
"text": updated.text, "updated": True})
|
||||
else:
|
||||
item = MemoryItem(id=str(uuid.uuid4()),
|
||||
section=result["section"], text=result["text"])
|
||||
store.add(item)
|
||||
if new_vec:
|
||||
existing_embeds[item.id] = new_vec
|
||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||
if saved:
|
||||
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
||||
return {"saved": True, "items": saved, **first}
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return {"saved": False}
|
||||
+160
-14
@@ -30,6 +30,7 @@ class MemoryItem(BaseModel):
|
||||
text: str
|
||||
tags: List[str] = []
|
||||
position: int = 0
|
||||
project_id: str = "" # "" = global: injected into every chat
|
||||
|
||||
class MessageItem(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
@@ -44,6 +45,7 @@ class ConversationItem(BaseModel):
|
||||
created_at: float
|
||||
updated_at: float
|
||||
title: Optional[str] = None
|
||||
project_id: str = ""
|
||||
|
||||
@property
|
||||
def preview(self) -> str:
|
||||
@@ -127,6 +129,12 @@ class PersistentMemoryStore:
|
||||
if cur.fetchone()[0] == 0:
|
||||
cur.execute("UPDATE memory SET position = rowid")
|
||||
|
||||
# Migrate: scope a fact to a project ("" = global, applies everywhere).
|
||||
try:
|
||||
cur.execute("ALTER TABLE memory ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -145,6 +153,24 @@ class PersistentMemoryStore:
|
||||
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
# Migrate: high-water mark for memory extraction — the id of the last
|
||||
# message the curator has already read. Extraction runs once the
|
||||
# conversation goes idle rather than after every exchange, so this is
|
||||
# what makes it idempotent and restart-safe: a backend that dies with a
|
||||
# pending sweep resumes from here instead of re-reading the whole
|
||||
# transcript and re-saving facts it already saved.
|
||||
try:
|
||||
cur.execute("ALTER TABLE conversations ADD COLUMN extracted_through INTEGER NOT NULL DEFAULT 0")
|
||||
# Only reached the first time the column is added. Everything already
|
||||
# in the database was extracted per-exchange under the old design, so
|
||||
# watermark it as read — without this the first idle sweep would
|
||||
# re-read every historical conversation and re-save its facts.
|
||||
cur.execute(
|
||||
"UPDATE conversations SET extracted_through = "
|
||||
"COALESCE((SELECT MAX(id) FROM messages WHERE conversation_id = conversations.id), 0)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -204,6 +230,11 @@ class PersistentMemoryStore:
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
# projects.instructions — per-project system prompt ("" = none).
|
||||
try:
|
||||
cur.execute("ALTER TABLE projects ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
# documents.project_id — "" (or missing) means unscoped / All.
|
||||
try:
|
||||
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
@@ -229,7 +260,7 @@ class PersistentMemoryStore:
|
||||
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
@@ -245,6 +276,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
)
|
||||
|
||||
return cache
|
||||
@@ -271,8 +303,10 @@ class PersistentMemoryStore:
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
|
||||
"INSERT OR REPLACE INTO memory (id, section, text, tags, position, project_id)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(item.id, item.section or "General", item.text, json.dumps(item.tags),
|
||||
item.position, item.project_id or "")
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -318,12 +352,12 @@ class PersistentMemoryStore:
|
||||
return item
|
||||
|
||||
def all(self) -> List[MemoryItem]:
|
||||
# Always read from DB — the memory service and backend run in separate processes
|
||||
# with separate caches, so the cache can be stale for facts extracted by the
|
||||
# memory service after this process started.
|
||||
# Always read from DB, never the cache: the CLI, the control panel and
|
||||
# the backend are separate processes with separate caches, so a fact
|
||||
# written by one is invisible to another's cache.
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = []
|
||||
@@ -338,6 +372,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
))
|
||||
return items
|
||||
|
||||
@@ -405,6 +440,18 @@ class PersistentMemoryStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_conversation_project(self, conversation_id: str, project_id: str):
|
||||
"""Move a conversation into a project ('' = unscoped)."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE conversations SET project_id = ? WHERE id = ?",
|
||||
(project_id or "", conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
|
||||
now = time.time()
|
||||
conn = self._connect()
|
||||
@@ -452,7 +499,7 @@ class PersistentMemoryStore:
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT c.id, c.created_at, c.updated_at, c.title,
|
||||
SELECT c.id, c.created_at, c.updated_at, c.title, c.project_id,
|
||||
m.role, m.content, m.timestamp, m.model, m.tokens
|
||||
FROM conversations c
|
||||
LEFT JOIN messages m ON m.conversation_id = c.id
|
||||
@@ -471,6 +518,7 @@ class PersistentMemoryStore:
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
title=row["title"],
|
||||
project_id=row["project_id"] or "",
|
||||
)
|
||||
order.append(cid)
|
||||
if row["role"] is not None:
|
||||
@@ -479,10 +527,79 @@ class PersistentMemoryStore:
|
||||
)
|
||||
return [convs[cid] for cid in order]
|
||||
|
||||
def pending_extraction(self, conversation_id: str) -> tuple[list, int]:
|
||||
"""Messages the curator has not read yet, and the id to watermark to.
|
||||
|
||||
Returns ([{role, content}], last_id). An empty list means nothing new,
|
||||
so callers can skip the model call entirely.
|
||||
"""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT extracted_through FROM conversations WHERE id = ?", (conversation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return [], 0
|
||||
rows = conn.execute(
|
||||
"SELECT id, role, content FROM messages "
|
||||
"WHERE conversation_id = ? AND id > ? ORDER BY id ASC",
|
||||
(conversation_id, row["extracted_through"] or 0),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
return [], 0
|
||||
return ([{"role": r["role"], "content": r["content"]} for r in rows], rows[-1]["id"])
|
||||
|
||||
def set_extracted_through(self, conversation_id: str, message_id: int) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE conversations SET extracted_through = ? WHERE id = ?",
|
||||
(int(message_id), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def conversations_awaiting_extraction(self, idle_seconds: float) -> list[str]:
|
||||
"""Conversations with unread messages that have been quiet long enough
|
||||
to count as finished. Used to resume sweeps dropped by a restart."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT c.id FROM conversations c JOIN messages m ON m.conversation_id = c.id "
|
||||
"WHERE m.id > c.extracted_through GROUP BY c.id "
|
||||
"HAVING MAX(m.timestamp) < ?",
|
||||
(time.time() - idle_seconds,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
def delete_conversation(self, conversation_id: str):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
# Drop the embeddings first, while the message ids still resolve.
|
||||
# Stale vectors are inert (the search joins messages) but they still
|
||||
# occupy slots in the ANN over-fetch, so leaving them behind quietly
|
||||
# thins recall of the conversations that are still here.
|
||||
cur.execute(
|
||||
"DELETE FROM message_vectors WHERE message_id IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
# vec_enabled only says the extension loaded; the virtual table is
|
||||
# created lazily on the first semantic search, so check for it.
|
||||
if self.vec_enabled and cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
|
||||
).fetchone():
|
||||
cur.execute(
|
||||
"DELETE FROM vec_messages WHERE rowid IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
|
||||
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
conn.commit()
|
||||
@@ -810,17 +927,40 @@ class PersistentMemoryStore:
|
||||
def list_projects(self) -> List[dict]:
|
||||
conn = self._connect()
|
||||
rows = conn.execute("""
|
||||
SELECT p.id, p.name, p.created_at,
|
||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
|
||||
SELECT p.id, p.name, p.created_at, p.instructions,
|
||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs,
|
||||
(SELECT COUNT(*) FROM conversations c WHERE c.project_id = p.id) AS chats
|
||||
FROM projects p ORDER BY p.created_at ASC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def delete_project(self, project_id: str) -> bool:
|
||||
"""Delete a project; its documents survive but become unscoped ("")."""
|
||||
def set_project_instructions(self, project_id: str, instructions: str) -> bool:
|
||||
"""Per-project system prompt, layered into chats bound to the project."""
|
||||
conn = self._connect()
|
||||
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||
try:
|
||||
cur = conn.execute("UPDATE projects SET instructions = ? WHERE id = ?",
|
||||
(instructions or "", project_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def project_instructions(self, project_id: str) -> str:
|
||||
"""'' when the project has none, or doesn't exist."""
|
||||
if not project_id:
|
||||
return ""
|
||||
conn = self._connect()
|
||||
row = conn.execute("SELECT instructions FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
conn.close()
|
||||
return (row["instructions"] or "") if row else ""
|
||||
|
||||
def delete_project(self, project_id: str) -> bool:
|
||||
"""Delete a project; its documents, chats and facts survive but become
|
||||
unscoped ("")."""
|
||||
conn = self._connect()
|
||||
for table in ("documents", "conversations", "memory"):
|
||||
conn.execute(f"UPDATE {table} SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
@@ -1024,7 +1164,10 @@ class PersistentMemoryStore:
|
||||
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
|
||||
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
|
||||
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
|
||||
"memory_gpu_offload": 0,
|
||||
# -1 = Auto (let Ollama fit it). The curator is the resident chat model
|
||||
# now, so there is nothing to keep off the GPU; pinning it to CPU (0)
|
||||
# only bought coexistence with a second, separate curator model.
|
||||
"memory_gpu_offload": -1,
|
||||
# Similar-fact merge: when a newly extracted fact's embedding is at least
|
||||
# this cosine-similar to an existing fact, UPDATE that fact in place
|
||||
# instead of appending a duplicate ("edit with new info"). 0 disables
|
||||
@@ -1033,6 +1176,9 @@ class PersistentMemoryStore:
|
||||
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
|
||||
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
|
||||
"memory_merge_threshold": 0.80,
|
||||
# Seconds of quiet before the curator reads a conversation. This is the
|
||||
# "conversation is over" signal; each new message restarts the clock.
|
||||
"memory_extract_idle": 120,
|
||||
}
|
||||
|
||||
def get_settings(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -29,12 +29,17 @@ except Exception:
|
||||
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
|
||||
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
|
||||
# smart only with its slow <think> step, weak without it.
|
||||
# Memory: mistral - the curator that extracts facts and titles conversations.
|
||||
# Memory: the curator that extracts facts and titles conversations. It is the
|
||||
# CHAT model on purpose, not a second one: the chat model is already resident in
|
||||
# VRAM and warm, so extraction costs no extra load. A distinct curator (mistral)
|
||||
# did not fit alongside it and had to be pinned to CPU (num_gpu=0), which made
|
||||
# every extraction a slow prompt-eval on a model too small to follow the
|
||||
# curator prompt's negative rules reliably.
|
||||
# Embed: nomic-embed-text - powers semantic recall of past conversations
|
||||
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
|
||||
# recall silently degrades to lexical substring matching.
|
||||
DEFAULT_CHAT_MODEL = "llama3.1:8b"
|
||||
DEFAULT_MEMORY_MODEL = "mistral:latest"
|
||||
DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
|
||||
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
# --- CORE DIRECTORIES ---
|
||||
@@ -161,7 +166,7 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
||||
_LOCAL_ORIGINS = [
|
||||
f"http://{h}:{p}"
|
||||
for h in ("localhost", "127.0.0.1")
|
||||
for p in (8000, 8001, 5173)
|
||||
for p in (8000, 5173)
|
||||
]
|
||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
|
||||
|
||||
+22
-24
@@ -2,30 +2,28 @@ from typing import List
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
|
||||
|
||||
class PlaybookManager:
|
||||
@classmethod
|
||||
def _all(cls) -> List[PlaybookItem]:
|
||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
||||
return playbook_store.all_playbooks()
|
||||
def _all() -> List[PlaybookItem]:
|
||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
||||
return playbook_store.all_playbooks()
|
||||
|
||||
@classmethod
|
||||
def get_main_playbook(cls) -> PlaybookItem | None:
|
||||
playbooks = cls._all()
|
||||
return playbooks[0] if playbooks else None
|
||||
|
||||
@classmethod
|
||||
def get_context_playbooks(cls) -> List[PlaybookItem]:
|
||||
"""All playbooks after the first — injected as reference context."""
|
||||
playbooks = cls._all()
|
||||
return playbooks[1:] if len(playbooks) > 1 else []
|
||||
def get_main_playbook() -> PlaybookItem | None:
|
||||
playbooks = _all()
|
||||
return playbooks[0] if playbooks else None
|
||||
|
||||
@classmethod
|
||||
def get_system_prompt(cls) -> str:
|
||||
playbook = cls.get_main_playbook()
|
||||
if not playbook:
|
||||
return ""
|
||||
goal = (getattr(playbook, "goal", "") or "").strip()
|
||||
instructions = (getattr(playbook, "instructions", "") or "").strip()
|
||||
if goal and instructions:
|
||||
return f"{goal}\n\n{instructions}"
|
||||
return goal or instructions
|
||||
|
||||
def get_context_playbooks() -> List[PlaybookItem]:
|
||||
"""All playbooks after the first — injected as reference context."""
|
||||
playbooks = _all()
|
||||
return playbooks[1:] if len(playbooks) > 1 else []
|
||||
|
||||
|
||||
def get_system_prompt() -> str:
|
||||
playbook = get_main_playbook()
|
||||
if not playbook:
|
||||
return ""
|
||||
goal = (getattr(playbook, "goal", "") or "").strip()
|
||||
instructions = (getattr(playbook, "instructions", "") or "").strip()
|
||||
if goal and instructions:
|
||||
return f"{goal}\n\n{instructions}"
|
||||
return goal or instructions
|
||||
|
||||
@@ -155,6 +155,66 @@ async def _remember(text: str = "", section: str = "General", **_) -> str:
|
||||
return json.dumps({"saved": text, "section": section or "General"})
|
||||
|
||||
|
||||
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
||||
# Paths never leave the repo: every request is resolve()d and checked against
|
||||
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
|
||||
# the tree that are either secrets, private data, or multi-GB noise.
|
||||
_DENIED = {
|
||||
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
||||
"runtime", "dist", "__pycache__", ".git-credentials",
|
||||
}
|
||||
_READ_MAX = 60_000
|
||||
|
||||
|
||||
def _repo_path(rel: str) -> "tuple[object, str | None]":
|
||||
"""Resolve a repo-relative path. Returns (path, error-string)."""
|
||||
from .nexus_config import PROJECT_ROOT
|
||||
rel = (rel or "").strip().lstrip("/")
|
||||
if not rel:
|
||||
return None, "path is required"
|
||||
target = (PROJECT_ROOT / rel).resolve()
|
||||
if not target.is_relative_to(PROJECT_ROOT):
|
||||
return None, "path escapes the project root"
|
||||
parts = set(target.relative_to(PROJECT_ROOT).parts)
|
||||
if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")):
|
||||
return None, f"{rel} is not readable"
|
||||
return target, None
|
||||
|
||||
|
||||
async def _read_file(path: str = "", **_) -> str:
|
||||
target, err = _repo_path(path)
|
||||
if err:
|
||||
return json.dumps({"error": err})
|
||||
if not target.is_file():
|
||||
return json.dumps({"error": f"{path} does not exist"})
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
return json.dumps({"error": f"cannot read {path}: {e}"})
|
||||
return json.dumps({
|
||||
"path": path,
|
||||
"truncated": len(text) > _READ_MAX,
|
||||
"content": text[:_READ_MAX],
|
||||
})
|
||||
|
||||
|
||||
async def _list_files(pattern: str = "", **_) -> str:
|
||||
"""Glob the repo so the model discovers real paths instead of inventing them."""
|
||||
from .nexus_config import PROJECT_ROOT
|
||||
pattern = (pattern or "**/*.py").strip().lstrip("/")
|
||||
hits = []
|
||||
for f in PROJECT_ROOT.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
|
||||
if err:
|
||||
continue
|
||||
hits.append(str(f.relative_to(PROJECT_ROOT)))
|
||||
if len(hits) >= 200:
|
||||
break
|
||||
return json.dumps(sorted(hits))
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
"search_memory": (
|
||||
@@ -197,6 +257,35 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_list_models,
|
||||
),
|
||||
"read_file": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "repo-relative file path"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_read_file,
|
||||
),
|
||||
"list_files": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_files",
|
||||
"description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
_list_files,
|
||||
),
|
||||
"search_documents": (
|
||||
{
|
||||
"type": "function",
|
||||
|
||||
Reference in New Issue
Block a user