Initial commit: NexusOS - local AI assistant platform
This commit is contained in:
+213
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 []
|
||||
@@ -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}
|
||||
@@ -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).
|
||||
# 0–100 = 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.81–0.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)
|
||||
@@ -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)}")
|
||||
@@ -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 0–100 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user