forked from enderofwings/NexusOS
Merge origin/main (v1.2.0: Projects, modules, in-app updates)
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
This commit is contained in:
+241
-59
@@ -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
|
||||
@@ -29,8 +30,8 @@ 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 . import playbook_manager
|
||||
from .playbook_manager import PlaybookManager
|
||||
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.
|
||||
@@ -62,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"
|
||||
)
|
||||
|
||||
# Static capability hint, appended to every system prompt. The live Preview UI
|
||||
@@ -211,8 +215,6 @@ from .curry_store import curry_db # noqa: F401 - import triggers Curry's own pr
|
||||
from .search import needs_web_search, web_search
|
||||
from . import slash_commands as _slash_commands
|
||||
|
||||
MEMORY_SERVICE = settings.memory_url
|
||||
|
||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||
|
||||
# Alias for startup scripts
|
||||
@@ -237,6 +239,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
|
||||
@@ -295,7 +304,6 @@ _CHAT_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_CHATS, "chat")
|
||||
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
|
||||
|
||||
# --- GLOBALS ---
|
||||
playbooks = PlaybookManager()
|
||||
ollama = None
|
||||
|
||||
|
||||
@@ -316,6 +324,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
|
||||
# -------------------------
|
||||
@@ -323,17 +342,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)
|
||||
async def _slash_command_stream(
|
||||
@@ -414,7 +566,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
|
||||
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", {})
|
||||
@@ -425,14 +577,28 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||
|
||||
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}"
|
||||
@@ -463,11 +629,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,
|
||||
@@ -525,7 +686,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:
|
||||
@@ -553,6 +714,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 ──────────────────────────────────────
|
||||
@@ -564,10 +727,18 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if images:
|
||||
metadata["images"] = images
|
||||
|
||||
# Tools: playbook allowlist, plus render_preview only when this turn
|
||||
# looks like a visual ask (always advertising it forced a non-stream
|
||||
# tool round on every chat and felt like "stuck thinking").
|
||||
_pb_tools = list(getattr(_main_pb, "tools", None) or []) if _main_pb else []
|
||||
# Tools: playbook allowlist (main playbook AND 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), plus
|
||||
# render_preview/run_snippet on a cue even when no playbook grants them
|
||||
# (always advertising render_preview forced a non-stream tool round on
|
||||
# every chat and felt like "stuck thinking"). _policy/allow_actions were
|
||||
# already computed above, in step with the capability-hint injection.
|
||||
_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 [])]
|
||||
))
|
||||
schemas_by_name: dict = {}
|
||||
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
||||
for s in _tools.standing_schemas():
|
||||
@@ -670,24 +841,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
except Exception as e:
|
||||
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
||||
|
||||
# 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
|
||||
@@ -795,8 +954,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")
|
||||
@@ -811,9 +975,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}")
|
||||
@@ -827,9 +993,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}")
|
||||
@@ -942,10 +1110,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))
|
||||
@@ -1249,6 +1414,8 @@ async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
||||
return {"text": text}
|
||||
|
||||
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Projects / workspaces
|
||||
# -------------------------
|
||||
@@ -1270,6 +1437,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):
|
||||
@@ -1382,9 +1558,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 = [
|
||||
@@ -1400,6 +1578,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
|
||||
]
|
||||
@@ -1477,17 +1656,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}
|
||||
+143
-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 ''")
|
||||
@@ -231,7 +262,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()
|
||||
|
||||
@@ -247,6 +278,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
)
|
||||
|
||||
return cache
|
||||
@@ -273,8 +305,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:
|
||||
@@ -320,12 +354,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 = []
|
||||
@@ -340,6 +374,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
))
|
||||
return items
|
||||
|
||||
@@ -407,6 +442,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()
|
||||
@@ -454,7 +501,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
|
||||
@@ -473,6 +520,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:
|
||||
@@ -481,10 +529,62 @@ 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 -
|
||||
# the delete-side counterpart of _vec_upsert_msg (see its docstring).
|
||||
ids = [r["id"] for r in cur.execute(
|
||||
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||
).fetchall()]
|
||||
@@ -851,17 +951,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()
|
||||
@@ -1065,7 +1188,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
|
||||
@@ -1074,6 +1200,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]:
|
||||
|
||||
@@ -106,12 +106,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 ---
|
||||
@@ -368,11 +373,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 (
|
||||
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
|
||||
_int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
|
||||
5173,
|
||||
)
|
||||
for p in (_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000), 5173)
|
||||
]
|
||||
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||
|
||||
+22
-24
@@ -55,30 +55,28 @@ def make_main(playbook_id: str) -> None:
|
||||
playbook_store.reorder_playbooks(ordered)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -169,6 +169,73 @@ 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. Read-only
|
||||
# counterpart to edit_source's write path (self_edit.py) — a model reading
|
||||
# real source before proposing an edit is the point.
|
||||
_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
|
||||
rel_posix = f.relative_to(PROJECT_ROOT).as_posix()
|
||||
target, err = _repo_path(rel_posix)
|
||||
if err:
|
||||
continue
|
||||
# .as_posix(), not str(): a bare str() gives backslash-separated paths
|
||||
# on Windows, which don't match the forward-slash patterns this tool's
|
||||
# own schema documents (e.g. "synapse/**/*.py") and that read_file
|
||||
# expects back.
|
||||
hits.append(rel_posix)
|
||||
if len(hits) >= 200:
|
||||
break
|
||||
return json.dumps(sorted(hits))
|
||||
|
||||
|
||||
# Canvas drawing APIs a real visualization must use — resizing width/height alone
|
||||
# clears the buffer and draws nothing (a failure mode small models hit often).
|
||||
_CANVAS_DRAW_APIS = (
|
||||
@@ -1139,6 +1206,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