diff --git a/synapse/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml b/data/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml
old mode 100644
new mode 100755
similarity index 79%
rename from synapse/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml
rename to data/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml
index e082057..a9ce4d2
--- a/synapse/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml
+++ b/data/playbooks/0858861d-6c42-48b9-be9f-d7e86cc45586.yaml
@@ -17,5 +17,6 @@ instructions: |-
Rules:
- Never refer to yourself as an AI or language model
- 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
- If you don't know something, say so plainly and help find the answer
diff --git a/data/playbooks/10aef5da-f100-4148-afdc-fe539398818a.yaml b/data/playbooks/10aef5da-f100-4148-afdc-fe539398818a.yaml
new file mode 100755
index 0000000..ba1c33c
--- /dev/null
+++ b/data/playbooks/10aef5da-f100-4148-afdc-fe539398818a.yaml
@@ -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
diff --git a/data/playbooks/13c9bf41-61e8-4986-b6aa-da7ed3cee04b.yaml b/data/playbooks/13c9bf41-61e8-4986-b6aa-da7ed3cee04b.yaml
new file mode 100755
index 0000000..20b129d
--- /dev/null
+++ b/data/playbooks/13c9bf41-61e8-4986-b6aa-da7ed3cee04b.yaml
@@ -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
diff --git a/data/playbooks/45748398-04f8-4753-8fca-e38a4345975d.yaml b/data/playbooks/45748398-04f8-4753-8fca-e38a4345975d.yaml
new file mode 100755
index 0000000..7acfa19
--- /dev/null
+++ b/data/playbooks/45748398-04f8-4753-8fca-e38a4345975d.yaml
@@ -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
diff --git a/data/playbooks/4b1c79e2-fbab-4444-aa02-294c26240b1b.yaml b/data/playbooks/4b1c79e2-fbab-4444-aa02-294c26240b1b.yaml
new file mode 100755
index 0000000..9608b39
--- /dev/null
+++ b/data/playbooks/4b1c79e2-fbab-4444-aa02-294c26240b1b.yaml
@@ -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
diff --git a/data/playbooks/75216a8a-9f6e-4abb-bfd0-f3dca78a0849.yaml b/data/playbooks/75216a8a-9f6e-4abb-bfd0-f3dca78a0849.yaml
new file mode 100755
index 0000000..2b7403d
--- /dev/null
+++ b/data/playbooks/75216a8a-9f6e-4abb-bfd0-f3dca78a0849.yaml
@@ -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
diff --git a/data/playbooks/9a7d5b06-23e0-40f5-b075-1a675fd6edc2.yaml b/data/playbooks/9a7d5b06-23e0-40f5-b075-1a675fd6edc2.yaml
new file mode 100755
index 0000000..0d17723
--- /dev/null
+++ b/data/playbooks/9a7d5b06-23e0-40f5-b075-1a675fd6edc2.yaml
@@ -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
diff --git a/data/playbooks/c9893106-645f-4e30-957d-70f24652c9f3.yaml b/data/playbooks/c9893106-645f-4e30-957d-70f24652c9f3.yaml
new file mode 100755
index 0000000..cf4708a
--- /dev/null
+++ b/data/playbooks/c9893106-645f-4e30-957d-70f24652c9f3.yaml
@@ -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
diff --git a/data/playbooks/f886a94f-b768-49c0-904c-f0f8b556d079.yaml b/data/playbooks/f886a94f-b768-49c0-904c-f0f8b556d079.yaml
new file mode 100755
index 0000000..8bc4fd5
--- /dev/null
+++ b/data/playbooks/f886a94f-b768-49c0-904c-f0f8b556d079.yaml
@@ -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
diff --git a/synapse/__init__.py b/synapse/__init__.py
old mode 100644
new mode 100755
diff --git a/synapse/chat.py b/synapse/chat.py
old mode 100644
new mode 100755
index bc6e022..0354c6f
--- a/synapse/chat.py
+++ b/synapse/chat.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import json as _json
import logging
import threading
from typing import AsyncGenerator, Dict, List, Optional, Any
@@ -58,6 +59,7 @@ async def generate_chat_response(
system = metadata.get("system", "")
model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature")
+ num_gpu = metadata.get("num_gpu")
messages: List[Dict[str, str]] = []
if system:
@@ -76,10 +78,11 @@ async def generate_chat_response(
try:
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,
)
response_text = result if isinstance(result, str) else str(result)
+
preview = response_text[:500].replace("\n", " ")
_synapse_trace(f"{preview}{'…' if len(response_text) > 500 else ''}\n{'─' * 50}\n")
_logger.info("generate_chat_response: completed model=%s", model)
@@ -115,30 +118,12 @@ async def _aiter_with_timeout(aiterable, timeout: Optional[float]):
# Normalizer for many return shapes
# -------------------------
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
- if hasattr(maybe_iterable, "__aiter__"):
- async for item in maybe_iterable:
- yield str(item)
- return
-
- if asyncio.iscoroutine(maybe_iterable):
- result = await maybe_iterable
- if hasattr(result, "__aiter__"):
- async for item in result:
- 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)
+ # The sole caller passes manager.chat(stream=True) — an async-def call, i.e.
+ # a coroutine that resolves to an async generator. Await it if needed, then
+ # stream the tokens.
+ result = await maybe_iterable if asyncio.iscoroutine(maybe_iterable) else maybe_iterable
+ async for item in result:
+ yield str(item)
# -------------------------
@@ -157,6 +142,7 @@ async def stream_chat_response(
system = metadata.get("system", "")
model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature")
+ num_gpu = metadata.get("num_gpu")
# Build messages array for /api/chat multi-turn format
messages: List[Dict[str, str]] = []
@@ -175,7 +161,7 @@ async def stream_chat_response(
_synapse_trace(f"USR: {user_message}\n{'─' * 50}\n")
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)
buffer_parts: list[str] = []
@@ -224,19 +210,3 @@ async def stream_chat_response(
except Exception:
_logger.exception("stream_chat_response: unexpected error during streaming")
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())
diff --git a/synapse/icons/__init__.py b/synapse/icons/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/synapse/icons/compositor.py b/synapse/icons/compositor.py
new file mode 100644
index 0000000..dbd12bc
--- /dev/null
+++ b/synapse/icons/compositor.py
@@ -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 = """\
+
+
+"""
+
+_RING_FALLBACK = """\
+
+
+"""
+
+
+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,
+ )
diff --git a/synapse/icons/resolver.py b/synapse/icons/resolver.py
new file mode 100644
index 0000000..52cf8b1
--- /dev/null
+++ b/synapse/icons/resolver.py
@@ -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())
diff --git a/synapse/main.py b/synapse/main.py
old mode 100644
new mode 100755
index 355d8b9..61c5cce
--- a/synapse/main.py
+++ b/synapse/main.py
@@ -4,46 +4,169 @@ from __future__ import annotations
import asyncio as _asyncio
import json as _json
import uuid as _uuid
+from collections import Counter as _Counter
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
from uuid import UUID
import httpx
+import os as _os
+from pathlib import Path
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import StreamingResponse
+from fastapi.responses import StreamingResponse, FileResponse
-from .nexus_config import settings
-from .chat import generate_chat_response, stream_chat_response
+from .nexus_config import settings, VERSION
+from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from .playbook_manager import PlaybookManager
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
sections: dict = defaultdict(list)
for item in facts:
+ if (item.section or "").strip().lower() == "instructions":
+ continue
sections[item.section or "General"].append(item.text)
parts = []
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)
-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:
s = store.get_settings()
if s.get("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:
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 .playbooks.store import playbook_store, PlaybookItem
+from .search import needs_web_search, web_search
MEMORY_SERVICE = "http://localhost:8001"
-app = FastAPI(title="Synapse Backend", version="1.0")
+app = FastAPI(title="Synapse Backend", version=VERSION)
# Alias for startup scripts
sio_app = app
@@ -73,6 +196,10 @@ async def startup_event():
global ollama
try:
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.")
except Exception as e:
# 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
except Exception:
status = None
- return {"status": "online", "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))
+ return {"status": "online", "version": VERSION, "ollama": status}
# -------------------------
@@ -140,20 +230,25 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
try:
message = payload.get("message", "")
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", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", [])
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:
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", "")
- # Append reference playbooks to the system prompt
- context_pbs = playbooks.get_context_playbooks()
+ # Fetch memory facts once — used for both playbook routing and system prompt injection
+ 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:
refs = "\n\n".join(
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"
system_prompt = (system_prompt + separator + refs) if system_prompt else refs
-
- # Inject persistent memory facts about Jon
- 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
+ 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.
# 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:
snippets = []
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"
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
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]:
response_chunks: list[str] = []
meta: dict = {}
+ final_model = model
+
+ # ── Phase 1: stream primary model response ────────────────────
try:
async for chunk in stream_chat_response(
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"
continue
response_chunks.append(chunk)
- yield f"data: {chunk}\n\n"
- # Persist the completed assistant response with model and token stats
- if response_chunks:
- store.add_message(
- conversation_id, "assistant", "".join(response_chunks),
- model=meta.get("model") or model,
- tokens=meta.get("tokens"),
- )
- except Exception as e:
- yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n"
+ yield f"data: {_json.dumps(chunk)}\n\n"
+ 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:
+ store.add_message(
+ conversation_id, "assistant", "".join(response_chunks),
+ model=meta.get("model") or final_model,
+ tokens=meta.get("tokens"),
+ )
+
+ # Response is complete — let the client re-enable its input now,
+ # 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
if response_chunks:
try:
- async with httpx.AsyncClient(timeout=25.0) as _mc:
+ async with httpx.AsyncClient(timeout=310.0) as _mc:
r = await _mc.post(
f"{MEMORY_SERVICE}/memories/extract",
json={
@@ -231,11 +417,11 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
)
if r.status_code == 200:
data = r.json()
- if data.get("saved"):
- mem_result = {"section": data["section"], "text": data["text"]}
+ for it in data.get("items", []):
+ mem_result = {"section": it["section"], "text": it["text"]}
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
- except Exception:
- pass
+ except Exception as e:
+ _synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
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:
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
# -------------------------
@@ -273,7 +441,10 @@ async def get_settings_endpoint():
@app.put("/settings")
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
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")
async def add_memory(payload: Dict[str, Any] = Body(...)):
- text = (payload.get("text") or "").strip()
- if not text:
+ text = (payload.get("text") or "").rstrip()
+ if not text.strip():
raise HTTPException(status_code=400, detail="Missing 'text'")
from .memory.store import MemoryItem
import uuid as _mem_uuid
@@ -310,7 +481,7 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
updated = MemoryItem(
id=item_id,
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),
)
store.update(updated)
@@ -325,6 +496,16 @@ async def delete_memory(item_id: str):
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
# -------------------------
@@ -332,8 +513,11 @@ async def delete_memory(item_id: str):
async def get_models():
try:
mgr = get_ollama_manager()
- models = await mgr.list_models()
- selected = await mgr.select_best_model()
+ # Embedding models (e.g. nomic-embed-text) can't chat — hide from picker.
+ 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}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -641,26 +825,6 @@ async def delete_playbook_endpoint(id: UUID):
# -------------------------
# 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
# -------------------------
@@ -682,6 +846,7 @@ async def get_conversations(q: Optional[str] = None):
"timestamp": c.created_at,
"updated_at": c.updated_at,
"preview": c.preview,
+ "title": c.title,
}
for c in conversations
]
@@ -690,6 +855,47 @@ async def get_conversations(q: Optional[str] = None):
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}")
async def get_conversation(conversation_id: str):
try:
@@ -699,6 +905,7 @@ async def get_conversation(conversation_id: str):
return {
"id": conv.id,
"timestamp": conv.created_at,
+ "title": conv.title,
"messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
for m in conv.messages
@@ -710,6 +917,25 @@ async def get_conversation(conversation_id: str):
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}")
async def delete_conversation(conversation_id: str):
try:
@@ -723,6 +949,80 @@ async def delete_conversation(conversation_id: str):
except Exception as 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
# -------------------------
diff --git a/synapse/memory/extractor.py b/synapse/memory/extractor.py
old mode 100644
new mode 100755
index a25a3e3..b293ce2
--- a/synapse/memory/extractor.py
+++ b/synapse/memory/extractor.py
@@ -6,42 +6,52 @@ from __future__ import annotations
import json
import logging
import re
-from typing import Optional
+
+from ..chat import _synapse_trace # append curator reasoning to the same MindTrace log
_log = logging.getLogger(__name__)
+_TR = "┅" * 55
+
_PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus.
-A conversation just occurred. Decide if the USER revealed a NEW, PERMANENT \
-personal fact that should be saved to long-term memory.
+Extract EVERY new, permanent personal fact the USER revealed in this exchange.
+There may be SEVERAL facts in one message — output one JSON object for each.
-SAVE ONLY facts that are stable and biographical:
-- Identity: full name, age, location, nationality
-- Possessions: vehicle, home, devices
-- Relationships: family members, partner, close friends
-- Career: job title, employer, field, skills
-- Long-running projects or goals (not today's to-dos)
-- Durable preferences or habits explicitly stated
+SAVE facts that are stable and biographical, such as: identity (name, age,
+location), relationships (family, partner, friends), pets, possessions (vehicles,
+home, devices), career (job, employer, skills), hobbies and interests,
+long-running projects or goals (not today's to-dos), and durable preferences.
DO NOT SAVE — these are ephemeral and would clutter memory:
-- Anything time-bound: "today I have...", "I'm working on X today", "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"
- Greetings or small talk: "good morning", "how are you"
- 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}
+For "section", REUSE one of these existing section names whenever it fits:
+{existing_sections}
+Only invent a new section if none fit, and make it a SHORT single word
+(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
+
---
USER: {user_message}
ASSISTANT: {assistant_response}
---
-Respond with JSON only — no prose, no markdown fences:
-{{"save": true, "section": "", "text": ""}}
-OR
+Respond with JSON only — no prose, no markdown fences. Output one object PER
+new fact (several objects, one per line, if there are several):
+{{"save": true, "section": "", "text": ""}}
+If there is nothing new to save, output exactly:
{{"save": false}}"""
@@ -51,31 +61,62 @@ async def extract_memory(
existing_sections: list[str],
existing_texts: list[str],
ollama_manager,
-) -> Optional[dict]:
- """Ask Mistral to extract a saveable memory fact from a conversation exchange.
+ model: str = "mistral:latest",
+ 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:
- 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:
texts_block = "(none yet)"
+ # Give Mistral the real section names to reuse, so it stops inventing
+ # sentence-long sections out of the category descriptions in the prompt.
+ # Only offer SHORT, clean names — never feed a junk sentence-section (e.g. a
+ # past bad "Long-running projects or goals") back as a valid choice.
+ clean = sorted(s for s in existing_sections if s and len(s.split()) <= 2 and len(s) <= 24)
+ sections_line = ", ".join(clean) if clean else (
+ "Identity, Relationships, Pets, Possessions, Career, Hobbies, Projects, Preferences"
+ )
prompt = _PROMPT.format(
existing_texts=texts_block,
- user_message=user_message[:800],
+ existing_sections=sections_line,
+ user_message=user_message[:3000],
assistant_response=assistant_response[:800],
)
+ # MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
+ # the same console as the frontline model, not just Python warnings on failure.
+ _synapse_trace(
+ f"\n{_TR}\n◆ CURATOR: {model} (num_gpu={num_gpu})\n"
+ f" PROMPT ({len(prompt)} chars):\n{prompt}\n{_TR}\n"
+ )
try:
+ # num_gpu=0 (the default) pins the curator fully in system RAM instead of
+ # the GPU, so it coexists with the GPU-resident chat model instead of
+ # evicting it. Without this, on a small GPU the two thrash: every
+ # exchange cold-loads the curator (~45s) and extraction times out,
+ # silently saving nothing. Boxes with spare VRAM override via settings.
response = await ollama_manager.chat(
messages=[{"role": "user", "content": prompt}],
- model="mistral:latest",
+ model=model,
stream=False,
temperature=0.0,
+ num_gpu=num_gpu,
)
if not response:
- return None
+ _synapse_trace("◆ CURATOR RAW: (empty response)\n")
+ return []
text = response.strip()
+ _synapse_trace(f"◆ CURATOR RAW:\n{text}\n")
# Strip markdown code fences if the model added them
if "```" in text:
@@ -83,12 +124,45 @@ async def extract_memory(
if m:
text = m.group(1).strip()
- data = json.loads(text)
- if data.get("save") and data.get("section") and data.get("text"):
- return {
- "section": str(data["section"]).strip(),
- "text": str(data["text"]).strip(),
- }
+ # For several facts Mistral is inconsistent: sometimes ONE JSON object
+ # per fact newline-separated, sometimes a single JSON ARRAY of objects.
+ # raw_decode pulls each top-level value (handles the newline case and
+ # plain "Extra data"); we then flatten any array so both shapes save all
+ # facts. Plain json.loads() would die on the newline case and skip the
+ # array (a list isn't a dict), losing every fact either way.
+ results: list[dict] = []
+
+ def _keep(o):
+ if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
+ results.append({
+ "section": str(o["section"]).strip(),
+ "text": str(o["text"]).strip(),
+ })
+
+ dec = json.JSONDecoder()
+ idx = 0
+ while idx < len(text):
+ while idx < len(text) and text[idx] in " \t\r\n,":
+ idx += 1
+ if idx >= len(text):
+ break
+ try:
+ obj, idx = dec.raw_decode(text, idx)
+ except json.JSONDecodeError:
+ break
+ if isinstance(obj, list):
+ for o in obj:
+ _keep(o)
+ else:
+ _keep(obj)
+ if not results:
+ _log.warning("memory: nothing saved. mistral said: %.300r", text)
+ _synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
+ else:
+ _facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
+ _synapse_trace(f"◆ CURATOR VERDICT: {len(results)} fact(s) — {_facts}\n{_TR}\n\n")
+ return results
except Exception as e:
- _log.debug("memory extraction failed: %s", e)
- return None
+ _log.warning("memory extraction failed: %s", e)
+ _synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
+ return []
diff --git a/synapse/memory/service.py b/synapse/memory/service.py
old mode 100644
new mode 100755
index d3046aa..d73da9d
--- a/synapse/memory/service.py
+++ b/synapse/memory/service.py
@@ -13,6 +13,7 @@ Endpoints:
from __future__ import annotations
import asyncio
+import math
import uuid
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("/")
async def health():
return {"status": "ok", "count": len(store.all())}
@@ -86,6 +114,13 @@ async def delete_memory(item_id: str):
return {"status": "deleted"}
+def _cosine(a: list, b: list) -> float:
+ dot = sum(x * y for x, y in zip(a, b))
+ na = math.sqrt(sum(x * x for x in a))
+ nb = math.sqrt(sum(y * y for y in b))
+ return dot / (na * nb) if na and nb else 0.0
+
+
class ExtractRequest(BaseModel):
user_message: str
assistant_response: str
@@ -99,25 +134,73 @@ async def extract_and_save(req: ExtractRequest):
existing_sections = list({i.section for i in existing})
existing_texts = [i.text for i in existing]
+ # Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
+ # which model does extraction, and whether it runs on CPU/RAM or the GPU.
+ settings = store.get_settings()
+ mgr = get_ollama_manager()
+ model = settings.get("memory_model") or await mgr.select_best_model()
+ num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
try:
- 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(
req.user_message,
req.assistant_response,
existing_sections,
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"],
- )
- store.add(item)
- return {"saved": True, "id": item.id, "section": item.section, "text": item.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)
+ if new_vec:
+ existing_embeds[item.id] = new_vec
+ saved.append({"id": item.id, "section": item.section, "text": item.text})
+ if saved:
+ first = {k: saved[0][k] for k in ("id", "section", "text")}
+ return {"saved": True, "items": saved, **first}
except asyncio.TimeoutError:
pass
except Exception:
diff --git a/synapse/memory/store.py b/synapse/memory/store.py
old mode 100644
new mode 100755
index 772791e..0d82fec
--- a/synapse/memory/store.py
+++ b/synapse/memory/store.py
@@ -3,9 +3,22 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel
import sqlite3
import json
+import math
import os
import time
+
+def _cosine(a: List[float], b: List[float]) -> float:
+ """Cosine similarity between two equal-length vectors. 0.0 on mismatch."""
+ if not a or not b or len(a) != len(b):
+ return 0.0
+ dot = sum(x * y for x, y in zip(a, b))
+ na = math.sqrt(sum(x * x for x in a))
+ nb = math.sqrt(sum(y * y for y in b))
+ if na == 0.0 or nb == 0.0:
+ return 0.0
+ return dot / (na * nb)
+
# -----------------------------
# Models
# -----------------------------
@@ -14,6 +27,7 @@ class MemoryItem(BaseModel):
section: str = "General"
text: str
tags: List[str] = []
+ position: int = 0
class MessageItem(BaseModel):
role: str # "user" or "assistant"
@@ -27,6 +41,7 @@ class ConversationItem(BaseModel):
messages: List[MessageItem] = []
created_at: float
updated_at: float
+ title: Optional[str] = None
@property
def preview(self) -> str:
@@ -75,14 +90,29 @@ class PersistentMemoryStore:
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
except Exception:
pass
+ # Migrate: add position column for stable ordering
+ try:
+ cur.execute("ALTER TABLE memory ADD COLUMN position INTEGER NOT NULL DEFAULT 0")
+ except Exception:
+ pass
+ # Backfill positions for rows added before this column existed
+ cur.execute("SELECT COUNT(*) FROM memory WHERE position > 0")
+ if cur.fetchone()[0] == 0:
+ cur.execute("UPDATE memory SET position = rowid")
cur.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at REAL NOT NULL,
- updated_at REAL NOT NULL
+ updated_at REAL NOT NULL,
+ title TEXT
)
""")
+ # Migrate: add title column if it doesn't exist yet
+ try:
+ cur.execute("ALTER TABLE conversations ADD COLUMN title TEXT")
+ except Exception:
+ pass
cur.execute("""
CREATE TABLE IF NOT EXISTS messages (
@@ -107,12 +137,26 @@ class PersistentMemoryStore:
ON messages (conversation_id)
""")
+ # Semantic recall: one embedding vector per message, stored as JSON.
+ # Backfilled lazily by semantic_search_conversations so existing history
+ # gets indexed on first search.
+ cur.execute("""
+ CREATE TABLE IF NOT EXISTS message_vectors (
+ message_id INTEGER PRIMARY KEY,
+ embedding TEXT NOT NULL,
+ FOREIGN KEY (message_id) REFERENCES messages(id)
+ )
+ """)
+
cur.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
+ cur.execute(
+ "DELETE FROM settings WHERE key IN ('anthropic_api_key', 'escalation_model')"
+ )
conn.commit()
conn.close()
@@ -123,7 +167,7 @@ class PersistentMemoryStore:
def _load_all_memory(self) -> Dict[str, MemoryItem]:
conn = self._connect()
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()
conn.close()
@@ -138,6 +182,7 @@ class PersistentMemoryStore:
section=row["section"] or "General",
text=row["text"],
tags=tags,
+ position=row["position"] or 0,
)
return cache
@@ -146,19 +191,35 @@ class PersistentMemoryStore:
# Memory API
# -----------------------------
def add(self, item: MemoryItem):
+ if not item.position:
+ conn = self._connect()
+ try:
+ row = conn.execute(
+ "SELECT position FROM memory WHERE id = ?", (item.id,)
+ ).fetchone()
+ if row and row["position"]:
+ item.position = row["position"]
+ else:
+ row = conn.execute("SELECT MAX(position) AS max_position FROM memory").fetchone()
+ item.position = (row["max_position"] or 0) + 1
+ finally:
+ conn.close()
self._cache[item.id] = item
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
- "INSERT OR REPLACE INTO memory (id, section, text, tags) VALUES (?, ?, ?, ?)",
- (item.id, item.section or "General", item.text, json.dumps(item.tags))
+ "INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
+ (item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
)
conn.commit()
finally:
conn.close()
def update(self, item: MemoryItem):
+ existing = self.get(item.id)
+ if existing:
+ item.position = existing.position
self.add(item)
def delete(self, item_id: str):
@@ -172,10 +233,76 @@ class PersistentMemoryStore:
conn.close()
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]:
- 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
@@ -194,7 +321,19 @@ class PersistentMemoryStore:
conn.close()
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()
conn = self._connect()
try:
@@ -203,11 +342,13 @@ class PersistentMemoryStore:
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
(conversation_id, role, content, now, model, tokens)
)
+ message_id = cur.lastrowid
cur.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id)
)
conn.commit()
+ return message_id
finally:
conn.close()
@@ -231,14 +372,15 @@ class PersistentMemoryStore:
id=row["id"],
messages=messages,
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]:
conn = self._connect()
cur = conn.cursor()
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
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
@@ -256,6 +398,7 @@ class PersistentMemoryStore:
id=cid,
created_at=row["created_at"],
updated_at=row["updated_at"],
+ title=row["title"],
)
order.append(cid)
if row["role"] is not None:
@@ -333,6 +476,130 @@ class PersistentMemoryStore:
conn.close()
return results
+ # nomic-embed-text is an asymmetric retrieval model: queries and stored
+ # documents must be embedded with these task prefixes or similarity collapses
+ # into noise. Cached vectors are document-embeddings (search_document:).
+ _EMBED_QUERY_PREFIX = "search_query: "
+ _EMBED_DOC_PREFIX = "search_document: "
+
+ async def semantic_search_conversations(
+ self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
+ ) -> List[dict]:
+ """Recall past conversations relevant to `query` using hybrid retrieval.
+
+ Combines semantic similarity (embeddings — finds reworded matches with no
+ shared keywords) with the existing lexical substring match (catches exact
+ terms the embedding underweights), unioned and deduped by conversation.
+
+ `embed_fn` is an async callable returning an embedding vector for a string
+ (typically OllamaManager.embed). Messages without a stored vector are
+ embedded and cached on first use (lazy backfill). If embeddings are
+ unavailable (no model / Ollama down) this degrades to pure lexical match,
+ so recall never silently breaks.
+
+ Returns the same shape as `search_conversations`: a list of
+ {id, updated_at, matches:[{role, content}]} with full user+assistant
+ pairs around each match.
+ """
+ if not query or not query.strip():
+ return []
+
+ query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
+ if not query_vec:
+ return self.search_conversations(query, limit=limit)
+
+ conn = self._connect()
+ cur = conn.cursor()
+
+ # Lazy backfill: embed any messages that don't have a vector yet.
+ cur.execute("""
+ SELECT m.id, m.content
+ FROM messages m
+ LEFT JOIN message_vectors v ON v.message_id = m.id
+ WHERE v.message_id IS NULL AND TRIM(m.content) != ''
+ """)
+ missing = cur.fetchall()
+ for row in missing:
+ vec = await embed_fn(self._EMBED_DOC_PREFIX + row["content"][:2000])
+ if vec:
+ cur.execute(
+ "INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
+ (row["id"], json.dumps(vec)),
+ )
+ if missing:
+ conn.commit()
+
+ # Score every stored message against the query vector.
+ cur.execute("""
+ SELECT v.message_id, v.embedding, m.conversation_id
+ FROM message_vectors v
+ JOIN messages m ON m.id = v.message_id
+ """)
+ scored = []
+ for row in cur.fetchall():
+ try:
+ vec = json.loads(row["embedding"])
+ except Exception:
+ continue
+ score = _cosine(query_vec, vec)
+ if score >= min_score:
+ scored.append((score, row["message_id"], row["conversation_id"]))
+
+ scored.sort(reverse=True)
+
+ results: List[dict] = []
+ seen_convs: set = set()
+ for score, message_id, conv_id in scored:
+ if len(results) >= limit:
+ break
+ if conv_id in seen_convs:
+ continue
+ pair = self._exchange_pair(cur, conv_id, message_id)
+ if pair:
+ seen_convs.add(conv_id)
+ results.append(pair)
+
+ conn.close()
+
+ # Hybrid union: fill any remaining slots with lexical matches the
+ # embedding missed (e.g. exact proper nouns), skipping dupes.
+ if len(results) < limit:
+ for conv in self.search_conversations(query, limit=limit):
+ if conv["id"] not in seen_convs:
+ seen_convs.add(conv["id"])
+ results.append(conv)
+ if len(results) >= limit:
+ break
+
+ return results
+
+ def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
+ """Build a {id, updated_at, matches} record with the full user+assistant
+ pair surrounding `message_id`, in the shape search callers expect."""
+ cur.execute(
+ "SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC",
+ (conv_id,),
+ )
+ all_msgs = cur.fetchall()
+ idx = next((i for i, m in enumerate(all_msgs) if m["id"] == message_id), None)
+ if idx is None:
+ return None
+ if all_msgs[idx]["role"] == "user":
+ start, end = idx, min(idx + 1, len(all_msgs) - 1)
+ else:
+ start, end = max(idx - 1, 0), idx
+ matches = [
+ {"role": all_msgs[i]["role"], "content": all_msgs[i]["content"][:500]}
+ for i in range(start, end + 1)
+ ]
+ cur.execute("SELECT updated_at FROM conversations WHERE id = ?", (conv_id,))
+ crow = cur.fetchone()
+ return {
+ "id": conv_id,
+ "updated_at": crow["updated_at"] if crow else 0,
+ "matches": matches,
+ }
+
# -----------------------------
# Settings API
# -----------------------------
@@ -341,6 +608,30 @@ class PersistentMemoryStore:
"temperature": 0.7,
"system_prompt": "",
"timeout": 120,
+ # How long Ollama keeps the model resident in VRAM between messages.
+ # "30m"/"-1" (never unload)/"0" (unload now). Avoids cold-reload latency
+ # when you return to an idle chat. Empty → Ollama's 5-minute default.
+ "keep_alive": "30m",
+ # CPU/GPU offload: -1 = Auto (Ollama auto-fits layers to VRAM).
+ # 0–100 = percent of model layers to force onto the GPU; the
+ # remainder runs on CPU. See OllamaManager.get_model_layers.
+ "gpu_offload": -1,
+ # Memory curator (the model that extracts facts after each exchange).
+ # Empty → same auto-selected model as chat. A dedicated model (e.g.
+ # "mistral:latest") gives better extraction but must share VRAM.
+ "memory_model": "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.81–0.99, while distinct facts
+ # top out ~0.61 — so 0.80 catches updates and never merges unrelated
+ # facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
+ "memory_merge_threshold": 0.80,
}
def get_settings(self) -> Dict[str, Any]:
diff --git a/synapse/nexus_config.py b/synapse/nexus_config.py
old mode 100644
new mode 100755
index 931989a..90adb41
--- a/synapse/nexus_config.py
+++ b/synapse/nexus_config.py
@@ -15,6 +15,12 @@ except Exception:
# --- PROJECT ROOT ---
PROJECT_ROOT = Path(__file__).resolve().parent.parent
+# --- VERSION (single source of truth: the VERSION file at the repo root) ---
+try:
+ VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
+except Exception:
+ VERSION = "0.0.0"
+
# --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models"
@@ -27,16 +33,15 @@ CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp"
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
-PLAYBOOK_DIR = PROJECT_ROOT / "synapse" / "playbooks"
+PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports"
# --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = MEMORY_DIR / "memory.db"
-PLAYBOOK_DB = MEMORY_DB # same file in your setup
# --- LOG FILES ---
-BACKEND_LOG = LOGS_DIR / "backend.log"
+BACKEND_LOG = RUNTIME_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"
@@ -73,7 +78,6 @@ def path(name: str) -> Path:
"uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR,
"memory_db": MEMORY_DB,
- "playbook_db": PLAYBOOK_DB,
"backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG,
"chat_log": CHAT_LOG,
@@ -90,6 +94,7 @@ class Settings:
or `Settings` class for typing/tests.
"""
def __init__(self) -> None:
+ self.version: str = VERSION
self.project_root: Path = PROJECT_ROOT
self.data_dir: Path = DATA_DIR
self.models_dir: Path = MODELS_DIR
@@ -99,7 +104,6 @@ class Settings:
# DB files
self.memory_db: Path = MEMORY_DB
- self.playbook_db: Path = PLAYBOOK_DB
# Logs
self.backend_log: Path = BACKEND_LOG
@@ -112,13 +116,13 @@ class Settings:
def as_dict(self) -> Dict[str, Any]:
return {
+ "version": self.version,
"project_root": str(self.project_root),
"data_dir": str(self.data_dir),
"models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db),
- "playbook_db": str(self.playbook_db),
"ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout,
}
@@ -127,10 +131,10 @@ class Settings:
settings = Settings()
# explicit exports for static checkers and IDEs
-__all__ = ["Settings", "settings", "path",
+__all__ = ["Settings", "settings", "path", "VERSION",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_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"]
# --- quick runtime sanity check when run directly (no side effects on import) ---
diff --git a/synapse/ollama_manager.py b/synapse/ollama_manager.py
old mode 100644
new mode 100755
index d3bf587..c7b9fca
--- a/synapse/ollama_manager.py
+++ b/synapse/ollama_manager.py
@@ -1,15 +1,16 @@
import asyncio
import json
import logging
-import re
import subprocess
import time
import httpx
import os
import signal
from pathlib import Path
+from typing import Any
from .nexus_config import settings
+from .memory.store import store
OLLAMA_PORT = 11434
@@ -131,10 +132,9 @@ def _detect_gpu_backend() -> tuple[str, dict]:
if vulkan_ok:
idx, name = _best_vulkan_device()
- env_overrides: dict = {"OLLAMA_GPU": "vulkan"}
- if idx > 0:
- # Explicitly route Ollama to the discrete GPU when it isn't device 0
- env_overrides["GGML_VK_VISIBLE_DEVICES"] = str(idx)
+ # Always pin to the selected device — without this, Ollama may use the Intel
+ # iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
+ env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides
@@ -153,18 +153,36 @@ def _detect_gpu_backend() -> tuple[str, dict]:
return "cpu", {}
-def _model_score(name: str) -> tuple:
- """Score a model name for auto-selection. Higher tuple = better."""
- lower = name.lower()
- match = re.search(r'(\d+(?:\.\d+)?)[bB]', lower)
- params = float(match.group(1)) if match else 7.0
- # Tier-break: known quality models get a small bonus
- quality = next(
- (i for i, prefix in enumerate(("llama3", "llama2", "mistral", "gemma", "phi", "qwen"), 1)
- if prefix in lower),
- 0,
- )
- return (params, quality)
+# Single source of model auto-selection preference. Ordered so small, GPU-fitting
+# models come first (qwen2.5:3b fits a 4GB card and is a strong all-rounder); the
+# tail differs by task. Prefix-matched against installed model names.
+_MODEL_PREFERENCE = {
+ "chat": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "gemma2", "gemma"),
+ "code": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "codellama", "deepseek-coder", "codegemma"),
+}
+
+
+def _preferred_model(models: list, preference) -> str | None:
+ """First installed model whose name starts with a preference prefix."""
+ for prefix in preference:
+ for m in models:
+ if m.lower().startswith(prefix.lower()):
+ return m
+ return None
+
+
+def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
+ """Assemble the Ollama `options` block from the knobs we expose.
+
+ Returns an empty dict when nothing is set so callers can omit `options`
+ entirely (preserving Ollama's defaults / auto behaviour).
+ """
+ opts: dict = {}
+ if temperature is not None:
+ opts["temperature"] = temperature
+ if num_gpu is not None:
+ opts["num_gpu"] = num_gpu
+ return opts
class OllamaManager:
@@ -181,8 +199,43 @@ class OllamaManager:
self._api_base = settings.ollama_host.rstrip("/")
# Model selection cache
- self._model_cache: str | None = None
- self._model_cache_ts: float = 0.0
+ self._model_cache: dict = {} # intent -> (model, monotonic_ts)
+
+ # Per-model offloadable layer count cache (never changes for a model)
+ self._layer_cache: dict[str, int] = {}
+
+ # How long Ollama keeps the model resident between requests. Applied to
+ # every chat/generate body so the model isn't reloaded on each message.
+ # Overridden from persisted settings at startup. Falsy → omit (Ollama's
+ # 5-minute default).
+ self.keep_alive: str | None = "30m"
+
+ def _apply_keep_alive(self, body: dict) -> dict:
+ """Add `keep_alive` (a top-level Ollama field) to a request body when set."""
+ if self.keep_alive:
+ body["keep_alive"] = self.keep_alive
+ return body
+
+ async def warm(self, model: str | None = None, num_gpu: int | None = None) -> None:
+ """Preload a model so the first request doesn't pay a cold load.
+ An empty-prompt /api/generate is Ollama's documented preload. Pass the
+ same `num_gpu` the real requests use, or the preloaded copy is placed
+ differently and gets reloaded on first use. Best-effort: never raises,
+ so a missing model or down server can't break startup."""
+ try:
+ model = model or await self.select_best_model()
+ if not model:
+ return
+ body = {"model": model, "prompt": "", "stream": False}
+ if num_gpu is not None:
+ body["options"] = {"num_gpu": num_gpu}
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ await client.post(
+ f"{self._api_base}/api/generate", json=self._apply_keep_alive(body),
+ )
+ _log.info("warm: preloaded model=%s num_gpu=%s keep_alive=%s", model, num_gpu, self.keep_alive)
+ except Exception as e:
+ _log.warning("warm: preload failed: %s", e)
def is_available(self):
bin_path = _ollama_bin()
@@ -328,12 +381,12 @@ class OllamaManager:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{self._api_base}/api/generate",
- json={
+ json=self._apply_keep_alive({
"model": model,
"prompt": prompt,
"system": system,
"stream": False,
- },
+ }),
)
elapsed = time.perf_counter() - start
@@ -356,12 +409,12 @@ class OllamaManager:
async with client.stream(
"POST",
f"{self._api_base}/api/generate",
- json={
+ json=self._apply_keep_alive({
"model": model,
"prompt": prompt,
"system": system,
"stream": True,
- },
+ }),
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
@@ -391,17 +444,23 @@ class OllamaManager:
model: str = "mistral",
stream: bool = False,
temperature: float | None = None,
+ num_gpu: int | None = None,
**kwargs,
):
"""Multi-turn chat via /api/chat (accepts a messages array with roles)."""
start = time.perf_counter()
try:
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:
body: dict = {"model": model, "messages": messages, "stream": False}
- if temperature is not None:
- body["options"] = {"temperature": temperature}
+ opts = _chat_options(temperature, num_gpu)
+ if opts:
+ body["options"] = opts
+ self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start
@@ -412,6 +471,33 @@ class OllamaManager:
_log.exception("chat error after %.3fs: %s", elapsed, e)
return None
+ async def embed(self, text: str, model: str = "nomic-embed-text") -> list[float] | None:
+ """Return an embedding vector for `text` via /api/embeddings.
+
+ Returns None on any failure so callers can fall back to lexical search —
+ a missing embedding model should never break chat or recall.
+ """
+ text = (text or "").strip()
+ if not text:
+ return None
+ try:
+ gpu_offload = store.get_settings().get("memory_gpu_offload", 0)
+ num_gpu = await self.resolve_num_gpu(gpu_offload, model)
+ body: dict[str, Any] = {"model": model, "prompt": text}
+ if num_gpu is not None:
+ body["options"] = {"num_gpu": num_gpu}
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.post(
+ f"{self._api_base}/api/embeddings",
+ json=body,
+ )
+ r.raise_for_status()
+ vec = r.json().get("embedding")
+ return vec if vec else None
+ except Exception as e:
+ _log.debug("embed failed (model=%s): %s", model, e)
+ return None
+
async def list_models(self) -> list[str]:
"""Return names of all locally installed Ollama models."""
try:
@@ -422,34 +508,84 @@ class OllamaManager:
except Exception:
return []
- async def select_best_model(self) -> str:
- """Pick the highest-scoring available model, falling back to 'mistral'.
-
- Result is cached for 60 seconds so rapid chat requests don't each
- hit the Ollama API to build the model list.
+ async def select_best_model(self, intent: str = "chat") -> str:
+ """Pick the preferred installed model for `intent` ('chat' or 'code'),
+ falling back to any installed model, then 'mistral'. Cached ~60s per
+ intent so rapid requests don't rebuild the model list each time.
"""
now = time.monotonic()
- if self._model_cache and (now - self._model_cache_ts) < 60:
- return self._model_cache
+ cached = self._model_cache.get(intent)
+ if cached and (now - cached[1]) < 60:
+ return cached[0]
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_ts = now
+ self._model_cache[intent] = (best, now)
return best
def invalidate_model_cache(self):
"""Force next select_best_model() to re-query (e.g. after pull/delete)."""
- self._model_cache = None
- self._model_cache_ts = 0.0
+ self._model_cache = {}
- 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 `.block_count` from /api/show and adds 1 for the
+ non-repeating output layer (Ollama reports e.g. 33 layers for a model
+ with block_count=32). Cached per-model since it never changes.
+ Returns None if the count can't be determined, so callers fall back
+ to Auto (no num_gpu override).
+ """
+ if model in self._layer_cache:
+ return self._layer_cache[model]
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.post(f"{self._api_base}/api/show", json={"model": model})
+ r.raise_for_status()
+ info = r.json().get("model_info", {}) or {}
+ block_count = next(
+ (v for k, v in info.items() if k.endswith(".block_count")), None
+ )
+ layers = int(block_count) + 1 if block_count is not None else None
+ except Exception as e:
+ _log.warning("get_model_layers(%s) failed: %s", model, e)
+ layers = None
+ if layers:
+ self._layer_cache[model] = layers
+ return layers
+
+ async def resolve_num_gpu(self, gpu_offload, model: str) -> int | None:
+ """Convert a stored gpu_offload setting into an Ollama `num_gpu` value.
+
+ `gpu_offload` is -1 for Auto (returns None → no override, Ollama auto-fits)
+ or 0–100 for the percent of the model's layers to force onto the GPU
+ (0 = all CPU/RAM). Layer count is model-specific, resolved from the live
+ model. Returns None on anything unexpected so callers fall back to Auto.
+ """
+ try:
+ pct = int(gpu_offload)
+ except (TypeError, ValueError):
+ return None
+ if pct < 0:
+ return None
+ pct = min(pct, 100)
+ layers = await self.get_model_layers(model)
+ if not layers:
+ return None
+ return max(0, round(pct / 100 * layers))
+
+ async def _chat_stream(self, messages: list, model: str, start: float,
+ temperature: float | None = None, num_gpu: int | None = None):
"""Async generator streaming tokens, then a final __meta__ stats sentinel."""
try:
body: dict = {"model": model, "messages": messages, "stream": True}
- if temperature is not None:
- body["options"] = {"temperature": temperature}
+ opts = _chat_options(temperature, num_gpu)
+ if opts:
+ body["options"] = opts
+ self._apply_keep_alive(body)
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"POST",
@@ -484,7 +620,7 @@ class OllamaManager:
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat stream error after %.3fs: %s", elapsed, e)
- return
+ raise
def initialize_ollama() -> OllamaManager:
diff --git a/synapse/playbook_manager.py b/synapse/playbook_manager.py
old mode 100644
new mode 100755
index 621503b..bd0aefa
--- a/synapse/playbook_manager.py
+++ b/synapse/playbook_manager.py
@@ -1,11 +1,8 @@
-import threading
from typing import List
from .playbooks.store import playbook_store, PlaybookItem
class PlaybookManager:
- _lock = threading.Lock()
-
@classmethod
def _all(cls) -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main)."""
@@ -25,18 +22,10 @@ class PlaybookManager:
@classmethod
def get_system_prompt(cls) -> str:
playbook = cls.get_main_playbook()
- return getattr(playbook, "instructions", "") or "" if playbook else ""
-
- @classmethod
- def render_prompt(cls, user_message: str, variables: dict | None = None) -> str:
- if not variables:
- return user_message
- result = user_message
- 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()
\ No newline at end of file
+ if not playbook:
+ return ""
+ goal = (getattr(playbook, "goal", "") or "").strip()
+ instructions = (getattr(playbook, "instructions", "") or "").strip()
+ if goal and instructions:
+ return f"{goal}\n\n{instructions}"
+ return goal or instructions
\ No newline at end of file
diff --git a/synapse/playbooks/__init__.py b/synapse/playbooks/__init__.py
old mode 100644
new mode 100755
diff --git a/synapse/playbooks/store.py b/synapse/playbooks/store.py
old mode 100644
new mode 100755
index c7ee3a8..c5fea4b
--- a/synapse/playbooks/store.py
+++ b/synapse/playbooks/store.py
@@ -90,18 +90,6 @@ class PlaybookFileStore:
item.order = index
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
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
diff --git a/synapse/search.py b/synapse/search.py
new file mode 100755
index 0000000..377a215
--- /dev/null
+++ b/synapse/search.py
@@ -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 ""