refactor(synapse): backend updates, add icons module, relocate playbooks to data/playbooks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-11 07:25:08 -05:00
parent 3ec57f9140
commit f4f77c5196
24 changed files with 1820 additions and 288 deletions
@@ -17,5 +17,6 @@ instructions: |-
Rules: Rules:
- Never refer to yourself as an AI or language model - Never refer to yourself as an AI or language model
- Never start a response with "Certainly!", "Of course!", or similar filler phrases - Never start a response with "Certainly!", "Of course!", or similar filler phrases
- Never restate, echo, rephrase, or summarize Jon's own message back to him. Do NOT open with a header or a recap of what he just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
- Keep responses concise unless Jon asks for detail - Keep responses concise unless Jon asks for detail
- If you don't know something, say so plainly and help find the answer - If you don't know something, say so plainly and help find the answer
+33
View File
@@ -0,0 +1,33 @@
id: 10aef5da-f100-4148-afdc-fe539398818a
title: Ford Mechanic
goal: Act as an experienced Ford mechanic who knows Jon's Ranger and gives straight, practical answers like a friend in the shop.
tags:
- ford
- ranger
- truck
- automotive
- repair
- diagnostics
- maintenance
- troubleshooting
- engine
- vulcan
order: 1
instructions: |-
Your personality:
- Warm, casual, and conversational — you know Jon and his truck well, treat him like a friend not a customer
- Confident and direct — give real answers, not hedged service-advisor speak
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Unless Jon says otherwise, assume he's asking about his 2000 Ford Ranger XLT 3.0 Vulcan V6 Flex 5-speed manual
- Get straight to likely causes and what to do — skip the preamble
- Give specific components, torque specs, and part numbers where relevant
- When multiple causes are possible, rank by likelihood and say which you'd chase first
- Flag special tools when a job needs them
- Reference TSBs or known Ranger-specific failure patterns when applicable (intake manifold gaskets, EGR, clutch hydraulics, etc.)
- Assume Jon is mechanically capable — don't over-explain unless he asks
Rules:
- If you don't know something, say so plainly and help find the answer
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
+27
View File
@@ -0,0 +1,27 @@
id: 13c9bf41-61e8-4986-b6aa-da7ed3cee04b
title: Claude Relay
goal: Know when to escalate to Claude for questions that are beyond your knowledge.
tags:
- claude
- relay
- escalation
- routing
order: 8
instructions: |-
Your responsibilities:
- You have access to Claude as a more capable backup for questions you cannot answer
- Use this escalation rarely and honestly — only when you truly cannot help
When to escalate:
- The question requires real-time information or internet access (current news, live prices, today's weather, recent events)
- The question is outside your training data or past your knowledge cutoff
- You are genuinely uncertain about a factual answer and fabricating would be harmful
How to escalate:
- Respond with exactly this phrase, and nothing else: Let me ask Claude.
- Do not apologize, do not explain, do not add punctuation or extra text
- Do not use this as a shortcut for questions you can answer with reasonable confidence
When not to escalate:
- General knowledge, reasoning, writing, coding, or advice you can handle yourself
- Questions where a best-effort answer is useful even if imperfect
+55
View File
@@ -0,0 +1,55 @@
id: 45748398-04f8-4753-8fca-e38a4345975d
title: NexusOS Developer
goal: Act as a senior engineer who knows the NexusOS codebase inside and out, helping Jon reason through changes, debug behavior, and plan features without needing to re-explain the architecture.
tags:
- nexusos
- python
- fastapi
- react
- ollama
- sqlite
- development
order: 4
instructions: |-
Your personality:
- Warm, casual, and conversational — you know this codebase and Jon built it, treat him like a fellow engineer not a student
- Confident and direct — give real answers grounded in how the system actually works
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Answer questions about NexusOS with full awareness of its architecture — don't give generic FastAPI/React advice when the specific implementation matters
- Help Jon reason through feature design, debug behavior, and plan changes before writing code
- When something could break another part of the system, flag it — the pieces are tightly coupled in places
- Keep in mind that you cannot read the current state of files; your knowledge reflects the architecture as described here
Architecture overview:
- Synapse backend: FastAPI app at synapse/main.py, port 8000. Handles chat, playbooks, memory CRUD, models, conversations, and settings
- Memory service: separate FastAPI app at synapse/memory/service.py, port 8001. Runs an Ollama-powered extractor that decides whether to persist facts from each exchange
- Frontend: React 19 + Vite at interface/web/. No router — App.jsx manages page state with a single currentPage useState. All API calls hit localhost:8000
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, settings. Playbooks are YAML files, not SQLite
- Playbooks: stored as UUID-named YAML files in synapse/playbooks/. PlaybookFileStore owns reads/writes. order=0 is the active system prompt; higher order values are injected as reference context
System prompt assembly (chat/stream endpoint):
- Layer 1: active playbook (order=0) instructions → becomes the base system prompt
- Layer 2: all other playbooks injected as "Reference playbooks" block below layer 1
- Layer 3: persistent memory facts from store.all(), rendered as grouped ## Section / bullet markdown
- Layer 4: up to 2 past conversation matches from store.search_conversations(), injected as "Relevant past exchanges"
- Model selection: uses stored settings model if set; otherwise auto-selects by intent (code vs chat keywords) preferring qwen2.5:3b → gemma3:1b for GPU-constrained Vega12 (3.5GB available VRAM)
Key files:
- synapse/main.py — all API routes, system prompt assembly, MindTrace logging, streaming SSE logic
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access for memory, conversations, messages, settings
- synapse/memory/service.py — memory extraction microservice (port 8001)
- synapse/memory/extractor.py — Ollama prompt that decides whether a conversation exchange yields a persistent fact
- synapse/playbooks/store.py — PlaybookFileStore: YAML read/write, ordering, search
- synapse/playbook_manager.py — thin wrapper used by main.py to get active/reference playbooks
- synapse/ollama_manager.py — Ollama lifecycle, GPU detection, model selection
- synapse/nexus_config.py — all filesystem paths and the Settings class
- interface/web/src/App.jsx — top-level page state and navigation
- interface/web/src/Chatbot.jsx — main chat UI, SSE streaming, conversation management
Rules:
- If you don't know something or it may have changed since this playbook was written, say so plainly
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
- Don't suggest generic solutions when a NexusOS-specific pattern already exists — point Jon to the right place in the codebase
+29
View File
@@ -0,0 +1,29 @@
id: 4b1c79e2-fbab-4444-aa02-294c26240b1b
title: Research Assistant
goal: Help Jon cut through noise and get to the answer — summarize, compare, source, and synthesize information quickly without padding.
tags:
- research
- summaries
- comparison
- news
- products
- general
order: 6
instructions: |-
Your personality:
- Warm, casual, and conversational — cut to what matters, don't perform thoroughness
- Confident and direct — give a clear bottom line, then support it; don't bury the lead
- Occasionally witty, but never at the expense of being useful
Your responsibilities:
- Lead with the answer or recommendation, follow with the reasoning — not the other way around
- When comparing options, pick a winner and say why rather than presenting a neutral list and leaving Jon to decide
- Summarize long material tightly — capture the key insight, not just the structure
- When sources matter, name them; when they don't, don't pad with citations
- Flag when something is contested, outdated, or when your knowledge cutoff is relevant
- If a question needs a web search to answer well, say so plainly rather than improvising from memory
Rules:
- Don't pad responses with background Jon didn't ask for
- If you don't know something or it's past your knowledge cutoff, say so plainly and help find the answer
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
+33
View File
@@ -0,0 +1,33 @@
id: 75216a8a-9f6e-4abb-bfd0-f3dca78a0849
title: Home Network
goal: Act as a knowledgeable network engineer who knows Jon's home setup and gives straight, practical answers like a friend who actually knows their way around a router.
tags:
- networking
- router
- asus
- merlin
- wifi
- linux
- dns
- firewall
- jffs
order: 5
instructions: |-
Your personality:
- Warm, casual, and conversational — you know Jon's network setup well, treat him like a friend not a ticket
- Confident and direct — give real answers, not vendor-support hedging
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Unless Jon says otherwise, assume his router is an ASUS running Merlin firmware with JFFS scripting enabled
- His primary client machine is a MacBook Pro (T2, AMD GPU) running Linux Mint 22 XFCE; he uses nmcli for network management
- Get straight to the likely cause and what to do — skip generic "have you tried turning it off and on again" advice
- Give specific commands, config file paths, and iptables/nftables rules where relevant
- Flag when a change requires a router reboot or service restart to take effect
- Know common Merlin-specific patterns: JFFS scripts (nat-start, firewall-start, services-start), Entware, custom DNS, OpenVPN/WireGuard, traffic monitoring
- When diagnosing connectivity issues, suggest the right layer to check first rather than running through the whole OSI stack
- Assume Jon is comfortable in a terminal — don't over-explain unless he asks
Rules:
- If you don't know something, say so plainly and help find the answer
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
+31
View File
@@ -0,0 +1,31 @@
id: 9a7d5b06-23e0-40f5-b075-1a675fd6edc2
title: Coding Assistant
goal: Act as a senior engineer who gives Jon complete, ready-to-run code and straight answers like a knowledgeable friend, not a docs page.
tags:
- coding
- programming
- linux
- bash
- scripting
- debugging
- development
- shell
order: 3
instructions: |-
Your personality:
- Warm, casual, and conversational — you know Jon's setup well, treat him like a friend not a student
- Confident and direct — give real answers, not hedged corporate-speak
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Give complete, copy-paste-ready code rather than partial snippets with placeholders
- When multiple approaches exist, briefly name the tradeoffs and just recommend one
- Don't pad responses with basics Jon already knows — get to the substance
- Write shell scripts with solid practices: error handling, clear variable names, comments on non-obvious logic
- Flag destructive or irreversible operations clearly
- Bake assumptions (paths, distro behavior, tool availability) inline rather than stopping to ask
- Primary environment is Linux Mint 22 XFCE on a MacBookPro15,3; common tools include bash, nmcli, mksquashfs, xorriso, and ASUS router JFFS scripting
Rules:
- If you don't know something, say so plainly and help find the answer
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
+31
View File
@@ -0,0 +1,31 @@
id: c9893106-645f-4e30-957d-70f24652c9f3
title: Honda Mechanic
goal: Act as an experienced Honda mechanic who knows Jon's cars and gives straight, practical answers like a friend in the shop.
tags:
- honda
- accord
- civic
- automotive
- repair
- diagnostics
- maintenance
- troubleshooting
order: 2
instructions: |-
Your personality:
- Warm, casual, and conversational — you know Jon and his cars well, treat him like a friend not a customer
- Confident and direct — give real answers, not hedged service-advisor speak
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Jon owns a 2006 Honda Accord LX 5-speed manual and a 2009 Honda Civic EX-L automatic — if context makes clear which he means, assume it; if not, ask before diving in
- Get straight to likely causes and what to do — skip the preamble
- Give specific components, torque specs, and part numbers where relevant
- When multiple causes are possible, rank by likelihood and say which you'd chase first
- Flag Honda-specific tools or procedures when relevant (HDS, fluid flush sequences, etc.)
- Reference TSBs or known failure patterns for these generations (K24 oil consumption, VTEC solenoid, Civic automatic transmission behavior, etc.)
- Assume Jon is mechanically capable — don't over-explain unless he asks
Rules:
- If you don't know something, say so plainly and help find the answer
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
+29
View File
@@ -0,0 +1,29 @@
id: f886a94f-b768-49c0-904c-f0f8b556d079
title: Writing Assistant
goal: Help Jon write clearly and in his own voice — emails, messages, documentation, anything prose — without over-formalizing or padding.
tags:
- writing
- editing
- email
- communication
- documentation
- proofreading
order: 7
instructions: |-
Your personality:
- Match Jon's register — casual and direct by default, more formal only when the context calls for it
- Never add corporate warmth, filler phrases, or hedging that Jon wouldn't use himself
- Occasionally witty when appropriate, but don't force it
Your responsibilities:
- When editing, preserve Jon's voice — fix clarity and correctness, don't rewrite his personality out of it
- When drafting from scratch, ask for the audience and intent if it's not clear; otherwise just write something and let him redirect
- Flag when something reads as too formal, too casual, or likely to land wrong for its audience
- Keep it tight — cut filler, passive constructions, and redundancy unless Jon's going for a specific effect
- For emails: lead with the point, put context after, end without hollow sign-off phrases unless the situation requires them
- For documentation: favor short sentences, concrete examples, and active voice over comprehensive coverage
Rules:
- Don't add exclamation points, emoji, or enthusiasm Jon didn't put there
- If the ask is ambiguous, make a reasonable call and note the assumption rather than asking a bunch of clarifying questions
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
Regular → Executable
View File
Regular → Executable
+10 -40
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json as _json
import logging import logging
import threading import threading
from typing import AsyncGenerator, Dict, List, Optional, Any from typing import AsyncGenerator, Dict, List, Optional, Any
@@ -58,6 +59,7 @@ async def generate_chat_response(
system = metadata.get("system", "") system = metadata.get("system", "")
model = metadata.get("model") or "mistral" model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature") temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu")
messages: List[Dict[str, str]] = [] messages: List[Dict[str, str]] = []
if system: if system:
@@ -76,10 +78,11 @@ async def generate_chat_response(
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
manager.chat(messages=messages, model=model, stream=False, temperature=temperature), manager.chat(messages=messages, model=model, stream=False, temperature=temperature, num_gpu=num_gpu),
timeout=timeout, timeout=timeout,
) )
response_text = result if isinstance(result, str) else str(result) response_text = result if isinstance(result, str) else str(result)
preview = response_text[:500].replace("\n", " ") preview = response_text[:500].replace("\n", " ")
_synapse_trace(f"{preview}{'' if len(response_text) > 500 else ''}\n{'' * 50}\n") _synapse_trace(f"{preview}{'' if len(response_text) > 500 else ''}\n{'' * 50}\n")
_logger.info("generate_chat_response: completed model=%s", model) _logger.info("generate_chat_response: completed model=%s", model)
@@ -115,30 +118,12 @@ async def _aiter_with_timeout(aiterable, timeout: Optional[float]):
# Normalizer for many return shapes # Normalizer for many return shapes
# ------------------------- # -------------------------
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]: async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
if hasattr(maybe_iterable, "__aiter__"): # The sole caller passes manager.chat(stream=True) — an async-def call, i.e.
async for item in maybe_iterable: # a coroutine that resolves to an async generator. Await it if needed, then
yield str(item) # stream the tokens.
return result = await maybe_iterable if asyncio.iscoroutine(maybe_iterable) else maybe_iterable
if asyncio.iscoroutine(maybe_iterable):
result = await maybe_iterable
if hasattr(result, "__aiter__"):
async for item in result: async for item in result:
yield str(item) yield str(item)
return
if hasattr(result, "__iter__") and not isinstance(result, (str, bytes)):
for item in result:
yield str(item)
return
yield str(result)
return
if hasattr(maybe_iterable, "__iter__") and not isinstance(maybe_iterable, (str, bytes)):
for item in maybe_iterable:
yield str(item)
return
yield str(maybe_iterable)
# ------------------------- # -------------------------
@@ -157,6 +142,7 @@ async def stream_chat_response(
system = metadata.get("system", "") system = metadata.get("system", "")
model = metadata.get("model") or "mistral" model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature") temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu")
# Build messages array for /api/chat multi-turn format # Build messages array for /api/chat multi-turn format
messages: List[Dict[str, str]] = [] messages: List[Dict[str, str]] = []
@@ -175,7 +161,7 @@ async def stream_chat_response(
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n") _synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try: try:
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature) maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu)
async_gen = _normalize_to_async_generator(maybe_iter) async_gen = _normalize_to_async_generator(maybe_iter)
buffer_parts: list[str] = [] buffer_parts: list[str] = []
@@ -224,19 +210,3 @@ async def stream_chat_response(
except Exception: except Exception:
_logger.exception("stream_chat_response: unexpected error during streaming") _logger.exception("stream_chat_response: unexpected error during streaming")
raise raise
# -------------------------
# Synchronous convenience wrappers
# -------------------------
def generate_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
return asyncio.run(generate_chat_response(user_message, metadata=metadata, timeout=timeout))
def stream_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None):
async def _collect():
chunks = []
async for c in stream_chat_response(user_message, metadata=metadata, timeout=timeout):
chunks.append(c)
return chunks
return asyncio.run(_collect())
View File
+244
View File
@@ -0,0 +1,244 @@
"""Composite app icons onto the NexusOS nexus underlay tile."""
from pathlib import Path
import subprocess
import tempfile
import os
import re
_ROOT = Path(__file__).resolve().parents[2]
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
SIZES = [16, 22, 24, 32, 48, 64, 128]
_ALLOWED_ROOTS = [
"/usr/share/icons",
"/usr/share/pixmaps",
"/usr/local/share/icons",
"/opt",
str(Path.home() / ".local/share/icons"),
str(Path.home() / ".icons"),
str(_ROOT / "assets"),
]
_UNDERLAY_FALLBACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#frame)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="#120018"/>
<rect x="16" y="16" width="96" height="96" rx="18" fill="#262626"/>
</svg>
"""
_RING_FALLBACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="ring" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#8cc63f"/>
<stop offset="100%" stop-color="#5a9020"/>
</radialGradient>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#ring)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#frame)"/>
<rect x="12" y="12" width="104" height="104" rx="22" fill="#120018"/>
<rect x="18" y="18" width="92" height="92" rx="16" fill="#262626"/>
</svg>
"""
def _load_underlay(nexus_ring: bool = False) -> str:
"""Return SVG text for the underlay, falling back to inline if file missing."""
target = RING_FILE if nexus_ring else UNDERLAY_FILE
fallback = _RING_FALLBACK if nexus_ring else _UNDERLAY_FALLBACK
if target.exists():
return target.read_text()
return fallback
def _inkscape_render(svg_path: str, out_path: str, size: int) -> None:
subprocess.run(
[
"inkscape", svg_path,
"--export-type=png",
f"--export-filename={out_path}",
f"--export-width={size}",
f"--export-height={size}",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _composite_png(underlay_png: str, app_png: str, out_path: str, frac: float, size: int) -> None:
"""Composite app icon centred on the underlay at given fractional size."""
app_size = max(1, int(size * frac))
offset = (size - app_size) // 2
subprocess.run(
[
"convert",
underlay_png,
"(", app_png, "-resize", f"{app_size}x{app_size}", ")",
"-gravity", "Center",
"-geometry", f"+0+0",
"-composite",
out_path,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _is_allowed_path(path: str) -> bool:
p = Path(os.path.realpath(path))
return p.is_file() and any(
p == root or root in p.parents
for root in (Path(r).resolve() for r in _ALLOWED_ROOTS)
)
def _safe_component(value: str, label: str) -> str:
if not value or not re.fullmatch(r"[A-Za-z0-9._-]+", value):
raise ValueError(f"Invalid icon {label}")
return value
def brand_icon(
src_path: str,
output_name: str,
frac: float = 0.60,
round_mask: bool = False,
nexus_ring: bool = False,
reload: bool = True,
category: str = "apps",
) -> None:
"""Brand a single icon and write PNGs to the NexusOS icon theme at all sizes."""
if not _is_allowed_path(src_path):
raise ValueError(f"Icon source path not in allowed roots: {src_path}")
output_name = _safe_component(output_name, "name")
category = _safe_component(category, "category")
underlay_svg_text = _load_underlay(nexus_ring)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
underlay_svg = tmp_path / "underlay.svg"
underlay_svg.write_text(underlay_svg_text)
for size in SIZES:
out_dir = ICONS_OUT / f"{size}x{size}" / category
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{output_name}.png"
# Break symlinks before writing; also remove any same-name .svg so
# GTK doesn't prefer the old SVG over our new branded PNG.
if out_path.is_symlink() or out_path.exists():
out_path.unlink()
svg_path = out_dir / f"{output_name}.svg"
if svg_path.exists() or svg_path.is_symlink():
svg_path.unlink()
underlay_png = str(tmp_path / f"underlay_{size}.png")
_inkscape_render(str(underlay_svg), underlay_png, size)
# Resize app icon source to a temp PNG for compositing.
# `-background none` is REQUIRED: ImageMagick rasterizes transparent
# SVGs onto an opaque WHITE canvas by default, which shows up as a
# white plate/border behind logos with transparent corners
# (e.g. VSCode, Edge). It must precede the input to affect the SVG.
app_png = str(tmp_path / f"app_{size}.png")
app_size = max(1, int(size * frac))
subprocess.run(
["convert", "-background", "none", src_path,
"-resize", f"{app_size}x{app_size}", app_png],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
[
"convert", underlay_png,
"(", app_png, ")",
"-gravity", "Center",
"-composite",
str(out_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if reload:
apply_icon_cache()
def apply_icon_cache() -> None:
"""Rebuild the GTK icon cache and reload the panel."""
import pwd
uid = os.getuid()
# systemd user session bus — needed for xfce4-panel -r to reach the running session
dbus_addr = os.environ.get(
"DBUS_SESSION_BUS_ADDRESS",
f"unix:path=/run/user/{uid}/bus",
)
_env = {
**os.environ,
"DISPLAY": os.environ.get("DISPLAY", ":0"),
"DBUS_SESSION_BUS_ADDRESS": dbus_addr,
"HOME": pwd.getpwuid(uid).pw_dir,
}
subprocess.run(
["gtk-update-icon-cache", "-f", str(ICONS_OUT)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Force every running GTK app (whisker menu, panel, Thunar) to drop its
# in-memory icon cache. A panel reload alone does NOT do this — GTK only
# re-resolves icons when the icon-theme NAME changes. Toggling to another
# theme and back fires the "theme-changed" signal that triggers the reload.
import time
current = "NexusOS"
try:
out = subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName"],
env=_env, capture_output=True, text=True,
)
if out.stdout.strip():
current = out.stdout.strip()
except Exception:
pass
alt = "Papirus-Dark" if current != "Papirus-Dark" else "Adwaita"
subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", alt],
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(0.5)
subprocess.run(
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", current],
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
# Restart Plank so it re-resolves icons from the updated theme
subprocess.run(["pkill", "plank"], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
["plank"],
env=_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
+140
View File
@@ -0,0 +1,140 @@
"""Resolve desktop app icon paths and scan installed applications."""
from pathlib import Path
from typing import Optional
import configparser
_HOME = Path.home()
ICON_SEARCH_DIRS = [
_HOME / ".local/share/icons",
Path("/usr/share/icons"),
Path("/usr/share/pixmaps"),
Path("/usr/local/share/icons"),
]
_THEME_ORDER = ["hicolor", "Papirus-Dark", "Papirus", "gnome", "Adwaita", "breeze"]
_ICON_EXTS = [".png", ".svg", ".xpm"]
DESKTOP_DIRS = [
_HOME / ".local/share/applications",
Path("/usr/local/share/applications"),
Path("/usr/share/applications"),
]
_NEXUS_ICONS = _HOME / ".icons" / "NexusOS"
def _icon_stem(icon_name: str) -> str:
"""Return the icon's name for theme lookup.
Only strips a real image extension (.png/.svg/.xpm). Reverse-DNS icon
names like ``com.visualstudio.code`` or ``org.gnome.Files`` must be kept
intact — Path.stem would wrongly treat the final dotted segment as an
extension and truncate it.
"""
p = Path(icon_name)
if p.suffix.lower() in _ICON_EXTS:
return p.stem
return p.name
def resolve_icon(icon_name: str, size: int = 128) -> Optional[str]:
"""Return absolute path to an icon file, or None if not found."""
if not icon_name:
return None
# Absolute path — use directly if it exists
p = Path(icon_name)
if p.is_absolute() and p.exists():
return str(p)
# Strip extension for name-based search
stem = _icon_stem(icon_name)
size_dirs = [f"{size}x{size}", f"{size}x{size}@2x", "scalable"]
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
for theme in _THEME_ORDER:
theme_dir = search_root / theme
if not theme_dir.is_dir():
continue
for size_dir in size_dirs:
for ctx in ("apps", "categories", "places", "status", "actions"):
ctx_dir = theme_dir / size_dir / ctx
if not ctx_dir.is_dir():
continue
for ext in _ICON_EXTS:
candidate = ctx_dir / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
# pixmaps fallback
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
if search_root.name == "pixmaps":
for ext in _ICON_EXTS:
candidate = search_root / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
for ext in _ICON_EXTS:
candidate = Path("/usr/share/pixmaps") / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
return None
def _is_branded(icon_name: str) -> bool:
"""Return True if a NexusOS-branded PNG already exists for this icon name."""
stem = _icon_stem(icon_name)
return (_NEXUS_ICONS / "128x128" / "apps" / f"{stem}.png").exists()
def scan_apps() -> list[dict]:
"""Return list of installed apps with resolved icon paths."""
seen: dict[str, dict] = {}
for desktop_dir in DESKTOP_DIRS:
if not desktop_dir.is_dir():
continue
for desktop_file in sorted(desktop_dir.glob("*.desktop")):
cfg = configparser.ConfigParser(interpolation=None, strict=False)
try:
cfg.read(str(desktop_file), encoding="utf-8")
except Exception:
continue
if not cfg.has_section("Desktop Entry"):
continue
de = cfg["Desktop Entry"]
if de.get("NoDisplay", "false").lower() == "true":
continue
if de.get("Hidden", "false").lower() == "true":
continue
if de.get("Type", "") != "Application":
continue
name = de.get("Name", desktop_file.stem)
icon_name = de.get("Icon", "")
if not icon_name:
continue
icon_path = resolve_icon(icon_name)
already_branded = _is_branded(icon_name)
key = name.lower()
if key not in seen:
seen[key] = {
"name": name,
"icon_name": _icon_stem(icon_name),
"icon_path": icon_path,
"already_branded": already_branded,
"desktop_file": str(desktop_file),
}
return sorted(seen.values(), key=lambda x: x["name"].lower())
Regular → Executable
+411 -111
View File
@@ -4,46 +4,169 @@ from __future__ import annotations
import asyncio as _asyncio import asyncio as _asyncio
import json as _json import json as _json
import uuid as _uuid import uuid as _uuid
from collections import Counter as _Counter
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
from uuid import UUID from uuid import UUID
import httpx import httpx
import os as _os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Body from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse, FileResponse
from .nexus_config import settings from .nexus_config import settings, VERSION
from .chat import generate_chat_response, stream_chat_response from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from .playbook_manager import PlaybookManager from .playbook_manager import PlaybookManager
def _render_memory_block(facts) -> str: def _render_memory_block(facts) -> str:
"""Render memory items as grouped ## Section / - bullet markdown.""" """Render memory items as grouped ## Section / - bullet markdown.
Leading-space indent on a fact's text is preserved so nested bullets stay nested.
Behavioural "Instructions" entries are skipped — they belong in the playbook,
not the "what you know about Jon" facts block. Injecting imperative directives
("provide full rewrites", "include file paths") as facts pushes a small model
to reformat/organise the user's input instead of conversing."""
from collections import defaultdict from collections import defaultdict
sections: dict = defaultdict(list) sections: dict = defaultdict(list)
for item in facts: for item in facts:
if (item.section or "").strip().lower() == "instructions":
continue
sections[item.section or "General"].append(item.text) sections[item.section or "General"].append(item.text)
parts = [] parts = []
for section, lines in sections.items(): for section, lines in sections.items():
parts.append(f"## {section}\n" + "\n".join(f" - {line}" for line in lines)) rendered = []
for line in lines:
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
rendered.append(" " * indent + f" - {stripped}")
parts.append(f"## {section}\n" + "\n".join(rendered))
return "\n\n".join(parts) return "\n\n".join(parts)
async def _auto_select_model() -> str: # Preamble wrapped around the injected memory facts. It stays anti-recite for
# everyday chat, but explicitly permits surfacing facts when Jon asks about
# himself / to be quizzed — the old absolute "do NOT acknowledge these facts"
# made small models play dumb on exactly that request.
_MEMORY_PREAMBLE = (
"\n\n---\nBackground on Jon — use this to personalize your replies. Don't dump "
"or recite these facts unprompted, but when Jon asks about himself or asks you "
"to recall or quiz what you know, use them directly and specifically:\n\n"
)
_CODING_KEYWORDS = frozenset({
"code", "coding", "function", "class", "method", "variable", "bug", "error",
"debug", "fix", "refactor", "script", "program", "syntax", "compile", "import",
"module", "library", "algorithm", "loop", "array", "string", "integer", "boolean",
"return", "def", "const", "let", "var", "test", "api", "endpoint", "database",
"query", "sql", "bash", "terminal", "command", "package", "dependency",
"python", "javascript", "typescript", "rust", "golang", "java", "html", "css",
".py", ".js", ".ts", ".jsx", ".tsx", ".sh", ".json", ".yaml", ".sql", ".css",
})
def _detect_intent(message: str) -> str:
lower = message.lower()
return "code" if any(kw in lower for kw in _CODING_KEYWORDS) else "chat"
import re as _re
def _route_playbooks(message: str, candidates: list) -> list:
"""Return the reference playbook(s) whose tags appear directly in the user's
message. Returns [] when nothing matches, so casual chat doesn't drag in a
specialist playbook.
Dropped an old memory-fallback tier that, when the message matched no tags,
scored playbook tags against the user's WHOLE memory corpus. Because memory
permanently mentions e.g. the home network, that injected the Home Network
playbook (~1.7k chars) into unrelated chats. Direct references like "my truck"
already match here ('truck' is a Ford tag), so the fallback was mostly noise.
"""
if not candidates or not message:
return candidates
msg_tokens = set(_re.findall(r'\b\w+\b', message.lower()))
scores = [(sum(1 for tag in pb.tags if tag.lower() in msg_tokens), pb) for pb in candidates]
best = max(s for s, _ in scores)
if best > 0:
return [pb for s, pb in scores if s == best]
return []
async def _auto_select_model(message: str = "") -> str:
"""A pinned settings.model wins; otherwise pick the preferred installed model
for the detected intent. The preference lists live in one place now —
ollama_manager._MODEL_PREFERENCE, via select_best_model(intent)."""
try: try:
s = store.get_settings() s = store.get_settings()
if s.get("model"): if s.get("model"):
return s["model"] return s["model"]
return await get_ollama_manager().select_best_model() intent = _detect_intent(message) if message else "chat"
return await get_ollama_manager().select_best_model(intent)
except Exception: except Exception:
return getattr(settings, "default_model", None) or "mistral" return getattr(settings, "default_model", None) or "mistral"
_TITLE_SYSTEM_PROMPT = (
"You generate a short, descriptive title for a chat conversation based on the "
"user's first message. Reply with ONLY the title: 3 to 6 words, no quotes, no "
"trailing punctuation, no preamble. Use plain text in title case. The title "
"names the TOPIC — never echo the user's question or phrase it as a question.\n\n"
"Examples:\n"
"Message: can you help me fix a bug in my python script?\n"
"Title: Python Script Bug Fix\n"
"Message: what's a good recipe for sourdough bread?\n"
"Title: Sourdough Bread Recipe\n"
"Message: i want to try out your memory, ask me questions about myself\n"
"Title: Testing Memory Recall"
)
async def _generate_conversation_title(first_message: str, model: str) -> Optional[str]:
"""Ask the LLM for a concise title. Titling is a background 'curation' task,
so it runs on the CURATOR model (mistral) rather than the chat model: the 7B
follows the terse title format better than the 3B chat model, and it's already
warm in RAM. Crucially we pass the curator's own num_gpu (CPU) so we hit that
warm CPU-resident instance — same memory pool, no reload, and the GPU chat
model is never disturbed. `model` is only a fallback if no curator is set.
Best-effort: returns None on any failure so titling never breaks the chat."""
snippet = first_message.strip()[:1000]
if not snippet:
return None
try:
s = store.get_settings()
title_model = s.get("memory_model") or model
num_gpu = await get_ollama_manager().resolve_num_gpu(s.get("memory_gpu_offload", 0), title_model)
result = await generate_chat_response(
user_message=snippet,
metadata={"system": _TITLE_SYSTEM_PROMPT, "model": title_model,
"temperature": 0.2, "num_gpu": num_gpu},
timeout=30,
)
title = (result.get("response") or "").strip()
# Strip stray quotes/wrapping the model sometimes adds, collapse whitespace.
title = title.strip().strip('"').strip("'").splitlines()[0].strip()
# Drop a "Title:" prefix the model may echo from the examples, and strip
# trailing punctuation the prompt forbids but small models still add.
if title.lower().startswith("title:"):
title = title[len("title:"):].strip()
title = " ".join(title.split()).rstrip("?.!,;:")
if not title:
return None
return title[:120]
except Exception:
return None
from .memory.store import store, MemoryItem from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem from .playbooks.store import playbook_store, PlaybookItem
from .search import needs_web_search, web_search
MEMORY_SERVICE = "http://localhost:8001" MEMORY_SERVICE = "http://localhost:8001"
app = FastAPI(title="Synapse Backend", version="1.0") app = FastAPI(title="Synapse Backend", version=VERSION)
# Alias for startup scripts # Alias for startup scripts
sio_app = app sio_app = app
@@ -73,6 +196,10 @@ async def startup_event():
global ollama global ollama
try: try:
ollama = await initialize_ollama_async() ollama = await initialize_ollama_async()
# Keep the model resident per the persisted setting, then preload the
# model the first chat would pick so that message doesn't pay a cold load.
ollama.keep_alive = store.get_settings().get("keep_alive") or ollama.keep_alive
_asyncio.create_task(ollama.warm(await _auto_select_model()))
print("[Synapse] Ollama service is running.") print("[Synapse] Ollama service is running.")
except Exception as e: except Exception as e:
# Start in degraded mode — chat endpoints will return errors until Ollama # Start in degraded mode — chat endpoints will return errors until Ollama
@@ -92,44 +219,7 @@ async def root():
status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None
except Exception: except Exception:
status = None status = None
return {"status": "online", "ollama": status} return {"status": "online", "version": VERSION, "ollama": status}
# -------------------------
# Chat (non-streaming)
# -------------------------
@app.post("/chat")
async def chat_endpoint(payload: Dict[str, Any]):
try:
message = payload.get("message", "")
app_settings = store.get_settings()
model = payload.get("model") or await _auto_select_model()
context = payload.get("context", {})
history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature"))
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
rendered_message = playbooks.render_prompt(message)
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
memory_facts = store.all()
if memory_facts:
facts_block = _render_memory_block(memory_facts)
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
result = await generate_chat_response(
user_message=rendered_message, metadata=metadata, history=history
)
return {"response": result.get("response", ""), "model": model}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ------------------------- # -------------------------
@@ -140,20 +230,25 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
try: try:
message = payload.get("message", "") message = payload.get("message", "")
app_settings = store.get_settings() app_settings = store.get_settings()
model = payload.get("model") or await _auto_select_model() model = payload.get("model") or await _auto_select_model(message)
context = payload.get("context", {}) context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4()) conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", []) history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature")) temperature = payload.get("temperature", app_settings.get("temperature"))
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
if not message: if not message:
raise HTTPException(status_code=400, detail="Missing 'message'") raise HTTPException(status_code=400, detail="Missing 'message'")
rendered_message = playbooks.render_prompt(message) rendered_message = message # chat has no template vars; render_prompt is for the playbook path
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "") system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
# Append reference playbooks to the system prompt # Fetch memory facts once — used for both playbook routing and system prompt injection
context_pbs = playbooks.get_context_playbooks() memory_facts = store.all()
# Append the best-matching reference playbook(s) to the system prompt
context_pbs = _route_playbooks(rendered_message, playbooks.get_context_playbooks())
if context_pbs: if context_pbs:
refs = "\n\n".join( refs = "\n\n".join(
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}" f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
@@ -161,16 +256,17 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
) )
separator = "\n\n---\nReference playbooks (read these as additional context):\n\n" separator = "\n\n---\nReference playbooks (read these as additional context):\n\n"
system_prompt = (system_prompt + separator + refs) if system_prompt else refs system_prompt = (system_prompt + separator + refs) if system_prompt else refs
# Inject persistent memory facts about Jon
memory_facts = store.all()
if memory_facts: if memory_facts:
facts_block = _render_memory_block(memory_facts) facts_block = _render_memory_block(memory_facts)
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block system_prompt = (system_prompt + _MEMORY_PREAMBLE + facts_block) if system_prompt else facts_block
# Search past conversations for relevant context and inject the top matches. # Search past conversations for relevant context and inject the top matches.
# This gives the model memory of prior exchanges without requiring tool-calling support. # This gives the model memory of prior exchanges without requiring tool-calling support.
past_context = store.search_conversations(message, limit=2) # Semantic recall (embeddings) finds relevant exchanges even without shared
# keywords; it falls back to lexical substring match if embeddings are down.
past_context = await store.semantic_search_conversations(
message, get_ollama_manager().embed, limit=2
)
if past_context: if past_context:
snippets = [] snippets = []
for conv in past_context: for conv in past_context:
@@ -183,7 +279,65 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n" separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature} # Fetch web search results for time-sensitive queries
search_results = ""
if needs_web_search(message):
search_results = await _asyncio.to_thread(web_search, message)
if search_results:
separator = "\n\n---\nWeb search results (treat as current information):\n\n"
system_prompt = (system_prompt + separator + search_results) if system_prompt else search_results
# ── MindTrace pre-flight ──────────────────────────────────────────
_trace_intent = _detect_intent(message) if message else "chat"
if payload.get("model"):
_trace_src = "user-override"
elif store.get_settings().get("model"):
_trace_src = "settings"
else:
_trace_src = f"auto/{_trace_intent}"
_synapse_trace(f"\n{'' * 55}\n")
_synapse_trace(f"▶ MODEL : {model} [{_trace_src}]\n")
if _trace_intent == "code":
_kws = [kw for kw in _CODING_KEYWORDS if kw in message.lower()][:5]
_synapse_trace(f" INTENT: code → {', '.join(_kws)}\n")
else:
_synapse_trace(f" INTENT: chat\n")
_main_pb = playbooks.get_main_playbook()
if _main_pb:
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
if _main_pb.goal:
_synapse_trace(f" goal: {_main_pb.goal[:100]}\n")
else:
_synapse_trace(f" PLAYBOOK: none\n")
if context_pbs:
_synapse_trace(f" ROUTED : {', '.join(pb.title for pb in context_pbs)}\n")
else:
_synapse_trace(f" ROUTED : none (no tag match)\n")
if search_results:
_synapse_trace(f" SEARCH : {len(search_results)} chars injected\n")
elif needs_web_search(message):
_synapse_trace(f" SEARCH : triggered but returned no results\n")
if memory_facts:
_secs = _Counter(f.section or "General" for f in memory_facts)
_sec_str = " ".join(f"{s}({n})" for s, n in _secs.items())
_synapse_trace(f" MEMORY : {len(memory_facts)} facts [{_sec_str}]\n")
else:
_synapse_trace(f" MEMORY : none\n")
if past_context:
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
_synapse_trace(f"{'' * 55}\n")
# ── end MindTrace pre-flight ──────────────────────────────────────
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu}
# Persist conversation and user message before streaming # Persist conversation and user message before streaming
store.create_conversation(conversation_id) store.create_conversation(conversation_id)
@@ -192,6 +346,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
async def event_stream() -> AsyncGenerator[str, None]: async def event_stream() -> AsyncGenerator[str, None]:
response_chunks: list[str] = [] response_chunks: list[str] = []
meta: dict = {} meta: dict = {}
final_model = model
# ── Phase 1: stream primary model response ────────────────────
try: try:
async for chunk in stream_chat_response( async for chunk in stream_chat_response(
user_message=rendered_message, user_message=rendered_message,
@@ -206,22 +363,51 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
yield f"event: meta\ndata: {chunk[8:]}\n\n" yield f"event: meta\ndata: {chunk[8:]}\n\n"
continue continue
response_chunks.append(chunk) response_chunks.append(chunk)
yield f"data: {chunk}\n\n" yield f"data: {_json.dumps(chunk)}\n\n"
# Persist the completed assistant response with model and token stats except _asyncio.TimeoutError:
_tval = store.get_settings().get("timeout", 120)
yield f"event: error\ndata: {_json.dumps({'detail': f'Model timed out after {_tval}s — try a smaller/faster model'})}\n\n"
return
except Exception as e:
detail = str(e) or type(e).__name__
yield f"event: error\ndata: {_json.dumps({'detail': detail})}\n\n"
return
# ── Persist completed response ───────────────────────────────
if response_chunks: if response_chunks:
store.add_message( store.add_message(
conversation_id, "assistant", "".join(response_chunks), conversation_id, "assistant", "".join(response_chunks),
model=meta.get("model") or model, model=meta.get("model") or final_model,
tokens=meta.get("tokens"), tokens=meta.get("tokens"),
) )
except Exception as e:
yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n" # Response is complete — let the client re-enable its input now,
return # so the slow title/memory work below doesn't freeze the UI.
yield "event: done\ndata: {}\n\n"
# Generate an AI title from the opening message. Retried on any turn
# while still untitled, so an interrupted first stream can recover.
if response_chunks:
try:
conv = store.get_conversation(conversation_id)
if conv and not conv.title:
first_user = next(
(m.content for m in conv.messages if m.role == "user"),
rendered_message,
)
title = await _generate_conversation_title(
first_user, meta.get("model") or final_model
)
if title:
store.set_conversation_title(conversation_id, title)
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
except Exception:
pass
# Ask the memory service curator to evaluate this exchange # Ask the memory service curator to evaluate this exchange
if response_chunks: if response_chunks:
try: try:
async with httpx.AsyncClient(timeout=25.0) as _mc: async with httpx.AsyncClient(timeout=310.0) as _mc:
r = await _mc.post( r = await _mc.post(
f"{MEMORY_SERVICE}/memories/extract", f"{MEMORY_SERVICE}/memories/extract",
json={ json={
@@ -231,11 +417,11 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
) )
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
if data.get("saved"): for it in data.get("items", []):
mem_result = {"section": data["section"], "text": data["text"]} mem_result = {"section": it["section"], "text": it["text"]}
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n" yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
except Exception: except Exception as e:
pass _synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -244,24 +430,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Playbook Execution
# -------------------------
@app.post("/playbook/run")
async def run_playbook(payload: Dict[str, Any]):
name = payload.get("name")
variables = payload.get("variables", {})
if not name:
raise HTTPException(status_code=400, detail="Missing playbook name")
try:
result = playbooks.render_prompt(name, variables=variables)
return {"result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ------------------------- # -------------------------
# Settings # Settings
# ------------------------- # -------------------------
@@ -273,7 +441,10 @@ async def get_settings_endpoint():
@app.put("/settings") @app.put("/settings")
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)): async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
store.update_settings(payload) store.update_settings(payload)
return store.get_settings() merged = store.get_settings()
if "keep_alive" in payload:
get_ollama_manager().keep_alive = merged.get("keep_alive") or None
return merged
# ------------------------- # -------------------------
@@ -286,8 +457,8 @@ async def get_memory():
@app.post("/memory") @app.post("/memory")
async def add_memory(payload: Dict[str, Any] = Body(...)): async def add_memory(payload: Dict[str, Any] = Body(...)):
text = (payload.get("text") or "").strip() text = (payload.get("text") or "").rstrip()
if not text: if not text.strip():
raise HTTPException(status_code=400, detail="Missing 'text'") raise HTTPException(status_code=400, detail="Missing 'text'")
from .memory.store import MemoryItem from .memory.store import MemoryItem
import uuid as _mem_uuid import uuid as _mem_uuid
@@ -310,7 +481,7 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
updated = MemoryItem( updated = MemoryItem(
id=item_id, id=item_id,
section=(payload.get("section") or existing.section or "General").strip(), section=(payload.get("section") or existing.section or "General").strip(),
text=(payload.get("text") or existing.text).strip(), text=(payload.get("text") or existing.text).rstrip(),
tags=payload.get("tags", existing.tags), tags=payload.get("tags", existing.tags),
) )
store.update(updated) store.update(updated)
@@ -325,6 +496,16 @@ async def delete_memory(item_id: str):
return {"status": "deleted"} return {"status": "deleted"}
@app.post("/memory/reorder")
async def reorder_memory(payload: Dict[str, Any] = Body(...)):
section = (payload.get("section") or "General").strip() or "General"
ids = payload.get("ids")
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
raise HTTPException(status_code=400, detail="ids must be a list of strings")
ok = store.reorder_section(section, ids)
return {"ok": ok}
# ------------------------- # -------------------------
# Models # Models
# ------------------------- # -------------------------
@@ -332,8 +513,11 @@ async def delete_memory(item_id: str):
async def get_models(): async def get_models():
try: try:
mgr = get_ollama_manager() mgr = get_ollama_manager()
models = await mgr.list_models() # Embedding models (e.g. nomic-embed-text) can't chat — hide from picker.
selected = await mgr.select_best_model() models = [m for m in await mgr.list_models() if "embed" not in m.lower()]
# Report the SAME model the chat path would auto-pick (honors a pin),
# so the picker's "Auto (…)" label matches what actually answers.
selected = await _auto_select_model()
return {"models": models, "selected": selected} return {"models": models, "selected": selected}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -641,26 +825,6 @@ async def delete_playbook_endpoint(id: UUID):
# ------------------------- # -------------------------
# Unified Search # Unified Search
# ------------------------- # -------------------------
@app.get("/search")
async def search_endpoint(q: Optional[str] = None):
"""Search conversations and playbooks by keyword."""
if not q or not q.strip():
raise HTTPException(status_code=400, detail="Missing query parameter 'q'")
try:
conversations = store.search_conversations(q, limit=5)
playbook_hits = playbook_store.search_playbooks(q)
return {
"query": q,
"conversations": conversations,
"playbooks": [
{"id": p.id, "title": p.title, "goal": p.goal, "tags": p.tags}
for p in playbook_hits
],
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ------------------------- # -------------------------
# Conversations # Conversations
# ------------------------- # -------------------------
@@ -682,6 +846,7 @@ async def get_conversations(q: Optional[str] = None):
"timestamp": c.created_at, "timestamp": c.created_at,
"updated_at": c.updated_at, "updated_at": c.updated_at,
"preview": c.preview, "preview": c.preview,
"title": c.title,
} }
for c in conversations for c in conversations
] ]
@@ -690,6 +855,47 @@ async def get_conversations(q: Optional[str] = None):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/conversations/export")
async def export_conversations(min_turns: int = 1):
"""Export conversations as ShareGPT JSONL for fine-tuning.
Each line is one conversation:
{"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]}
Query params:
min_turns — minimum user/assistant exchanges to include (default 1)
"""
from fastapi.responses import Response
import datetime
conversations = store.all_conversations()
lines = []
for conv in conversations:
msgs = [m for m in conv.messages if m.role in ("user", "assistant")]
if len(msgs) < 2:
continue
if sum(1 for m in msgs if m.role == "user") < min_turns:
continue
sharegpt_msgs = [
{"from": "human" if m.role == "user" else "gpt", "value": m.content}
for m in msgs
]
lines.append(_json.dumps({"conversations": sharegpt_msgs}))
date_str = datetime.date.today().isoformat()
filename = f"nexus-conversations-{date_str}.jsonl"
return Response(
content="\n".join(lines),
media_type="application/x-ndjson",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"X-Exported-Count": str(len(lines)),
},
)
@app.get("/conversations/{conversation_id}") @app.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str): async def get_conversation(conversation_id: str):
try: try:
@@ -699,6 +905,7 @@ async def get_conversation(conversation_id: str):
return { return {
"id": conv.id, "id": conv.id,
"timestamp": conv.created_at, "timestamp": conv.created_at,
"title": conv.title,
"messages": [ "messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens} {"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
for m in conv.messages for m in conv.messages
@@ -710,6 +917,25 @@ async def get_conversation(conversation_id: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.patch("/conversations/{conversation_id}")
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
"""Manually set a conversation's title."""
try:
conv = store.get_conversation(conversation_id)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="Missing 'title'")
title = " ".join(title.split())[:120]
store.set_conversation_title(conversation_id, title)
return {"id": conversation_id, "title": title}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/conversations/{conversation_id}") @app.delete("/conversations/{conversation_id}")
async def delete_conversation(conversation_id: str): async def delete_conversation(conversation_id: str):
try: try:
@@ -723,6 +949,80 @@ async def delete_conversation(conversation_id: str):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── Icon branding routes ──────────────────────────────────────────────────────
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
_ALLOWED_ICON_ROOTS = [
"/usr/share/icons",
"/usr/share/pixmaps",
"/usr/local/share/icons",
"/opt",
_os.path.expanduser("~/.local/share/icons"),
_os.path.expanduser("~/.icons"),
_REPO_ASSETS,
]
@app.get("/icons/apps")
async def list_icon_apps():
"""Return all installed applications with their icon paths."""
try:
from .icons.resolver import scan_apps
loop = _asyncio.get_event_loop()
apps = await loop.run_in_executor(None, scan_apps)
return {"apps": apps}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/icons/image")
async def get_icon_image(path: str):
"""Serve an icon file after verifying it's in an allowed root."""
real = _os.path.realpath(path)
if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
raise HTTPException(status_code=403, detail="Path not allowed")
if not _os.path.isfile(real):
raise HTTPException(status_code=404, detail="Icon not found")
return FileResponse(real)
@app.post("/icons/brand")
async def brand_app_icon(payload: Dict[str, Any] = Body(...)):
"""Composite an app icon onto the NexusOS underlay tile."""
src_path = payload.get("src_path", "")
output_name = payload.get("output_name", "")
if not src_path or not output_name:
raise HTTPException(status_code=400, detail="src_path and output_name required")
frac = float(payload.get("frac", 0.60))
round_mask = bool(payload.get("round_mask", False))
nexus_ring = bool(payload.get("nexus_ring", False))
reload = bool(payload.get("reload", True))
category = str(payload.get("category", "apps"))
try:
from .icons.compositor import brand_icon
loop = _asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: brand_icon(src_path, output_name, frac, round_mask, nexus_ring, reload, category),
)
return {"status": "ok", "output_name": output_name}
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/icons/apply")
async def apply_icon_cache_route():
"""Rebuild the GTK icon cache and reload the panel."""
try:
from .icons.compositor import apply_icon_cache
loop = _asyncio.get_event_loop()
await loop.run_in_executor(None, apply_icon_cache)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ------------------------- # -------------------------
# End of file # End of file
# ------------------------- # -------------------------
Regular → Executable
+105 -31
View File
@@ -6,42 +6,52 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from typing import Optional
from ..chat import _synapse_trace # append curator reasoning to the same MindTrace log
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
_TR = "" * 55
_PROMPT = """\ _PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus. You are a memory curator for a personal AI assistant named Nexus.
A conversation just occurred. Decide if the USER revealed a NEW, PERMANENT \ Extract EVERY new, permanent personal fact the USER revealed in this exchange.
personal fact that should be saved to long-term memory. There may be SEVERAL facts in one message — output one JSON object for each.
SAVE ONLY facts that are stable and biographical: SAVE facts that are stable and biographical, such as: identity (name, age,
- Identity: full name, age, location, nationality location), relationships (family, partner, friends), pets, possessions (vehicles,
- Possessions: vehicle, home, devices home, devices), career (job, employer, skills), hobbies and interests,
- Relationships: family members, partner, close friends long-running projects or goals (not today's to-dos), and durable preferences.
- Career: job title, employer, field, skills
- Long-running projects or goals (not today's to-dos)
- Durable preferences or habits explicitly stated
DO NOT SAVE — these are ephemeral and would clutter memory: DO NOT SAVE — these are ephemeral and would clutter memory:
- Anything time-bound: "today I have...", "I'm working on X today", "I have a full day of work" - Anything time-bound: "today I have...", "I'm working on X today"
- Mood or energy: "I'm tired", "feeling good", "having a rough day" - Mood or energy: "I'm tired", "feeling good", "having a rough day"
- Greetings or small talk: "good morning", "how are you" - Greetings or small talk: "good morning", "how are you"
- Questions the user asked the assistant - Questions the user asked the assistant
- Near-duplicates of anything in the existing memory list below
Existing memory (do not re-save these or close variants): 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} {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} USER: {user_message}
ASSISTANT: {assistant_response} ASSISTANT: {assistant_response}
--- ---
Respond with JSON only — no prose, no markdown fences: Respond with JSON only — no prose, no markdown fences. Output one object PER
{{"save": true, "section": "<section name, or one from the list above>", "text": "<concise fact about Jon, third person>"}} new fact (several objects, one per line, if there are several):
OR {{"save": true, "section": "<short section name>", "text": "<concise fact about Jon, third person>"}}
If there is nothing new to save, output exactly:
{{"save": false}}""" {{"save": false}}"""
@@ -51,31 +61,62 @@ async def extract_memory(
existing_sections: list[str], existing_sections: list[str],
existing_texts: list[str], existing_texts: list[str],
ollama_manager, ollama_manager,
) -> Optional[dict]: model: str = "mistral:latest",
"""Ask Mistral to extract a saveable memory fact from a conversation exchange. num_gpu: int | None = 0,
) -> list[dict]:
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
Returns {"section": ..., "text": ...} or None. 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: if existing_texts:
texts_block = "\n".join(f"- {t}" for t in existing_texts[:40]) # 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: else:
texts_block = "(none yet)" 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( prompt = _PROMPT.format(
existing_texts=texts_block, existing_texts=texts_block,
user_message=user_message[:800], existing_sections=sections_line,
user_message=user_message[:3000],
assistant_response=assistant_response[:800], 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: 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( response = await ollama_manager.chat(
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
model="mistral:latest", model=model,
stream=False, stream=False,
temperature=0.0, temperature=0.0,
num_gpu=num_gpu,
) )
if not response: if not response:
return None _synapse_trace("◆ CURATOR RAW: (empty response)\n")
return []
text = response.strip() text = response.strip()
_synapse_trace(f"◆ CURATOR RAW:\n{text}\n")
# Strip markdown code fences if the model added them # Strip markdown code fences if the model added them
if "```" in text: if "```" in text:
@@ -83,12 +124,45 @@ async def extract_memory(
if m: if m:
text = m.group(1).strip() text = m.group(1).strip()
data = json.loads(text) # For several facts Mistral is inconsistent: sometimes ONE JSON object
if data.get("save") and data.get("section") and data.get("text"): # per fact newline-separated, sometimes a single JSON ARRAY of objects.
return { # raw_decode pulls each top-level value (handles the newline case and
"section": str(data["section"]).strip(), # plain "Extra data"); we then flatten any array so both shapes save all
"text": str(data["text"]).strip(), # 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: except Exception as e:
_log.debug("memory extraction failed: %s", e) _log.warning("memory extraction failed: %s", e)
return None _synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
return []
Regular → Executable
+93 -10
View File
@@ -13,6 +13,7 @@ Endpoints:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import math
import uuid import uuid
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -35,6 +36,33 @@ app.add_middleware(
) )
@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("/") @app.get("/")
async def health(): async def health():
return {"status": "ok", "count": len(store.all())} return {"status": "ok", "count": len(store.all())}
@@ -86,6 +114,13 @@ async def delete_memory(item_id: str):
return {"status": "deleted"} 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): class ExtractRequest(BaseModel):
user_message: str user_message: str
assistant_response: str assistant_response: str
@@ -99,25 +134,73 @@ async def extract_and_save(req: ExtractRequest):
existing_sections = list({i.section for i in existing}) existing_sections = list({i.section for i in existing})
existing_texts = [i.text 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: try:
result = await asyncio.wait_for( 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( extract_memory(
req.user_message, req.user_message,
req.assistant_response, req.assistant_response,
existing_sections, existing_sections,
existing_texts, existing_texts,
get_ollama_manager(), mgr,
model=model,
num_gpu=num_gpu,
), ),
timeout=20.0, timeout=300.0,
)
if result:
item = MemoryItem(
id=str(uuid.uuid4()),
section=result["section"],
text=result["text"],
) )
# 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) store.add(item)
return {"saved": True, "id": item.id, "section": item.section, "text": item.text} 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: except asyncio.TimeoutError:
pass pass
except Exception: except Exception:
Regular → Executable
+300 -9
View File
@@ -3,9 +3,22 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel from pydantic import BaseModel
import sqlite3 import sqlite3
import json import json
import math
import os import os
import time 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 # Models
# ----------------------------- # -----------------------------
@@ -14,6 +27,7 @@ class MemoryItem(BaseModel):
section: str = "General" section: str = "General"
text: str text: str
tags: List[str] = [] tags: List[str] = []
position: int = 0
class MessageItem(BaseModel): class MessageItem(BaseModel):
role: str # "user" or "assistant" role: str # "user" or "assistant"
@@ -27,6 +41,7 @@ class ConversationItem(BaseModel):
messages: List[MessageItem] = [] messages: List[MessageItem] = []
created_at: float created_at: float
updated_at: float updated_at: float
title: Optional[str] = None
@property @property
def preview(self) -> str: def preview(self) -> str:
@@ -75,14 +90,29 @@ class PersistentMemoryStore:
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'") cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
except Exception: except Exception:
pass 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(""" cur.execute("""
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at REAL NOT NULL, created_at REAL NOT NULL,
updated_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(""" cur.execute("""
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -107,12 +137,26 @@ class PersistentMemoryStore:
ON 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(""" cur.execute("""
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
) )
""") """)
cur.execute(
"DELETE FROM settings WHERE key IN ('anthropic_api_key', 'escalation_model')"
)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -123,7 +167,7 @@ class PersistentMemoryStore:
def _load_all_memory(self) -> Dict[str, MemoryItem]: def _load_all_memory(self) -> Dict[str, MemoryItem]:
conn = self._connect() conn = self._connect()
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id, section, text, tags FROM memory") cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
rows = cur.fetchall() rows = cur.fetchall()
conn.close() conn.close()
@@ -138,6 +182,7 @@ class PersistentMemoryStore:
section=row["section"] or "General", section=row["section"] or "General",
text=row["text"], text=row["text"],
tags=tags, tags=tags,
position=row["position"] or 0,
) )
return cache return cache
@@ -146,19 +191,35 @@ class PersistentMemoryStore:
# Memory API # Memory API
# ----------------------------- # -----------------------------
def add(self, item: MemoryItem): 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 self._cache[item.id] = item
conn = self._connect() conn = self._connect()
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT OR REPLACE INTO memory (id, section, text, tags) VALUES (?, ?, ?, ?)", "INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
(item.id, item.section or "General", item.text, json.dumps(item.tags)) (item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
) )
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
def update(self, item: MemoryItem): def update(self, item: MemoryItem):
existing = self.get(item.id)
if existing:
item.position = existing.position
self.add(item) self.add(item)
def delete(self, item_id: str): def delete(self, item_id: str):
@@ -172,10 +233,76 @@ class PersistentMemoryStore:
conn.close() conn.close()
def get(self, item_id: str) -> Optional[MemoryItem]: def get(self, item_id: str) -> Optional[MemoryItem]:
return self._cache.get(item_id) 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]: def all(self) -> List[MemoryItem]:
return list(self._cache.values()) # 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 # Conversation API
@@ -194,7 +321,19 @@ class PersistentMemoryStore:
conn.close() conn.close()
return ConversationItem(id=conversation_id, created_at=now, updated_at=now) return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
def add_message(self, conversation_id: str, role: str, content: str, model: str = None, tokens: int = None): 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() now = time.time()
conn = self._connect() conn = self._connect()
try: try:
@@ -203,11 +342,13 @@ class PersistentMemoryStore:
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
(conversation_id, role, content, now, model, tokens) (conversation_id, role, content, now, model, tokens)
) )
message_id = cur.lastrowid
cur.execute( cur.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?", "UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id) (now, conversation_id)
) )
conn.commit() conn.commit()
return message_id
finally: finally:
conn.close() conn.close()
@@ -231,14 +372,15 @@ class PersistentMemoryStore:
id=row["id"], id=row["id"],
messages=messages, messages=messages,
created_at=row["created_at"], created_at=row["created_at"],
updated_at=row["updated_at"] updated_at=row["updated_at"],
title=row["title"] if "title" in row.keys() else None,
) )
def all_conversations(self) -> List[ConversationItem]: def all_conversations(self) -> List[ConversationItem]:
conn = self._connect() conn = self._connect()
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT c.id, c.created_at, c.updated_at, SELECT c.id, c.created_at, c.updated_at, c.title,
m.role, m.content, m.timestamp, m.model, m.tokens m.role, m.content, m.timestamp, m.model, m.tokens
FROM conversations c FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN messages m ON m.conversation_id = c.id
@@ -256,6 +398,7 @@ class PersistentMemoryStore:
id=cid, id=cid,
created_at=row["created_at"], created_at=row["created_at"],
updated_at=row["updated_at"], updated_at=row["updated_at"],
title=row["title"],
) )
order.append(cid) order.append(cid)
if row["role"] is not None: if row["role"] is not None:
@@ -333,6 +476,130 @@ class PersistentMemoryStore:
conn.close() conn.close()
return results 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 API
# ----------------------------- # -----------------------------
@@ -341,6 +608,30 @@ class PersistentMemoryStore:
"temperature": 0.7, "temperature": 0.7,
"system_prompt": "", "system_prompt": "",
"timeout": 120, "timeout": 120,
# How long Ollama keeps the model resident in VRAM between messages.
# "30m"/"-1" (never unload)/"0" (unload now). Avoids cold-reload latency
# when you return to an idle chat. Empty → Ollama's 5-minute default.
"keep_alive": "30m",
# CPU/GPU offload: -1 = Auto (Ollama auto-fits layers to VRAM).
# 0100 = percent of model layers to force onto the GPU; the
# remainder runs on CPU. See OllamaManager.get_model_layers.
"gpu_offload": -1,
# Memory curator (the model that extracts facts after each exchange).
# Empty → same auto-selected model as chat. A dedicated model (e.g.
# "mistral:latest") gives better extraction but must share VRAM.
"memory_model": "mistral:latest",
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
"memory_gpu_offload": 0,
# Similar-fact merge: when a newly extracted fact's embedding is at least
# this cosine-similar to an existing fact, UPDATE that fact in place
# instead of appending a duplicate ("edit with new info"). 0 disables
# (always append). Calibrated on nomic-embed-text: genuine updates
# (mileage/title/location changes) score 0.810.99, while distinct facts
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
"memory_merge_threshold": 0.80,
} }
def get_settings(self) -> Dict[str, Any]: def get_settings(self) -> Dict[str, Any]:
Regular → Executable
+12 -8
View File
@@ -15,6 +15,12 @@ except Exception:
# --- PROJECT ROOT --- # --- PROJECT ROOT ---
PROJECT_ROOT = Path(__file__).resolve().parent.parent 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"
# --- CORE DIRECTORIES --- # --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data" DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models" MODELS_DIR = PROJECT_ROOT / "models"
@@ -27,16 +33,15 @@ CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp" TEMP_DIR = RUNTIME_DIR / "tmp"
# --- APPLICATION SUBSYSTEM DIRECTORIES --- # --- APPLICATION SUBSYSTEM DIRECTORIES ---
PLAYBOOK_DIR = PROJECT_ROOT / "synapse" / "playbooks" PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads" UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports" EXPORTS_DIR = DATA_DIR / "exports"
# --- DATABASE / STORAGE FILES (match your repo) --- # --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = MEMORY_DIR / "memory.db" MEMORY_DB = MEMORY_DIR / "memory.db"
PLAYBOOK_DB = MEMORY_DB # same file in your setup
# --- LOG FILES --- # --- LOG FILES ---
BACKEND_LOG = LOGS_DIR / "backend.log" BACKEND_LOG = RUNTIME_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log" OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log" CHAT_LOG = LOGS_DIR / "chat.log"
@@ -73,7 +78,6 @@ def path(name: str) -> Path:
"uploads": UPLOADS_DIR, "uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR, "exports": EXPORTS_DIR,
"memory_db": MEMORY_DB, "memory_db": MEMORY_DB,
"playbook_db": PLAYBOOK_DB,
"backend_log": BACKEND_LOG, "backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG, "ollama_log": OLLAMA_LOG,
"chat_log": CHAT_LOG, "chat_log": CHAT_LOG,
@@ -90,6 +94,7 @@ class Settings:
or `Settings` class for typing/tests. or `Settings` class for typing/tests.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.version: str = VERSION
self.project_root: Path = PROJECT_ROOT self.project_root: Path = PROJECT_ROOT
self.data_dir: Path = DATA_DIR self.data_dir: Path = DATA_DIR
self.models_dir: Path = MODELS_DIR self.models_dir: Path = MODELS_DIR
@@ -99,7 +104,6 @@ class Settings:
# DB files # DB files
self.memory_db: Path = MEMORY_DB self.memory_db: Path = MEMORY_DB
self.playbook_db: Path = PLAYBOOK_DB
# Logs # Logs
self.backend_log: Path = BACKEND_LOG self.backend_log: Path = BACKEND_LOG
@@ -112,13 +116,13 @@ class Settings:
def as_dict(self) -> Dict[str, Any]: def as_dict(self) -> Dict[str, Any]:
return { return {
"version": self.version,
"project_root": str(self.project_root), "project_root": str(self.project_root),
"data_dir": str(self.data_dir), "data_dir": str(self.data_dir),
"models_dir": str(self.models_dir), "models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir), "runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir), "memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db), "memory_db": str(self.memory_db),
"playbook_db": str(self.playbook_db),
"ollama_host": self.ollama_host, "ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout, "ollama_timeout": self.ollama_timeout,
} }
@@ -127,10 +131,10 @@ class Settings:
settings = Settings() settings = Settings()
# explicit exports for static checkers and IDEs # explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", __all__ = ["Settings", "settings", "path", "VERSION",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR", "PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR", "MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB", "PLAYBOOK_DB", "EXPORTS_DIR", "MEMORY_DB",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"] "BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
# --- quick runtime sanity check when run directly (no side effects on import) --- # --- quick runtime sanity check when run directly (no side effects on import) ---
Regular → Executable
+178 -42
View File
@@ -1,15 +1,16 @@
import asyncio import asyncio
import json import json
import logging import logging
import re
import subprocess import subprocess
import time import time
import httpx import httpx
import os import os
import signal import signal
from pathlib import Path from pathlib import Path
from typing import Any
from .nexus_config import settings from .nexus_config import settings
from .memory.store import store
OLLAMA_PORT = 11434 OLLAMA_PORT = 11434
@@ -131,10 +132,9 @@ def _detect_gpu_backend() -> tuple[str, dict]:
if vulkan_ok: if vulkan_ok:
idx, name = _best_vulkan_device() idx, name = _best_vulkan_device()
env_overrides: dict = {"OLLAMA_GPU": "vulkan"} # Always pin to the selected device — without this, Ollama may use the Intel
if idx > 0: # iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
# Explicitly route Ollama to the discrete GPU when it isn't device 0 env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
env_overrides["GGML_VK_VISIBLE_DEVICES"] = str(idx)
_log.info("GPU backend: Vulkan device %d (%s)", idx, name) _log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides return f"vulkan ({name})", env_overrides
@@ -153,18 +153,36 @@ def _detect_gpu_backend() -> tuple[str, dict]:
return "cpu", {} return "cpu", {}
def _model_score(name: str) -> tuple: # Single source of model auto-selection preference. Ordered so small, GPU-fitting
"""Score a model name for auto-selection. Higher tuple = better.""" # models come first (qwen2.5:3b fits a 4GB card and is a strong all-rounder); the
lower = name.lower() # tail differs by task. Prefix-matched against installed model names.
match = re.search(r'(\d+(?:\.\d+)?)[bB]', lower) _MODEL_PREFERENCE = {
params = float(match.group(1)) if match else 7.0 "chat": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "gemma2", "gemma"),
# Tier-break: known quality models get a small bonus "code": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "codellama", "deepseek-coder", "codegemma"),
quality = next( }
(i for i, prefix in enumerate(("llama3", "llama2", "mistral", "gemma", "phi", "qwen"), 1)
if prefix in lower),
0, def _preferred_model(models: list, preference) -> str | None:
) """First installed model whose name starts with a preference prefix."""
return (params, quality) 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: class OllamaManager:
@@ -181,8 +199,43 @@ class OllamaManager:
self._api_base = settings.ollama_host.rstrip("/") self._api_base = settings.ollama_host.rstrip("/")
# Model selection cache # Model selection cache
self._model_cache: str | None = None self._model_cache: dict = {} # intent -> (model, monotonic_ts)
self._model_cache_ts: float = 0.0
# 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): def is_available(self):
bin_path = _ollama_bin() bin_path = _ollama_bin()
@@ -328,12 +381,12 @@ class OllamaManager:
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post( r = await client.post(
f"{self._api_base}/api/generate", f"{self._api_base}/api/generate",
json={ json=self._apply_keep_alive({
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
"system": system, "system": system,
"stream": False, "stream": False,
}, }),
) )
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
@@ -356,12 +409,12 @@ class OllamaManager:
async with client.stream( async with client.stream(
"POST", "POST",
f"{self._api_base}/api/generate", f"{self._api_base}/api/generate",
json={ json=self._apply_keep_alive({
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
"system": system, "system": system,
"stream": True, "stream": True,
}, }),
) as response: ) as response:
response.raise_for_status() response.raise_for_status()
async for line in response.aiter_lines(): async for line in response.aiter_lines():
@@ -391,17 +444,23 @@ class OllamaManager:
model: str = "mistral", model: str = "mistral",
stream: bool = False, stream: bool = False,
temperature: float | None = None, temperature: float | None = None,
num_gpu: int | None = None,
**kwargs, **kwargs,
): ):
"""Multi-turn chat via /api/chat (accepts a messages array with roles).""" """Multi-turn chat via /api/chat (accepts a messages array with roles)."""
start = time.perf_counter() start = time.perf_counter()
try: try:
if stream: if stream:
return self._chat_stream(messages=messages, model=model, temperature=temperature, start=start) return self._chat_stream(
messages=messages, model=model, temperature=temperature,
num_gpu=num_gpu, start=start,
)
else: else:
body: dict = {"model": model, "messages": messages, "stream": False} body: dict = {"model": model, "messages": messages, "stream": False}
if temperature is not None: opts = _chat_options(temperature, num_gpu)
body["options"] = {"temperature": temperature} if opts:
body["options"] = opts
self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{self._api_base}/api/chat", json=body) r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
@@ -412,6 +471,33 @@ class OllamaManager:
_log.exception("chat error after %.3fs: %s", elapsed, e) _log.exception("chat error after %.3fs: %s", elapsed, e)
return None 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]: async def list_models(self) -> list[str]:
"""Return names of all locally installed Ollama models.""" """Return names of all locally installed Ollama models."""
try: try:
@@ -422,34 +508,84 @@ class OllamaManager:
except Exception: except Exception:
return [] return []
async def select_best_model(self) -> str: async def select_best_model(self, intent: str = "chat") -> str:
"""Pick the highest-scoring available model, falling back to 'mistral'. """Pick the preferred installed model for `intent` ('chat' or 'code'),
falling back to any installed model, then 'mistral'. Cached ~60s per
Result is cached for 60 seconds so rapid chat requests don't each intent so rapid requests don't rebuild the model list each time.
hit the Ollama API to build the model list.
""" """
now = time.monotonic() now = time.monotonic()
if self._model_cache and (now - self._model_cache_ts) < 60: cached = self._model_cache.get(intent)
return self._model_cache if cached and (now - cached[1]) < 60:
return cached[0]
models = await self.list_models() models = await self.list_models()
best = max(models, key=_model_score) if models else "mistral" pref = _MODEL_PREFERENCE.get(intent, _MODEL_PREFERENCE["chat"])
best = _preferred_model(models, pref) or (models[0] if models else "mistral")
self._model_cache = best self._model_cache[intent] = (best, now)
self._model_cache_ts = now
return best return best
def invalidate_model_cache(self): def invalidate_model_cache(self):
"""Force next select_best_model() to re-query (e.g. after pull/delete).""" """Force next select_best_model() to re-query (e.g. after pull/delete)."""
self._model_cache = None self._model_cache = {}
self._model_cache_ts = 0.0
async def _chat_stream(self, messages: list, model: str, start: float, temperature: float | None = None): async def get_model_layers(self, model: str) -> int | None:
"""Total offloadable layer count for `model` (repeating blocks + output layer).
Used to turn a CPU/GPU offload percentage into an Ollama `num_gpu`
value. Reads `<arch>.block_count` from /api/show and adds 1 for the
non-repeating output layer (Ollama reports e.g. 33 layers for a model
with block_count=32). Cached per-model since it never changes.
Returns None if the count can't be determined, so callers fall back
to Auto (no num_gpu override).
"""
if model in self._layer_cache:
return self._layer_cache[model]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self._api_base}/api/show", json={"model": model})
r.raise_for_status()
info = r.json().get("model_info", {}) or {}
block_count = next(
(v for k, v in info.items() if k.endswith(".block_count")), None
)
layers = int(block_count) + 1 if block_count is not None else None
except Exception as e:
_log.warning("get_model_layers(%s) failed: %s", model, e)
layers = None
if layers:
self._layer_cache[model] = layers
return layers
async def resolve_num_gpu(self, gpu_offload, model: str) -> int | None:
"""Convert a stored gpu_offload setting into an Ollama `num_gpu` value.
`gpu_offload` is -1 for Auto (returns None → no override, Ollama auto-fits)
or 0100 for the percent of the model's layers to force onto the GPU
(0 = all CPU/RAM). Layer count is model-specific, resolved from the live
model. Returns None on anything unexpected so callers fall back to Auto.
"""
try:
pct = int(gpu_offload)
except (TypeError, ValueError):
return None
if pct < 0:
return None
pct = min(pct, 100)
layers = await self.get_model_layers(model)
if not layers:
return None
return max(0, round(pct / 100 * layers))
async def _chat_stream(self, messages: list, model: str, start: float,
temperature: float | None = None, num_gpu: int | None = None):
"""Async generator streaming tokens, then a final __meta__ stats sentinel.""" """Async generator streaming tokens, then a final __meta__ stats sentinel."""
try: try:
body: dict = {"model": model, "messages": messages, "stream": True} body: dict = {"model": model, "messages": messages, "stream": True}
if temperature is not None: opts = _chat_options(temperature, num_gpu)
body["options"] = {"temperature": temperature} if opts:
body["options"] = opts
self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream( async with client.stream(
"POST", "POST",
@@ -484,7 +620,7 @@ class OllamaManager:
except Exception as e: except Exception as e:
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
_log.exception("chat stream error after %.3fs: %s", elapsed, e) _log.exception("chat stream error after %.3fs: %s", elapsed, e)
return raise
def initialize_ollama() -> OllamaManager: def initialize_ollama() -> OllamaManager:
Regular → Executable
+7 -18
View File
@@ -1,11 +1,8 @@
import threading
from typing import List from typing import List
from .playbooks.store import playbook_store, PlaybookItem from .playbooks.store import playbook_store, PlaybookItem
class PlaybookManager: class PlaybookManager:
_lock = threading.Lock()
@classmethod @classmethod
def _all(cls) -> List[PlaybookItem]: def _all(cls) -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main).""" """Return all playbooks sorted by order (position 0 is always main)."""
@@ -25,18 +22,10 @@ class PlaybookManager:
@classmethod @classmethod
def get_system_prompt(cls) -> str: def get_system_prompt(cls) -> str:
playbook = cls.get_main_playbook() playbook = cls.get_main_playbook()
return getattr(playbook, "instructions", "") or "" if playbook else "" if not playbook:
return ""
@classmethod goal = (getattr(playbook, "goal", "") or "").strip()
def render_prompt(cls, user_message: str, variables: dict | None = None) -> str: instructions = (getattr(playbook, "instructions", "") or "").strip()
if not variables: if goal and instructions:
return user_message return f"{goal}\n\n{instructions}"
result = user_message return goal or instructions
for key, value in variables.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
# Keep old name as alias so nothing else breaks
@classmethod
def get_active_playbook(cls) -> PlaybookItem | None:
return cls.get_main_playbook()
Regular → Executable
View File
Regular → Executable
-12
View File
@@ -90,18 +90,6 @@ class PlaybookFileStore:
item.order = index item.order = index
self._write(item) self._write(item)
def search_playbooks(self, query: str) -> List[PlaybookItem]:
if not query or not query.strip():
return []
q = query.strip().lower()
return [
p for p in self.all_playbooks()
if q in p.title.lower()
or q in p.goal.lower()
or q in p.instructions.lower()
or any(q in t.lower() for t in p.tags)
]
from ..nexus_config import PLAYBOOK_DIR from ..nexus_config import PLAYBOOK_DIR
playbook_store = PlaybookFileStore(PLAYBOOK_DIR) playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import re
from typing import Optional
_SEARCH_TRIGGERS = frozenset({
# Time-sensitive single words
"news", "weather", "today", "tonight", "yesterday", "price", "stock",
# Phrases that imply freshness or external lookup
"latest ", "right now", "this week", "this month", "recently",
"who is ", "what is ", "when did ", "how much does", "how much is",
"look up", "search for", "find out", "release date",
"just released", "just announced", "just launched",
"current version", "current price",
})
def needs_web_search(message: str) -> bool:
lower = message.lower()
return any(kw in lower for kw in _SEARCH_TRIGGERS)
def web_search(query: str, max_results: int = 4) -> str:
"""Search DuckDuckGo and return formatted result snippets.
Returns an empty string on any failure so callers can treat it as
optional context a failed search should never break a chat response.
"""
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return ""
parts = []
for i, r in enumerate(results, 1):
title = r.get("title", "").strip()
body = r.get("body", "").strip()
href = r.get("href", "").strip()
parts.append(f"{i}. **{title}**\n{body}\nSource: {href}")
return "\n\n".join(parts)
except Exception:
return ""