Initial commit: NexusOS - local AI assistant platform

This commit is contained in:
Jon Wingender
2026-07-21 22:48:38 -05:00
commit 714b9fc890
850 changed files with 28278 additions and 0 deletions
View File
+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import asyncio
import json as _json
import logging
import threading
from typing import AsyncGenerator, Dict, List, Optional, Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .ollama_manager import get_ollama_manager
# -------------------------
# Logger setup
# -------------------------
_logger = logging.getLogger("nexus.chat")
_logger.setLevel(logging.INFO)
if not _logger.handlers:
handler = logging.FileHandler(str(settings.chat_log)) if getattr(settings, "chat_log", None) else logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
handler.setFormatter(formatter)
_logger.addHandler(handler)
# -------------------------
# Synapse tracer (real-time prompt/token view for control panel)
# -------------------------
_synapse_lock = threading.Lock()
_synapse_fh = None
def _synapse_trace(text: str) -> None:
global _synapse_fh
try:
log_path = getattr(settings, "chat_log", None)
if not log_path:
return
with _synapse_lock:
if _synapse_fh is None or _synapse_fh.closed:
_synapse_fh = open(str(log_path), "a", buffering=1, encoding="utf-8")
_synapse_fh.write(text)
_synapse_fh.flush()
except Exception:
pass
# -------------------------
# Non-streaming generation
# -------------------------
async def generate_chat_response(
user_message: str,
metadata: Optional[Dict[str, Any]] = None,
history: Optional[List[Dict[str, str]]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
metadata = metadata or {}
timeout = timeout or getattr(settings, "ollama_timeout", 120)
manager = get_ollama_manager()
system = metadata.get("system", "")
model = metadata.get("model") or DEFAULT_CHAT_MODEL
temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu")
messages: List[Dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
_logger.info("generate_chat_response: model=%s turns=%d timeout=%s", model, len(messages), timeout)
sys_preview = (system or "")[:200].replace("\n", " ")
_synapse_trace(f"\n── TURN [{model} | {len(messages)} msgs] {'' * 30}\n")
if system:
_synapse_trace(f"SYS: {sys_preview}{'' if len(system) > 200 else ''}\n")
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try:
result = await asyncio.wait_for(
manager.chat(messages=messages, model=model, stream=False, temperature=temperature, num_gpu=num_gpu),
timeout=timeout,
)
response_text = result if isinstance(result, str) else str(result)
preview = response_text[:500].replace("\n", " ")
_synapse_trace(f"{preview}{'' if len(response_text) > 500 else ''}\n{'' * 50}\n")
_logger.info("generate_chat_response: completed model=%s", model)
return {"response": response_text, "model": model, "metadata": metadata}
except asyncio.TimeoutError:
_logger.exception("generate_chat_response: timeout after %s seconds", timeout)
raise
except Exception:
_logger.exception("generate_chat_response: unexpected error")
raise
# -------------------------
# Async iterator timeout helper
# -------------------------
async def _aiter_with_timeout(aiterable, timeout: Optional[float]):
if timeout is None or timeout <= 0:
async for item in aiterable:
yield item
return
aiter = aiterable.__aiter__()
while True:
try:
item = await asyncio.wait_for(aiter.__anext__(), timeout=timeout)
yield item
except StopAsyncIteration:
break
# -------------------------
# Normalizer for many return shapes
# -------------------------
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
# The sole caller passes manager.chat(stream=True) — an async-def call, i.e.
# a coroutine that resolves to an async generator. Await it if needed, then
# stream the tokens.
result = await maybe_iterable if asyncio.iscoroutine(maybe_iterable) else maybe_iterable
async for item in result:
yield str(item)
# -------------------------
# Streaming implementation
# -------------------------
async def stream_chat_response(
user_message: str,
metadata: Optional[Dict[str, Any]] = None,
history: Optional[List[Dict[str, str]]] = None,
timeout: Optional[float] = None,
) -> AsyncGenerator[str, None]:
metadata = metadata or {}
timeout = timeout or getattr(settings, "ollama_timeout", 120)
manager = get_ollama_manager()
system = metadata.get("system", "")
model = metadata.get("model") or DEFAULT_CHAT_MODEL
temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu")
think = metadata.get("think", False)
# Build messages array for /api/chat multi-turn format
messages: List[Dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
_logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout)
sys_preview = (system or "")[:200].replace("\n", " ")
_synapse_trace(f"\n── TURN [{model} | {len(messages)} msgs] {'' * 30}\n")
if system:
_synapse_trace(f"SYS: {sys_preview}{'' if len(system) > 200 else ''}\n")
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try:
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu, think=think)
async_gen = _normalize_to_async_generator(maybe_iter)
buffer_parts: list[str] = []
buffer_len = 0
FLUSH_THRESHOLD = 24
async for piece in _aiter_with_timeout(async_gen, timeout):
if piece is None:
continue
text = str(piece)
if not text:
continue
# Pass stats sentinel through immediately, don't buffer it
if text.startswith("__meta__"):
if buffer_parts:
chunk = "".join(buffer_parts)
buffer_parts = []
buffer_len = 0
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
yield text
continue
buffer_parts.append(text)
buffer_len += len(text)
if buffer_len >= FLUSH_THRESHOLD or any(text.endswith(c) for c in (".", "!", "?", "\n")):
chunk = "".join(buffer_parts)
buffer_parts = []
buffer_len = 0
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
if buffer_parts:
chunk = "".join(buffer_parts)
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
_synapse_trace(f"{'' * 50}\n")
_logger.info("stream_chat_response: stream completed")
except asyncio.TimeoutError:
_logger.exception("stream_chat_response: timeout after %s seconds", timeout)
raise
except Exception:
_logger.exception("stream_chat_response: unexpected error during streaming")
raise
View File
+244
View File
@@ -0,0 +1,244 @@
"""Composite app icons onto the NexusOS nexus underlay tile."""
from pathlib import Path
import subprocess
import tempfile
import os
import re
_ROOT = Path(__file__).resolve().parents[2]
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
SIZES = [16, 22, 24, 32, 48, 64, 128]
_ALLOWED_ROOTS = [
"/usr/share/icons",
"/usr/share/pixmaps",
"/usr/local/share/icons",
"/opt",
str(Path.home() / ".local/share/icons"),
str(Path.home() / ".icons"),
str(_ROOT / "assets"),
]
_UNDERLAY_FALLBACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#frame)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="#120018"/>
<rect x="16" y="16" width="96" height="96" rx="18" fill="#262626"/>
</svg>
"""
_RING_FALLBACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="ring" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#8cc63f"/>
<stop offset="100%" stop-color="#5a9020"/>
</radialGradient>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#ring)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#frame)"/>
<rect x="12" y="12" width="104" height="104" rx="22" fill="#120018"/>
<rect x="18" y="18" width="92" height="92" rx="16" fill="#262626"/>
</svg>
"""
def _load_underlay(nexus_ring: bool = False) -> str:
"""Return SVG text for the underlay, falling back to inline if file missing."""
target = RING_FILE if nexus_ring else UNDERLAY_FILE
fallback = _RING_FALLBACK if nexus_ring else _UNDERLAY_FALLBACK
if target.exists():
return target.read_text()
return fallback
def _inkscape_render(svg_path: str, out_path: str, size: int) -> None:
subprocess.run(
[
"inkscape", svg_path,
"--export-type=png",
f"--export-filename={out_path}",
f"--export-width={size}",
f"--export-height={size}",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _composite_png(underlay_png: str, app_png: str, out_path: str, frac: float, size: int) -> None:
"""Composite app icon centred on the underlay at given fractional size."""
app_size = max(1, int(size * frac))
offset = (size - app_size) // 2
subprocess.run(
[
"convert",
underlay_png,
"(", app_png, "-resize", f"{app_size}x{app_size}", ")",
"-gravity", "Center",
"-geometry", f"+0+0",
"-composite",
out_path,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _is_allowed_path(path: str) -> bool:
p = Path(os.path.realpath(path))
return p.is_file() and any(
p == root or root in p.parents
for root in (Path(r).resolve() for r in _ALLOWED_ROOTS)
)
def _safe_component(value: str, label: str) -> str:
if not value or not re.fullmatch(r"[A-Za-z0-9._-]+", value):
raise ValueError(f"Invalid icon {label}")
return value
def brand_icon(
src_path: str,
output_name: str,
frac: float = 0.60,
round_mask: bool = False,
nexus_ring: bool = False,
reload: bool = True,
category: str = "apps",
) -> None:
"""Brand a single icon and write PNGs to the NexusOS icon theme at all sizes."""
if not _is_allowed_path(src_path):
raise ValueError(f"Icon source path not in allowed roots: {src_path}")
output_name = _safe_component(output_name, "name")
category = _safe_component(category, "category")
underlay_svg_text = _load_underlay(nexus_ring)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
underlay_svg = tmp_path / "underlay.svg"
underlay_svg.write_text(underlay_svg_text)
for size in SIZES:
out_dir = ICONS_OUT / f"{size}x{size}" / category
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{output_name}.png"
# Break symlinks before writing; also remove any same-name .svg so
# GTK doesn't prefer the old SVG over our new branded PNG.
if out_path.is_symlink() or out_path.exists():
out_path.unlink()
svg_path = out_dir / f"{output_name}.svg"
if svg_path.exists() or svg_path.is_symlink():
svg_path.unlink()
underlay_png = str(tmp_path / f"underlay_{size}.png")
_inkscape_render(str(underlay_svg), underlay_png, size)
# Resize app icon source to a temp PNG for compositing.
# `-background none` is REQUIRED: ImageMagick rasterizes transparent
# SVGs onto an opaque WHITE canvas by default, which shows up as a
# white plate/border behind logos with transparent corners
# (e.g. VSCode, Edge). It must precede the input to affect the SVG.
app_png = str(tmp_path / f"app_{size}.png")
app_size = max(1, int(size * frac))
subprocess.run(
["convert", "-background", "none", src_path,
"-resize", f"{app_size}x{app_size}", app_png],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"convert", underlay_png,
"(", app_png, ")",
"-gravity", "Center",
"-composite",
str(out_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if reload:
apply_icon_cache()
def apply_icon_cache() -> None:
"""Rebuild the GTK icon cache and reload the panel."""
import pwd
uid = os.getuid()
# systemd user session bus — needed for xfce4-panel -r to reach the running session
dbus_addr = os.environ.get(
"DBUS_SESSION_BUS_ADDRESS",
f"unix:path=/run/user/{uid}/bus",
)
_env = {
**os.environ,
"DISPLAY": os.environ.get("DISPLAY", ":0"),
"DBUS_SESSION_BUS_ADDRESS": dbus_addr,
"HOME": pwd.getpwuid(uid).pw_dir,
}
subprocess.run(
["gtk-update-icon-cache", "-f", str(ICONS_OUT)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Force every running GTK app (whisker menu, panel, Thunar) to drop its
# in-memory icon cache. A panel reload alone does NOT do this — GTK only
# re-resolves icons when the icon-theme NAME changes. Toggling to another
# theme and back fires the "theme-changed" signal that triggers the reload.
import time
current = "NexusOS"
try:
out = subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName"],
env=_env, capture_output=True, text=True,
)
if out.stdout.strip():
current = out.stdout.strip()
except Exception:
pass
alt = "Papirus-Dark" if current != "Papirus-Dark" else "Adwaita"
subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", alt],
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(0.5)
subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", current],
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
# Restart Plank so it re-resolves icons from the updated theme
subprocess.run(["pkill", "plank"], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
["plank"],
env=_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
+140
View File
@@ -0,0 +1,140 @@
"""Resolve desktop app icon paths and scan installed applications."""
from pathlib import Path
from typing import Optional
import configparser
_HOME = Path.home()
ICON_SEARCH_DIRS = [
_HOME / ".local/share/icons",
Path("/usr/share/icons"),
Path("/usr/share/pixmaps"),
Path("/usr/local/share/icons"),
]
_THEME_ORDER = ["hicolor", "Papirus-Dark", "Papirus", "gnome", "Adwaita", "breeze"]
_ICON_EXTS = [".png", ".svg", ".xpm"]
DESKTOP_DIRS = [
_HOME / ".local/share/applications",
Path("/usr/local/share/applications"),
Path("/usr/share/applications"),
]
_NEXUS_ICONS = _HOME / ".icons" / "NexusOS"
def _icon_stem(icon_name: str) -> str:
"""Return the icon's name for theme lookup.
Only strips a real image extension (.png/.svg/.xpm). Reverse-DNS icon
names like ``com.visualstudio.code`` or ``org.gnome.Files`` must be kept
intact — Path.stem would wrongly treat the final dotted segment as an
extension and truncate it.
"""
p = Path(icon_name)
if p.suffix.lower() in _ICON_EXTS:
return p.stem
return p.name
def resolve_icon(icon_name: str, size: int = 128) -> Optional[str]:
"""Return absolute path to an icon file, or None if not found."""
if not icon_name:
return None
# Absolute path — use directly if it exists
p = Path(icon_name)
if p.is_absolute() and p.exists():
return str(p)
# Strip extension for name-based search
stem = _icon_stem(icon_name)
size_dirs = [f"{size}x{size}", f"{size}x{size}@2x", "scalable"]
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
for theme in _THEME_ORDER:
theme_dir = search_root / theme
if not theme_dir.is_dir():
continue
for size_dir in size_dirs:
for ctx in ("apps", "categories", "places", "status", "actions"):
ctx_dir = theme_dir / size_dir / ctx
if not ctx_dir.is_dir():
continue
for ext in _ICON_EXTS:
candidate = ctx_dir / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
# pixmaps fallback
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
if search_root.name == "pixmaps":
for ext in _ICON_EXTS:
candidate = search_root / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
for ext in _ICON_EXTS:
candidate = Path("/usr/share/pixmaps") / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
return None
def _is_branded(icon_name: str) -> bool:
"""Return True if a NexusOS-branded PNG already exists for this icon name."""
stem = _icon_stem(icon_name)
return (_NEXUS_ICONS / "128x128" / "apps" / f"{stem}.png").exists()
def scan_apps() -> list[dict]:
"""Return list of installed apps with resolved icon paths."""
seen: dict[str, dict] = {}
for desktop_dir in DESKTOP_DIRS:
if not desktop_dir.is_dir():
continue
for desktop_file in sorted(desktop_dir.glob("*.desktop")):
cfg = configparser.ConfigParser(interpolation=None, strict=False)
try:
cfg.read(str(desktop_file), encoding="utf-8")
except Exception:
continue
if not cfg.has_section("Desktop Entry"):
continue
de = cfg["Desktop Entry"]
if de.get("NoDisplay", "false").lower() == "true":
continue
if de.get("Hidden", "false").lower() == "true":
continue
if de.get("Type", "") != "Application":
continue
name = de.get("Name", desktop_file.stem)
icon_name = de.get("Icon", "")
if not icon_name:
continue
icon_path = resolve_icon(icon_name)
already_branded = _is_branded(icon_name)
key = name.lower()
if key not in seen:
seen[key] = {
"name": name,
"icon_name": _icon_stem(icon_name),
"icon_path": icon_path,
"already_branded": already_branded,
"desktop_file": str(desktop_file),
}
return sorted(seen.values(), key=lambda x: x["name"].lower())
+1094
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
decide if it contains a new permanent personal fact worth saving."""
from __future__ import annotations
import json
import logging
import re
from ..chat import _synapse_trace # append curator reasoning to the same MindTrace log
from ..nexus_config import DEFAULT_MEMORY_MODEL
_log = logging.getLogger(__name__)
_TR = "" * 55
_PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus.
Extract EVERY new, permanent personal fact the USER revealed in this exchange.
There may be SEVERAL facts in one message — output one JSON object for each.
SAVE facts that are stable and biographical, such as: identity (name, age,
location), relationships (family, partner, friends), pets, possessions (vehicles,
home, devices), career (job, employer, skills), hobbies and interests,
long-running projects or goals (not today's to-dos), and durable preferences.
DO NOT SAVE — these are ephemeral and would clutter memory:
- Anything time-bound: "today I have...", "I'm working on X today"
- Mood or energy: "I'm tired", "feeling good", "having a rough day"
- Greetings or small talk: "good morning", "how are you"
- Questions the user asked the assistant
The existing memory is below FOR CONTEXT. If the user ADDS NEW DETAIL to
something already known (e.g. a new detail about a known pet, car, or project),
DO save that new detail as its own fact. Only skip a fact that is an EXACT
restatement of one already listed.
Existing memory:
{existing_texts}
For "section", REUSE one of these existing section names whenever it fits:
{existing_sections}
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}
---
Respond with JSON only — no prose, no markdown fences. Output one object PER
new fact (several objects, one per line, if there are several):
{{"save": true, "section": "<short section name>", "text": "<concise fact about the user, third person>"}}
If there is nothing new to save, output exactly:
{{"save": false}}"""
async def extract_memory(
user_message: str,
assistant_response: str,
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.
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
exchange can hold several facts, and Mistral emits one JSON object per fact.
"""
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
# facts made a ~1200-token prompt that took ~50s+ to process. If dedup
# starts re-saving older facts, move dedup to a difflib check in the
# service instead of stuffing every fact into the prompt.
texts_block = "\n".join(f"- {t}" for t in existing_texts[-12:])
else:
texts_block = "(none yet)"
# Give Mistral the real section names to reuse, so it stops inventing
# sentence-long sections out of the category descriptions in the prompt.
# Only offer SHORT, clean names — never feed a junk sentence-section (e.g. a
# past bad "Long-running projects or goals") back as a valid choice.
clean = sorted(s for s in existing_sections if s and len(s.split()) <= 2 and len(s) <= 24)
sections_line = ", ".join(clean) if clean else (
"Identity, Relationships, Pets, Possessions, Career, Hobbies, Projects, Preferences"
)
prompt = _PROMPT.format(
existing_texts=texts_block,
existing_sections=sections_line,
user_message=user_message[:3000],
assistant_response=assistant_response[:800],
)
# 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.
_synapse_trace(
f"\n{_TR}\n◆ CURATOR: {model} (num_gpu={num_gpu})\n"
f" PROMPT ({len(prompt)} chars):\n{prompt}\n{_TR}\n"
)
try:
# num_gpu=0 (the default) pins the curator fully in system RAM instead of
# the GPU, so it coexists with the GPU-resident chat model instead of
# evicting it. Without this, on a small GPU the two thrash: every
# exchange cold-loads the curator (~45s) and extraction times out,
# silently saving nothing. Boxes with spare VRAM override via settings.
response = await ollama_manager.chat(
messages=[{"role": "user", "content": prompt}],
model=model,
stream=False,
temperature=0.0,
num_gpu=num_gpu,
)
if not response:
_synapse_trace("◆ CURATOR RAW: (empty response)\n")
return []
text = response.strip()
_synapse_trace(f"◆ CURATOR RAW:\n{text}\n")
# Strip markdown code fences if the model added them
if "```" in text:
m = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
if m:
text = m.group(1).strip()
# For several facts Mistral is inconsistent: sometimes ONE JSON object
# per fact newline-separated, sometimes a single JSON ARRAY of objects.
# raw_decode pulls each top-level value (handles the newline case and
# plain "Extra data"); we then flatten any array so both shapes save all
# facts. Plain json.loads() would die on the newline case and skip the
# array (a list isn't a dict), losing every fact either way.
results: list[dict] = []
def _keep(o):
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
results.append({
"section": str(o["section"]).strip(),
"text": str(o["text"]).strip(),
})
dec = json.JSONDecoder()
idx = 0
while idx < len(text):
while idx < len(text) and text[idx] in " \t\r\n,":
idx += 1
if idx >= len(text):
break
try:
obj, idx = dec.raw_decode(text, idx)
except json.JSONDecodeError:
break
if isinstance(obj, list):
for o in obj:
_keep(o)
else:
_keep(obj)
if not results:
_log.warning("memory: nothing saved. mistral 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)
_synapse_trace(f"◆ CURATOR VERDICT: {len(results)} fact(s) — {_facts}\n{_TR}\n\n")
return results
except Exception as e:
_log.warning("memory extraction failed: %s", e)
_synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
return []
+208
View File
@@ -0,0 +1,208 @@
"""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 pydantic import BaseModel
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
app = FastAPI(title="Nexus Memory Service", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_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}
+676
View File
@@ -0,0 +1,676 @@
from pathlib import Path
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from ..nexus_config import DEFAULT_MEMORY_MODEL
import sqlite3
import json
import math
import os
import time
def _cosine(a: List[float], b: List[float]) -> float:
"""Cosine similarity between two equal-length vectors. 0.0 on mismatch."""
if not a or not b or len(a) != len(b):
return 0.0
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))
if na == 0.0 or nb == 0.0:
return 0.0
return dot / (na * nb)
# -----------------------------
# Models
# -----------------------------
class MemoryItem(BaseModel):
id: str
section: str = "General"
text: str
tags: List[str] = []
position: int = 0
class MessageItem(BaseModel):
role: str # "user" or "assistant"
content: str
timestamp: float
model: Optional[str] = None
tokens: Optional[int] = None
class ConversationItem(BaseModel):
id: str
messages: List[MessageItem] = []
created_at: float
updated_at: float
title: Optional[str] = None
@property
def preview(self) -> str:
for msg in self.messages:
if msg.role == "user":
return msg.content[:80]
return "Empty conversation"
@property
def timestamp(self) -> float:
return self.created_at
# -----------------------------
# Persistent Store
# -----------------------------
class PersistentMemoryStore:
def __init__(self, db_path: Path):
self.db_path = db_path
os.makedirs(self.db_path.parent, exist_ok=True)
self._ensure_tables()
self._cache: Dict[str, MemoryItem] = self._load_all_memory()
# -----------------------------
# Internal helpers
# -----------------------------
def _connect(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
return conn
def _ensure_tables(self):
conn = self._connect()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS memory (
id TEXT PRIMARY KEY,
section TEXT NOT NULL DEFAULT 'General',
text TEXT NOT NULL,
tags TEXT
)
""")
# Migrate: add section column if it doesn't exist yet
try:
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
except Exception:
pass
# Migrate: add position column for stable ordering
try:
cur.execute("ALTER TABLE memory ADD COLUMN position INTEGER NOT NULL DEFAULT 0")
except Exception:
pass
# Backfill positions for rows added before this column existed
cur.execute("SELECT COUNT(*) FROM memory WHERE position > 0")
if cur.fetchone()[0] == 0:
cur.execute("UPDATE memory SET position = rowid")
cur.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
title TEXT
)
""")
# Migrate: add title column if it doesn't exist yet
try:
cur.execute("ALTER TABLE conversations ADD COLUMN title TEXT")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL,
model TEXT,
tokens INTEGER,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
""")
for col, typedef in (("model", "TEXT"), ("tokens", "INTEGER")):
try:
cur.execute(f"ALTER TABLE messages ADD COLUMN {col} {typedef}")
except Exception:
pass
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
ON messages (conversation_id)
""")
# Semantic recall: one embedding vector per message, stored as JSON.
# Backfilled lazily by semantic_search_conversations so existing history
# gets indexed on first search.
cur.execute("""
CREATE TABLE IF NOT EXISTS message_vectors (
message_id INTEGER PRIMARY KEY,
embedding TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES messages(id)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
cur.execute(
"DELETE FROM settings WHERE key IN ('anthropic_api_key', 'escalation_model')"
)
conn.commit()
conn.close()
# -----------------------------
# Loaders
# -----------------------------
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")
rows = cur.fetchall()
conn.close()
cache = {}
for row in rows:
try:
tags = json.loads(row["tags"]) if row["tags"] else []
except Exception:
tags = []
cache[row["id"]] = MemoryItem(
id=row["id"],
section=row["section"] or "General",
text=row["text"],
tags=tags,
position=row["position"] or 0,
)
return cache
# -----------------------------
# Memory API
# -----------------------------
def add(self, item: MemoryItem):
if not item.position:
conn = self._connect()
try:
row = conn.execute(
"SELECT position FROM memory WHERE id = ?", (item.id,)
).fetchone()
if row and row["position"]:
item.position = row["position"]
else:
row = conn.execute("SELECT MAX(position) AS max_position FROM memory").fetchone()
item.position = (row["max_position"] or 0) + 1
finally:
conn.close()
self._cache[item.id] = item
conn = self._connect()
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)
)
conn.commit()
finally:
conn.close()
def update(self, item: MemoryItem):
existing = self.get(item.id)
if existing:
item.position = existing.position
self.add(item)
def delete(self, item_id: str):
self._cache.pop(item_id, None)
conn = self._connect()
try:
cur = conn.cursor()
cur.execute("DELETE FROM memory WHERE id = ?", (item_id,))
conn.commit()
finally:
conn.close()
def get(self, item_id: str) -> Optional[MemoryItem]:
conn = self._connect()
try:
row = conn.execute(
"SELECT id, section, text, tags, position FROM memory WHERE id = ?",
(item_id,),
).fetchone()
finally:
conn.close()
if not row:
self._cache.pop(item_id, None)
return None
try:
tags = json.loads(row["tags"]) if row["tags"] else []
except Exception:
tags = []
item = MemoryItem(
id=row["id"], section=row["section"] or "General", text=row["text"],
tags=tags, position=row["position"] or 0,
)
self._cache[item_id] = item
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.
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
rows = cur.fetchall()
conn.close()
items = []
for row in rows:
try:
tags = json.loads(row["tags"]) if row["tags"] else []
except Exception:
tags = []
items.append(MemoryItem(
id=row["id"],
section=row["section"] or "General",
text=row["text"],
tags=tags,
position=row["position"] or 0,
))
return items
def reorder_section(self, section: str, ordered_ids: List[str]) -> bool:
"""Rewrite the order of items in a section using its existing position pool.
ordered_ids must contain exactly the ids currently in the section."""
section_norm = section or "General"
in_section = [i for i in self.all() if (i.section or "General") == section_norm]
if len(in_section) != len(ordered_ids):
return False
by_id = {i.id: i for i in in_section}
siblings = []
for id_ in ordered_ids:
if id_ not in by_id:
return False
siblings.append(by_id[id_])
positions = sorted([i.position for i in in_section])
conn = self._connect()
try:
cur = conn.cursor()
for s, new_pos in zip(siblings, positions):
s.position = new_pos
cur.execute("UPDATE memory SET position = ? WHERE id = ?", (new_pos, s.id))
conn.commit()
finally:
conn.close()
return True
# -----------------------------
# Conversation API
# -----------------------------
def create_conversation(self, conversation_id: str) -> ConversationItem:
now = time.time()
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)",
(conversation_id, now, now)
)
conn.commit()
finally:
conn.close()
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
def set_conversation_title(self, conversation_id: str, title: str):
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"UPDATE conversations SET title = ? WHERE id = ?",
(title, 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()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
(conversation_id, role, content, now, model, tokens)
)
message_id = cur.lastrowid
cur.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id)
)
conn.commit()
return message_id
finally:
conn.close()
def get_conversation(self, conversation_id: str) -> Optional[ConversationItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT * FROM conversations WHERE id = ?", (conversation_id,))
row = cur.fetchone()
if not row:
conn.close()
return None
cur.execute(
"SELECT role, content, timestamp, model, tokens FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC",
(conversation_id,)
)
msg_rows = cur.fetchall()
conn.close()
messages = [MessageItem(role=r["role"], content=r["content"], timestamp=r["timestamp"], model=r["model"], tokens=r["tokens"]) for r in msg_rows]
return ConversationItem(
id=row["id"],
messages=messages,
created_at=row["created_at"],
updated_at=row["updated_at"],
title=row["title"] if "title" in row.keys() else None,
)
def all_conversations(self) -> List[ConversationItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT c.id, c.created_at, c.updated_at, c.title,
m.role, m.content, m.timestamp, m.model, m.tokens
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
ORDER BY c.updated_at DESC, m.timestamp ASC
""")
rows = cur.fetchall()
conn.close()
convs: Dict[str, ConversationItem] = {}
order: list[str] = []
for row in rows:
cid = row["id"]
if cid not in convs:
convs[cid] = ConversationItem(
id=cid,
created_at=row["created_at"],
updated_at=row["updated_at"],
title=row["title"],
)
order.append(cid)
if row["role"] is not None:
convs[cid].messages.append(
MessageItem(role=row["role"], content=row["content"], timestamp=row["timestamp"], model=row["model"], tokens=row["tokens"])
)
return [convs[cid] for cid in order]
def delete_conversation(self, conversation_id: str):
conn = self._connect()
try:
cur = conn.cursor()
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
conn.commit()
finally:
conn.close()
# -----------------------------
# Search API
# -----------------------------
def search_conversations(self, query: str, limit: int = 3) -> List[dict]:
"""Return up to `limit` conversations that contain the query string,
with full user+assistant exchange pairs around each match."""
if not query or not query.strip():
return []
q = query.strip().lower()
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT c.id, c.created_at, c.updated_at
FROM conversations c
JOIN messages m ON m.conversation_id = c.id
WHERE LOWER(m.content) LIKE ?
ORDER BY c.updated_at DESC
LIMIT ?
""", (f"%{q}%", limit))
rows = cur.fetchall()
results = []
for row in rows:
# Load all messages in order so we can find complete exchange pairs
cur.execute("""
SELECT role, content FROM messages
WHERE conversation_id = ?
ORDER BY timestamp ASC
""", (row["id"],))
all_msgs = [{"role": r["role"], "content": r["content"]} for r in cur.fetchall()]
# For each matching message, collect the full user+assistant pair around it
seen_pairs: set = set()
matches = []
for i, msg in enumerate(all_msgs):
if q not in msg["content"].lower():
continue
if msg["role"] == "user":
start, end = i, i + 1 if i + 1 < len(all_msgs) else i
else:
start, end = (i - 1 if i > 0 else i), i
if (start, end) in seen_pairs:
continue
seen_pairs.add((start, end))
for m in all_msgs[start:end + 1]:
matches.append({"role": m["role"], "content": m["content"][:500]})
if len(seen_pairs) >= 2:
break
if matches:
results.append({
"id": row["id"],
"updated_at": row["updated_at"],
"matches": matches,
})
conn.close()
return results
# nomic-embed-text is an asymmetric retrieval model: queries and stored
# documents must be embedded with these task prefixes or similarity collapses
# into noise. Cached vectors are document-embeddings (search_document:).
_EMBED_QUERY_PREFIX = "search_query: "
_EMBED_DOC_PREFIX = "search_document: "
async def semantic_search_conversations(
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
) -> List[dict]:
"""Recall past conversations relevant to `query` using hybrid retrieval.
Combines semantic similarity (embeddings — finds reworded matches with no
shared keywords) with the existing lexical substring match (catches exact
terms the embedding underweights), unioned and deduped by conversation.
`embed_fn` is an async callable returning an embedding vector for a string
(typically OllamaManager.embed). Messages without a stored vector are
embedded and cached on first use (lazy backfill). If embeddings are
unavailable (no model / Ollama down) this degrades to pure lexical match,
so recall never silently breaks.
Returns the same shape as `search_conversations`: a list of
{id, updated_at, matches:[{role, content}]} with full user+assistant
pairs around each match.
"""
if not query or not query.strip():
return []
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
if not query_vec:
return self.search_conversations(query, limit=limit)
conn = self._connect()
cur = conn.cursor()
# Lazy backfill: embed any messages that don't have a vector yet.
cur.execute("""
SELECT m.id, m.content
FROM messages m
LEFT JOIN message_vectors v ON v.message_id = m.id
WHERE v.message_id IS NULL AND TRIM(m.content) != ''
""")
missing = cur.fetchall()
for row in missing:
vec = await embed_fn(self._EMBED_DOC_PREFIX + row["content"][:2000])
if vec:
cur.execute(
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
(row["id"], json.dumps(vec)),
)
if missing:
conn.commit()
# Score every stored message against the query vector.
cur.execute("""
SELECT v.message_id, v.embedding, m.conversation_id
FROM message_vectors v
JOIN messages m ON m.id = v.message_id
""")
scored = []
for row in cur.fetchall():
try:
vec = json.loads(row["embedding"])
except Exception:
continue
score = _cosine(query_vec, vec)
if score >= min_score:
scored.append((score, row["message_id"], row["conversation_id"]))
scored.sort(reverse=True)
results: List[dict] = []
seen_convs: set = set()
for score, message_id, conv_id in scored:
if len(results) >= limit:
break
if conv_id in seen_convs:
continue
pair = self._exchange_pair(cur, conv_id, message_id)
if pair:
seen_convs.add(conv_id)
results.append(pair)
conn.close()
# Hybrid union: fill any remaining slots with lexical matches the
# embedding missed (e.g. exact proper nouns), skipping dupes.
if len(results) < limit:
for conv in self.search_conversations(query, limit=limit):
if conv["id"] not in seen_convs:
seen_convs.add(conv["id"])
results.append(conv)
if len(results) >= limit:
break
return results
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
"""Build a {id, updated_at, matches} record with the full user+assistant
pair surrounding `message_id`, in the shape search callers expect."""
cur.execute(
"SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC",
(conv_id,),
)
all_msgs = cur.fetchall()
idx = next((i for i, m in enumerate(all_msgs) if m["id"] == message_id), None)
if idx is None:
return None
if all_msgs[idx]["role"] == "user":
start, end = idx, min(idx + 1, len(all_msgs) - 1)
else:
start, end = max(idx - 1, 0), idx
matches = [
{"role": all_msgs[i]["role"], "content": all_msgs[i]["content"][:500]}
for i in range(start, end + 1)
]
cur.execute("SELECT updated_at FROM conversations WHERE id = ?", (conv_id,))
crow = cur.fetchone()
return {
"id": conv_id,
"updated_at": crow["updated_at"] if crow else 0,
"matches": matches,
}
# -----------------------------
# Settings API
# -----------------------------
_SETTINGS_DEFAULTS: Dict[str, Any] = {
"model": "",
# Qwen3-style reasoning. Off by default: the hidden <think> block is pure
# latency for chat/memory. Turn on for hard multi-step problems.
"think": False,
"temperature": 0.7,
"system_prompt": "",
"timeout": 120,
# How long Ollama keeps the model resident in VRAM between messages.
# "30m"/"-1" (never unload)/"0" (unload now). Avoids cold-reload latency
# when you return to an idle chat. Empty → Ollama's 5-minute default.
"keep_alive": "30m",
# CPU/GPU offload: -1 = Auto (Ollama auto-fits layers to VRAM).
# 0100 = percent of model layers to force onto the GPU; the
# remainder runs on CPU. See OllamaManager.get_model_layers.
"gpu_offload": -1,
# Memory curator (the model that extracts facts after each exchange).
# Empty → same auto-selected model as chat. A dedicated model (e.g.
# "mistral:latest") gives better extraction but must share VRAM.
"memory_model": DEFAULT_MEMORY_MODEL,
# 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,
# 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
# (always append). Calibrated on nomic-embed-text: genuine updates
# (mileage/title/location changes) score 0.810.99, while distinct facts
# 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,
}
def get_settings(self) -> Dict[str, Any]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT key, value FROM settings")
rows = cur.fetchall()
conn.close()
result = dict(self._SETTINGS_DEFAULTS)
for row in rows:
try:
result[row["key"]] = json.loads(row["value"])
except Exception:
result[row["key"]] = row["value"]
return result
def update_settings(self, data: Dict[str, Any]):
conn = self._connect()
try:
cur = conn.cursor()
for key, value in data.items():
if key in self._SETTINGS_DEFAULTS:
cur.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, json.dumps(value))
)
conn.commit()
finally:
conn.close()
# -----------------------------
# Store Instance
# -----------------------------
from ..nexus_config import MEMORY_DB
DB_PATH = MEMORY_DB
store = PersistentMemoryStore(DB_PATH)
+157
View File
@@ -0,0 +1,157 @@
# config.py
from __future__ import annotations
import os
from pathlib import Path
from typing import Dict, Any
# --- ENV ---
try:
from dotenv import load_dotenv # optional
load_dotenv()
except Exception:
pass
# --- PROJECT ROOT ---
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# --- VERSION (single source of truth: the VERSION file at the repo root) ---
try:
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
except Exception:
VERSION = "0.0.0"
# --- MODEL DEFAULTS ---
# Single source of truth for the two models NexusOS ships with. The installers
# pull and pin these, the runtime falls back to them; keeping them in one place
# is what stops installer and backend from drifting apart.
#
# 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.
DEFAULT_CHAT_MODEL = "llama3.1:8b"
DEFAULT_MEMORY_MODEL = "mistral:latest"
# --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models"
RUNTIME_DIR = PROJECT_ROOT / "runtime"
MEMORY_DIR = PROJECT_ROOT / "synapse" / "memory"
LOGS_DIR = RUNTIME_DIR / "logs"
CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp"
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports"
# --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = MEMORY_DIR / "memory.db"
# --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
for d in (
DATA_DIR,
MODELS_DIR,
RUNTIME_DIR,
LOGS_DIR,
MEMORY_DIR,
CACHE_DIR,
TEMP_DIR,
PLAYBOOK_DIR,
UPLOADS_DIR,
EXPORTS_DIR,
):
d.mkdir(parents=True, exist_ok=True)
# --- PATH ACCESSOR (fail-fast) ---
def path(name: str) -> Path:
"""
Return a Path for a known name. Raises KeyError if name is unknown.
"""
mapping = {
"root": PROJECT_ROOT,
"data": DATA_DIR,
"models": MODELS_DIR,
"runtime": RUNTIME_DIR,
"logs": LOGS_DIR,
"memory": MEMORY_DIR,
"cache": CACHE_DIR,
"tmp": TEMP_DIR,
"playbooks": PLAYBOOK_DIR,
"uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR,
"memory_db": MEMORY_DB,
"backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG,
"chat_log": CHAT_LOG,
}
try:
return mapping[name]
except KeyError:
raise KeyError(f"Unknown config path name: {name}")
# --- Settings class and exported instance ---
class Settings:
"""
Lightweight settings container. Use `settings` instance for runtime access,
or `Settings` class for typing/tests.
"""
def __init__(self) -> None:
self.version: str = VERSION
self.project_root: Path = PROJECT_ROOT
self.data_dir: Path = DATA_DIR
self.models_dir: Path = MODELS_DIR
self.runtime_dir: Path = RUNTIME_DIR
self.memory_dir: Path = MEMORY_DIR
self.logs_dir: Path = LOGS_DIR
# DB files
self.memory_db: Path = MEMORY_DB
# Logs
self.backend_log: Path = BACKEND_LOG
self.ollama_log: Path = OLLAMA_LOG
self.chat_log: Path = CHAT_LOG
# Env overrides
self.ollama_host: str = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
self.ollama_timeout: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
def as_dict(self) -> Dict[str, Any]:
return {
"version": self.version,
"project_root": str(self.project_root),
"data_dir": str(self.data_dir),
"models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db),
"ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout,
}
# exported instance
settings = Settings()
# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", "VERSION",
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
# --- quick runtime sanity check when run directly (no side effects on import) ---
if __name__ == "__main__":
print("Config paths:")
for key in ("root", "data", "models", "runtime", "memory", "memory_db"):
print(f" {key}: {path(key)}")
+725
View File
@@ -0,0 +1,725 @@
import asyncio
import json
import logging
import subprocess
import time
import httpx
import os
import signal
from pathlib import Path
from typing import Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .memory.store import store
OLLAMA_PORT = 11434
_log = logging.getLogger("nexus.ollama")
# --- Global Singleton Instance ---
_ollama_manager = None
# Bundled binary ships alongside the project; fall back to system PATH
_BUNDLED_OLLAMA = Path(__file__).resolve().parent.parent / "ollama" / "bin" / "ollama"
# POSIX: detach the child into its own session so we can signal the whole group.
# Windows has no setsid/killpg — run the child normally and terminate() it.
_DETACH_KW = {} if os.name == "nt" else {"start_new_session": True}
def _ollama_bin() -> str:
"""Return path to the Ollama executable, preferring the bundled copy."""
if _BUNDLED_OLLAMA.exists():
return str(_BUNDLED_OLLAMA)
return "ollama"
def _best_vulkan_device() -> tuple[int, str]:
"""
Parse `vulkaninfo --summary` and return (device_index, device_name) for the
best Vulkan compute device. Prefers discrete GPUs over integrated ones, and
AMD/NVIDIA vendor IDs over Intel — so a Radeon is chosen over an Intel iGPU
even when the iGPU appears first in the device list.
"""
try:
r = subprocess.run(
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5,
)
if r.returncode != 0:
return 0, "GPU"
devices: list[dict] = []
current: dict = {}
for line in r.stdout.splitlines():
line = line.strip()
if line.startswith("GPU") and line.endswith(":"):
if current:
devices.append(current)
raw_idx = line[3:-1]
current = {
"index": int(raw_idx) if raw_idx.isdigit() else len(devices),
"name": "GPU",
"type": "",
"vendor": "",
}
elif "=" in line:
key, _, val = line.partition("=")
key, val = key.strip(), val.strip()
if key == "deviceName":
current["name"] = val
elif key == "deviceType":
current["type"] = val.upper()
elif key == "vendorID":
current["vendor"] = val.lower()
if current:
devices.append(current)
if not devices:
return 0, "GPU"
def _score(d: dict) -> tuple:
# Discrete beats everything; integrated is last resort
type_score = 2 if "DISCRETE" in d["type"] else (0 if "INTEGRATED" in d["type"] else 1)
# AMD (0x1002) and NVIDIA (0x10de) preferred over Intel (0x8086)
vendor_score = 1 if any(v in d["vendor"] for v in ("0x1002", "0x10de")) else 0
return (type_score, vendor_score)
best = max(devices, key=_score)
return best["index"], best["name"]
except Exception:
return 0, "GPU"
def _detect_gpu_backend() -> tuple[str, dict]:
"""
Probe available GPU compute backends.
Returns (label, env_overrides) where env_overrides is merged into the
Ollama subprocess environment before launch.
Priority: CUDA > Vulkan > ROCm > CPU.
"""
# NVIDIA CUDA — preferred when both GPU and CUDA drivers are present
try:
r = subprocess.run(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
name = r.stdout.strip().splitlines()[0]
_log.info("GPU backend: CUDA (%s)", name)
return f"cuda ({name})", {} # Ollama auto-detects CUDA
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Vulkan — works on AMD, Intel, and NVIDIA without a full CUDA/ROCm stack
vulkan_ok = False
try:
r = subprocess.run(
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5,
)
vulkan_ok = r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if not vulkan_ok:
# Fall back to checking for ICD loader files without the vulkaninfo tool
icd_dirs = [
Path("/usr/share/vulkan/icd.d"),
Path("/etc/vulkan/icd.d"),
Path(os.path.expanduser("~/.local/share/vulkan/icd.d")),
]
try:
vulkan_ok = any(p.is_dir() and any(p.iterdir()) for p in icd_dirs)
except PermissionError:
pass
if vulkan_ok:
idx, name = _best_vulkan_device()
# Always pin to the selected device — without this, Ollama may use the Intel
# iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides
# AMD ROCm — fallback when Vulkan ICD is absent but ROCm stack is installed
try:
r = subprocess.run(
["rocm-smi", "--showproductname"], capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
_log.info("GPU backend: ROCm")
return "rocm", {} # Ollama auto-detects ROCm
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
_log.info("GPU backend: CPU (no GPU acceleration detected)")
return "cpu", {}
# Single source of model auto-selection preference. Ordered so small, GPU-fitting
# models come first (qwen2.5:3b fits a 4GB card and is a strong all-rounder); the
# tail differs by task. Prefix-matched against installed model names.
_MODEL_PREFERENCE = {
"chat": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "gemma2", "gemma"),
"code": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "codellama", "deepseek-coder", "codegemma"),
}
def _preferred_model(models: list, preference) -> str | None:
"""First installed model whose name starts with a preference prefix."""
for prefix in preference:
for m in models:
if m.lower().startswith(prefix.lower()):
return m
return None
def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
"""Assemble the Ollama `options` block from the knobs we expose.
Returns an empty dict when nothing is set so callers can omit `options`
entirely (preserving Ollama's defaults / auto behaviour).
"""
opts: dict = {}
if temperature is not None:
opts["temperature"] = temperature
if num_gpu is not None:
opts["num_gpu"] = num_gpu
return opts
class OllamaManager:
def __init__(self, runtime_dir=None):
self.process = None
self.running = False
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
self.log_file = self.runtime_dir / "logs" / "ollama.log"
# Resolved at construction so host changes in settings take effect
self._api_base = settings.ollama_host.rstrip("/")
# Model selection cache
self._model_cache: dict = {} # intent -> (model, monotonic_ts)
# Per-model offloadable layer count cache (never changes for a model)
self._layer_cache: dict[str, int] = {}
# How long Ollama keeps the model resident between requests. Applied to
# every chat/generate body so the model isn't reloaded on each message.
# Overridden from persisted settings at startup. Falsy → omit (Ollama's
# 5-minute default).
self.keep_alive: str | None = "30m"
def _apply_keep_alive(self, body: dict) -> dict:
"""Add `keep_alive` (a top-level Ollama field) to a request body when set."""
if self.keep_alive:
body["keep_alive"] = self.keep_alive
return body
async def warm(self, model: str | None = None, num_gpu: int | None = None) -> None:
"""Preload a model so the first request doesn't pay a cold load.
An empty-prompt /api/generate is Ollama's documented preload. Pass the
same `num_gpu` the real requests use, or the preloaded copy is placed
differently and gets reloaded on first use. Best-effort: never raises,
so a missing model or down server can't break startup."""
try:
model = model or await self.select_best_model()
if not model:
return
body = {"model": model, "prompt": "", "stream": False}
if num_gpu is not None:
body["options"] = {"num_gpu": num_gpu}
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{self._api_base}/api/generate", json=self._apply_keep_alive(body),
)
_log.info("warm: preloaded model=%s num_gpu=%s keep_alive=%s", model, num_gpu, self.keep_alive)
except Exception as e:
_log.warning("warm: preload failed: %s", e)
def is_available(self):
bin_path = _ollama_bin()
try:
subprocess.run([bin_path, "--version"], capture_output=True, check=True, timeout=5)
return True
except Exception:
return False
def is_running(self):
try:
r = httpx.get(f"{self._api_base}/api/tags", timeout=2.0)
return r.status_code == 200
except httpx.RequestError:
return False
def start(self):
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
if self.is_running():
_log.info("Ollama already running (%s)", self._api_base)
self.running = True
return True
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
# Linux ships a bundled model store under the project. On Windows,
# Ollama is installed system-wide and pulls into its default store,
# so don't override OLLAMA_MODELS or a NexusOS-spawned `serve` won't
# see the models the installer already pulled.
if os.name != "nt":
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
[_ollama_bin(), "serve"],
stdout=log,
stderr=subprocess.STDOUT,
env=env,
**_DETACH_KW,
)
for attempt in range(30):
if self.is_running():
_log.info("Ollama service started (%s)", self._api_base)
self.running = True
return True
time.sleep(1)
if attempt % 5 == 0:
_log.info("Waiting for Ollama... (%ds)", attempt)
_log.error("Ollama failed to start: timeout")
return False
except Exception as e:
_log.exception("Ollama failed to start: %s", e)
return False
async def start_async(self):
"""Async-safe version of start() for use inside async startup handlers."""
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
if self.is_running():
_log.info("Ollama already running (%s)", self._api_base)
self.running = True
return True
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
# Linux ships a bundled model store under the project. On Windows,
# Ollama is installed system-wide and pulls into its default store,
# so don't override OLLAMA_MODELS or a NexusOS-spawned `serve` won't
# see the models the installer already pulled.
if os.name != "nt":
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
[_ollama_bin(), "serve"],
stdout=log,
stderr=subprocess.STDOUT,
env=env,
**_DETACH_KW,
)
for attempt in range(30):
if self.is_running():
_log.info("Ollama service started (%s)", self._api_base)
self.running = True
return True
await asyncio.sleep(1)
if attempt % 5 == 0:
_log.info("Waiting for Ollama... (%ds)", attempt)
_log.error("Ollama failed to start: timeout")
return False
except Exception as e:
_log.exception("Ollama failed to start: %s", e)
return False
def stop(self):
# Terminate a server we spawned ourselves.
if self.process:
try:
_log.info("Stopping Ollama service (owned process)...")
if os.name == "nt":
self.process.terminate()
else:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait(timeout=10)
_log.info("Ollama service stopped")
except Exception:
try:
if os.name == "nt":
self.process.kill()
else:
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except Exception:
pass
finally:
self.process = None
# Ollama may still be up because something else started it (e.g. the
# Windows desktop app autostarts one). The manual Stop button should
# still stop the AI, so kill any remaining server by name. Best-effort:
# an elevated instance can't be killed from a user-level process, so log
# rather than raise.
if self.is_running():
_log.info("Stopping externally-started Ollama...")
try:
if os.name == "nt":
for image in ("ollama app.exe", "ollama.exe"):
subprocess.run(
["taskkill", "/F", "/T", "/IM", image],
capture_output=True, timeout=10,
)
else:
subprocess.run(
["pkill", "-f", "ollama serve"], capture_output=True, timeout=10,
)
except Exception as e:
_log.warning("external Ollama stop failed: %s", e)
self.running = False
def get_status(self):
if self.is_running():
return "running"
elif self.is_available():
return "available"
else:
return "unavailable"
async def generate(
self,
prompt: str,
model: str = DEFAULT_CHAT_MODEL,
stream: bool = False,
system: str = "",
**kwargs
):
start = time.perf_counter()
_log.debug("generate model=%s system=%r prompt=%.120s", model, system, prompt)
try:
if stream:
return self._stream(prompt=prompt, model=model, system=system, start=start)
else:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{self._api_base}/api/generate",
json=self._apply_keep_alive({
"model": model,
"prompt": prompt,
"system": system,
"stream": False,
}),
)
elapsed = time.perf_counter() - start
_log.info("generate completed model=%s status=%d elapsed=%.3fs", model, r.status_code, elapsed)
r.raise_for_status()
return r.json().get("response", "")
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("generate error after %.3fs: %s", elapsed, e)
return None
async def _stream(self, prompt: str, model: str, system: str, start: float):
"""
Async generator that streams token chunks from Ollama.
Yields string chunks as they arrive.
"""
try:
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"POST",
f"{self._api_base}/api/generate",
json=self._apply_keep_alive({
"model": model,
"prompt": prompt,
"system": system,
"stream": True,
}),
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
token = data.get("response", "")
if token:
yield token
if data.get("done", False):
elapsed = time.perf_counter() - start
_log.info("generate stream completed model=%s elapsed=%.3fs", model, elapsed)
break
except Exception:
continue
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("generate stream error after %.3fs: %s", elapsed, e)
return
async def chat(
self,
messages: list,
model: str = DEFAULT_CHAT_MODEL,
stream: bool = False,
temperature: float | None = None,
num_gpu: int | None = None,
think: bool = False,
**kwargs,
):
"""Multi-turn chat via /api/chat (accepts a messages array with roles).
`think` toggles Qwen3-style reasoning. Default off: the hidden <think>
block is pure latency for chat/memory. Ollama ignores it for models that
don't support thinking.
"""
start = time.perf_counter()
try:
if stream:
return self._chat_stream(
messages=messages, model=model, temperature=temperature,
num_gpu=num_gpu, think=think, start=start,
)
else:
body: dict = {"model": model, "messages": messages, "stream": False}
body["think"] = think
opts = _chat_options(temperature, num_gpu)
if opts:
body["options"] = opts
self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start
r.raise_for_status()
return r.json().get("message", {}).get("content", "")
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat error after %.3fs: %s", elapsed, e)
return None
async def embed(self, text: str, model: str = "nomic-embed-text") -> list[float] | None:
"""Return an embedding vector for `text` via /api/embeddings.
Returns None on any failure so callers can fall back to lexical search —
a missing embedding model should never break chat or recall.
"""
text = (text or "").strip()
if not text:
return None
try:
gpu_offload = store.get_settings().get("memory_gpu_offload", 0)
num_gpu = await self.resolve_num_gpu(gpu_offload, model)
body: dict[str, Any] = {"model": model, "prompt": text}
if num_gpu is not None:
body["options"] = {"num_gpu": num_gpu}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{self._api_base}/api/embeddings",
json=body,
)
r.raise_for_status()
vec = r.json().get("embedding")
return vec if vec else None
except Exception as e:
_log.debug("embed failed (model=%s): %s", model, e)
return None
async def list_models(self) -> list[str]:
"""Return names of all locally installed Ollama models."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{self._api_base}/api/tags")
r.raise_for_status()
return [m["name"] for m in r.json().get("models", [])]
except Exception:
return []
async def select_best_model(self, intent: str = "chat") -> str:
"""Pick the preferred installed model for `intent` ('chat' or 'code'),
falling back to any installed model, then DEFAULT_CHAT_MODEL. Cached ~60s per
intent so rapid requests don't rebuild the model list each time.
"""
now = time.monotonic()
cached = self._model_cache.get(intent)
if cached and (now - cached[1]) < 60:
return cached[0]
models = await self.list_models()
pref = _MODEL_PREFERENCE.get(intent, _MODEL_PREFERENCE["chat"])
best = _preferred_model(models, pref) or (models[0] if models else DEFAULT_CHAT_MODEL)
self._model_cache[intent] = (best, now)
return best
def invalidate_model_cache(self):
"""Force next select_best_model() to re-query (e.g. after pull/delete)."""
self._model_cache = {}
async def get_model_layers(self, model: str) -> int | None:
"""Total offloadable layer count for `model` (repeating blocks + output layer).
Used to turn a CPU/GPU offload percentage into an Ollama `num_gpu`
value. Reads `<arch>.block_count` from /api/show and adds 1 for the
non-repeating output layer (Ollama reports e.g. 33 layers for a model
with block_count=32). Cached per-model since it never changes.
Returns None if the count can't be determined, so callers fall back
to Auto (no num_gpu override).
"""
if model in self._layer_cache:
return self._layer_cache[model]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self._api_base}/api/show", json={"model": model})
r.raise_for_status()
info = r.json().get("model_info", {}) or {}
block_count = next(
(v for k, v in info.items() if k.endswith(".block_count")), None
)
layers = int(block_count) + 1 if block_count is not None else None
except Exception as e:
_log.warning("get_model_layers(%s) failed: %s", model, e)
layers = None
if layers:
self._layer_cache[model] = layers
return layers
async def resolve_num_gpu(self, gpu_offload, model: str) -> int | None:
"""Convert a stored gpu_offload setting into an Ollama `num_gpu` value.
`gpu_offload` is -1 for Auto (returns None → no override, Ollama auto-fits)
or 0100 for the percent of the model's layers to force onto the GPU
(0 = all CPU/RAM). Layer count is model-specific, resolved from the live
model. Returns None on anything unexpected so callers fall back to Auto.
"""
try:
pct = int(gpu_offload)
except (TypeError, ValueError):
return None
if pct < 0:
return None
pct = min(pct, 100)
layers = await self.get_model_layers(model)
if not layers:
return None
return max(0, round(pct / 100 * layers))
async def _chat_stream(self, messages: list, model: str, start: float,
temperature: float | None = None, num_gpu: int | None = None,
think: bool = False):
"""Async generator streaming tokens, then a final __meta__ stats sentinel."""
try:
body: dict = {"model": model, "messages": messages, "stream": True}
body["think"] = think # see chat(): reasoning off by default for speed
opts = _chat_options(temperature, num_gpu)
if opts:
body["options"] = opts
self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"POST",
f"{self._api_base}/api/chat",
json=body,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
token = data.get("message", {}).get("content", "")
if token:
yield token
if data.get("done", False):
elapsed = time.perf_counter() - start
_log.info("chat stream completed model=%s elapsed=%.3fs", model, elapsed)
eval_count = data.get("eval_count", 0)
eval_ns = data.get("eval_duration", 0)
tokens_per_s = round(eval_count / (eval_ns / 1e9), 1) if eval_ns else 0
stats = json.dumps({
"model": model,
"tokens": eval_count,
"elapsed_s": round(elapsed, 2),
"tokens_per_s": tokens_per_s,
})
yield f"__meta__{stats}"
break
except Exception:
continue
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat stream error after %.3fs: %s", elapsed, e)
raise
def initialize_ollama() -> OllamaManager:
global _ollama_manager
if _ollama_manager is None:
manager = OllamaManager()
if not manager.is_running():
manager.start()
if not manager.is_running():
raise RuntimeError("Ollama API is not reachable after start().")
_ollama_manager = manager
return _ollama_manager
async def initialize_ollama_async() -> OllamaManager:
"""Async-safe initializer — uses asyncio.sleep so the event loop stays live."""
global _ollama_manager
if _ollama_manager is None:
manager = OllamaManager()
if not manager.is_running():
await manager.start_async()
if not manager.is_running():
raise RuntimeError("Ollama API is not reachable after start().")
_ollama_manager = manager
return _ollama_manager
def get_ollama_manager() -> OllamaManager:
global _ollama_manager
if _ollama_manager is None:
_ollama_manager = OllamaManager()
return _ollama_manager
def shutdown_ollama() -> None:
global _ollama_manager
if _ollama_manager is not None:
_ollama_manager.stop()
_ollama_manager = None
+31
View File
@@ -0,0 +1,31 @@
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()
@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 []
@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
View File
+95
View File
@@ -0,0 +1,95 @@
from pathlib import Path
from typing import List, Optional
import yaml
from pydantic import BaseModel
class _BlockDumper(yaml.Dumper):
pass
_BlockDumper.add_representer(
str,
lambda dumper, data: dumper.represent_scalar(
"tag:yaml.org,2002:str", data, style="|" if "\n" in data else None
),
)
class PlaybookItem(BaseModel):
id: str
title: str
goal: str
instructions: str
tags: List[str] = []
order: int = 0
class PlaybookFileStore:
def __init__(self, directory: Path):
self.directory = directory
directory.mkdir(parents=True, exist_ok=True)
def _path(self, id: str) -> Path:
return self.directory / f"{id}.yaml"
def _read(self, path: Path) -> Optional[PlaybookItem]:
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return PlaybookItem(
id=data["id"],
title=data["title"],
goal=data.get("goal", ""),
instructions=data.get("instructions", ""),
tags=data.get("tags", []),
order=data.get("order", 0),
)
except Exception:
return None
@staticmethod
def _clean(s: str) -> str:
return "\n".join(line.rstrip() for line in s.split("\n")).strip()
def _write(self, item: PlaybookItem):
data = {
"id": item.id,
"title": item.title,
"goal": item.goal,
"tags": item.tags,
"order": item.order,
"instructions": self._clean(item.instructions),
}
with open(self._path(item.id), "w", encoding="utf-8") as f:
yaml.dump(data, f, Dumper=_BlockDumper, allow_unicode=True,
default_flow_style=False, sort_keys=False, width=4096)
def all_playbooks(self) -> List[PlaybookItem]:
items = [self._read(p) for p in self.directory.glob("*.yaml")]
return sorted((i for i in items if i), key=lambda x: x.order)
def get_playbook(self, id: str) -> Optional[PlaybookItem]:
return self._read(self._path(id))
def add_playbook(self, item: PlaybookItem):
self._write(item)
def delete_playbook(self, id: str):
p = self._path(id)
if p.exists():
p.unlink()
def reorder_playbooks(self, ordered_ids: List[str]):
all_ids = {p.stem for p in self.directory.glob("*.yaml")}
valid = [pid for pid in ordered_ids if pid in all_ids]
missing = sorted(all_ids - set(valid))
for index, pid in enumerate(valid + missing):
item = self.get_playbook(pid)
if item:
item.order = index
self._write(item)
from ..nexus_config import PLAYBOOK_DIR
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import re
from typing import Optional
_SEARCH_TRIGGERS = frozenset({
# Time-sensitive single words
"news", "weather", "today", "tonight", "yesterday", "price", "stock",
# Phrases that imply freshness or external lookup
"latest ", "right now", "this week", "this month", "recently",
"who is ", "what is ", "when did ", "how much does", "how much is",
"look up", "search for", "find out", "release date",
"just released", "just announced", "just launched",
"current version", "current price",
})
def needs_web_search(message: str) -> bool:
lower = message.lower()
return any(kw in lower for kw in _SEARCH_TRIGGERS)
def web_search(query: str, max_results: int = 4) -> str:
"""Search DuckDuckGo and return formatted result snippets.
Returns an empty string on any failure so callers can treat it as
optional context — a failed search should never break a chat response.
"""
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return ""
parts = []
for i, r in enumerate(results, 1):
title = r.get("title", "").strip()
body = r.get("body", "").strip()
href = r.get("href", "").strip()
parts.append(f"{i}. **{title}**\n{body}\nSource: {href}")
return "\n\n".join(parts)
except Exception:
return ""