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 ""