refactor(synapse): backend updates, add icons module, relocate playbooks to data/playbooks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Regular → Executable
+1
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
id: f886a94f-b768-49c0-904c-f0f8b556d079
|
||||
title: Writing Assistant
|
||||
goal: Help Jon write clearly and in his own voice — emails, messages, documentation, anything prose — without over-formalizing or padding.
|
||||
tags:
|
||||
- writing
|
||||
- editing
|
||||
- email
|
||||
- communication
|
||||
- documentation
|
||||
- proofreading
|
||||
order: 7
|
||||
instructions: |-
|
||||
Your personality:
|
||||
- Match Jon's register — casual and direct by default, more formal only when the context calls for it
|
||||
- Never add corporate warmth, filler phrases, or hedging that Jon wouldn't use himself
|
||||
- Occasionally witty when appropriate, but don't force it
|
||||
|
||||
Your responsibilities:
|
||||
- When editing, preserve Jon's voice — fix clarity and correctness, don't rewrite his personality out of it
|
||||
- When drafting from scratch, ask for the audience and intent if it's not clear; otherwise just write something and let him redirect
|
||||
- Flag when something reads as too formal, too casual, or likely to land wrong for its audience
|
||||
- Keep it tight — cut filler, passive constructions, and redundancy unless Jon's going for a specific effect
|
||||
- For emails: lead with the point, put context after, end without hollow sign-off phrases unless the situation requires them
|
||||
- For documentation: favor short sentences, concrete examples, and active voice over comprehensive coverage
|
||||
|
||||
Rules:
|
||||
- Don't add exclamation points, emoji, or enthusiasm Jon didn't put there
|
||||
- If the ask is ambiguous, make a reasonable call and note the assumption rather than asking a bunch of clarifying questions
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||
Regular → Executable
Regular → Executable
+10
-40
@@ -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__"):
|
||||
# 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)
|
||||
return
|
||||
if hasattr(result, "__iter__") and not isinstance(result, (str, bytes)):
|
||||
for item in result:
|
||||
yield str(item)
|
||||
return
|
||||
yield str(result)
|
||||
return
|
||||
|
||||
if hasattr(maybe_iterable, "__iter__") and not isinstance(maybe_iterable, (str, bytes)):
|
||||
for item in maybe_iterable:
|
||||
yield str(item)
|
||||
return
|
||||
|
||||
yield str(maybe_iterable)
|
||||
|
||||
|
||||
# -------------------------
|
||||
@@ -157,6 +142,7 @@ async def stream_chat_response(
|
||||
system = metadata.get("system", "")
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Composite app icons onto the NexusOS nexus underlay tile."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
|
||||
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
||||
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
|
||||
SIZES = [16, 22, 24, 32, 48, 64, 128]
|
||||
|
||||
_ALLOWED_ROOTS = [
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
"/usr/local/share/icons",
|
||||
"/opt",
|
||||
str(Path.home() / ".local/share/icons"),
|
||||
str(Path.home() / ".icons"),
|
||||
str(_ROOT / "assets"),
|
||||
]
|
||||
|
||||
_UNDERLAY_FALLBACK = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#b040c0"/>
|
||||
<stop offset="100%" stop-color="#4a0050"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#frame)"/>
|
||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="#120018"/>
|
||||
<rect x="16" y="16" width="96" height="96" rx="18" fill="#262626"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
_RING_FALLBACK = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<radialGradient id="ring" cx="50%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#8cc63f"/>
|
||||
<stop offset="100%" stop-color="#5a9020"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#b040c0"/>
|
||||
<stop offset="100%" stop-color="#4a0050"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#ring)"/>
|
||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#frame)"/>
|
||||
<rect x="12" y="12" width="104" height="104" rx="22" fill="#120018"/>
|
||||
<rect x="18" y="18" width="92" height="92" rx="16" fill="#262626"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def _load_underlay(nexus_ring: bool = False) -> str:
|
||||
"""Return SVG text for the underlay, falling back to inline if file missing."""
|
||||
target = RING_FILE if nexus_ring else UNDERLAY_FILE
|
||||
fallback = _RING_FALLBACK if nexus_ring else _UNDERLAY_FALLBACK
|
||||
if target.exists():
|
||||
return target.read_text()
|
||||
return fallback
|
||||
|
||||
|
||||
def _inkscape_render(svg_path: str, out_path: str, size: int) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"inkscape", svg_path,
|
||||
"--export-type=png",
|
||||
f"--export-filename={out_path}",
|
||||
f"--export-width={size}",
|
||||
f"--export-height={size}",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _composite_png(underlay_png: str, app_png: str, out_path: str, frac: float, size: int) -> None:
|
||||
"""Composite app icon centred on the underlay at given fractional size."""
|
||||
app_size = max(1, int(size * frac))
|
||||
offset = (size - app_size) // 2
|
||||
subprocess.run(
|
||||
[
|
||||
"convert",
|
||||
underlay_png,
|
||||
"(", app_png, "-resize", f"{app_size}x{app_size}", ")",
|
||||
"-gravity", "Center",
|
||||
"-geometry", f"+0+0",
|
||||
"-composite",
|
||||
out_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _is_allowed_path(path: str) -> bool:
|
||||
p = Path(os.path.realpath(path))
|
||||
return p.is_file() and any(
|
||||
p == root or root in p.parents
|
||||
for root in (Path(r).resolve() for r in _ALLOWED_ROOTS)
|
||||
)
|
||||
|
||||
|
||||
def _safe_component(value: str, label: str) -> str:
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._-]+", value):
|
||||
raise ValueError(f"Invalid icon {label}")
|
||||
return value
|
||||
|
||||
|
||||
def brand_icon(
|
||||
src_path: str,
|
||||
output_name: str,
|
||||
frac: float = 0.60,
|
||||
round_mask: bool = False,
|
||||
nexus_ring: bool = False,
|
||||
reload: bool = True,
|
||||
category: str = "apps",
|
||||
) -> None:
|
||||
"""Brand a single icon and write PNGs to the NexusOS icon theme at all sizes."""
|
||||
if not _is_allowed_path(src_path):
|
||||
raise ValueError(f"Icon source path not in allowed roots: {src_path}")
|
||||
output_name = _safe_component(output_name, "name")
|
||||
category = _safe_component(category, "category")
|
||||
|
||||
underlay_svg_text = _load_underlay(nexus_ring)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
underlay_svg = tmp_path / "underlay.svg"
|
||||
underlay_svg.write_text(underlay_svg_text)
|
||||
|
||||
for size in SIZES:
|
||||
out_dir = ICONS_OUT / f"{size}x{size}" / category
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / f"{output_name}.png"
|
||||
|
||||
# Break symlinks before writing; also remove any same-name .svg so
|
||||
# GTK doesn't prefer the old SVG over our new branded PNG.
|
||||
if out_path.is_symlink() or out_path.exists():
|
||||
out_path.unlink()
|
||||
svg_path = out_dir / f"{output_name}.svg"
|
||||
if svg_path.exists() or svg_path.is_symlink():
|
||||
svg_path.unlink()
|
||||
|
||||
underlay_png = str(tmp_path / f"underlay_{size}.png")
|
||||
_inkscape_render(str(underlay_svg), underlay_png, size)
|
||||
|
||||
# Resize app icon source to a temp PNG for compositing.
|
||||
# `-background none` is REQUIRED: ImageMagick rasterizes transparent
|
||||
# SVGs onto an opaque WHITE canvas by default, which shows up as a
|
||||
# white plate/border behind logos with transparent corners
|
||||
# (e.g. VSCode, Edge). It must precede the input to affect the SVG.
|
||||
app_png = str(tmp_path / f"app_{size}.png")
|
||||
app_size = max(1, int(size * frac))
|
||||
subprocess.run(
|
||||
["convert", "-background", "none", src_path,
|
||||
"-resize", f"{app_size}x{app_size}", app_png],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"convert", underlay_png,
|
||||
"(", app_png, ")",
|
||||
"-gravity", "Center",
|
||||
"-composite",
|
||||
str(out_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
if reload:
|
||||
apply_icon_cache()
|
||||
|
||||
|
||||
def apply_icon_cache() -> None:
|
||||
"""Rebuild the GTK icon cache and reload the panel."""
|
||||
import pwd
|
||||
uid = os.getuid()
|
||||
# systemd user session bus — needed for xfce4-panel -r to reach the running session
|
||||
dbus_addr = os.environ.get(
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
f"unix:path=/run/user/{uid}/bus",
|
||||
)
|
||||
_env = {
|
||||
**os.environ,
|
||||
"DISPLAY": os.environ.get("DISPLAY", ":0"),
|
||||
"DBUS_SESSION_BUS_ADDRESS": dbus_addr,
|
||||
"HOME": pwd.getpwuid(uid).pw_dir,
|
||||
}
|
||||
|
||||
subprocess.run(
|
||||
["gtk-update-icon-cache", "-f", str(ICONS_OUT)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Force every running GTK app (whisker menu, panel, Thunar) to drop its
|
||||
# in-memory icon cache. A panel reload alone does NOT do this — GTK only
|
||||
# re-resolves icons when the icon-theme NAME changes. Toggling to another
|
||||
# theme and back fires the "theme-changed" signal that triggers the reload.
|
||||
import time
|
||||
current = "NexusOS"
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName"],
|
||||
env=_env, capture_output=True, text=True,
|
||||
)
|
||||
if out.stdout.strip():
|
||||
current = out.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
alt = "Papirus-Dark" if current != "Papirus-Dark" else "Adwaita"
|
||||
subprocess.run(
|
||||
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", alt],
|
||||
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
subprocess.run(
|
||||
["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", current],
|
||||
env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Restart Plank so it re-resolves icons from the updated theme
|
||||
subprocess.run(["pkill", "plank"], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
["plank"],
|
||||
env=_env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Resolve desktop app icon paths and scan installed applications."""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import configparser
|
||||
|
||||
_HOME = Path.home()
|
||||
|
||||
ICON_SEARCH_DIRS = [
|
||||
_HOME / ".local/share/icons",
|
||||
Path("/usr/share/icons"),
|
||||
Path("/usr/share/pixmaps"),
|
||||
Path("/usr/local/share/icons"),
|
||||
]
|
||||
|
||||
_THEME_ORDER = ["hicolor", "Papirus-Dark", "Papirus", "gnome", "Adwaita", "breeze"]
|
||||
_ICON_EXTS = [".png", ".svg", ".xpm"]
|
||||
|
||||
DESKTOP_DIRS = [
|
||||
_HOME / ".local/share/applications",
|
||||
Path("/usr/local/share/applications"),
|
||||
Path("/usr/share/applications"),
|
||||
]
|
||||
|
||||
_NEXUS_ICONS = _HOME / ".icons" / "NexusOS"
|
||||
|
||||
|
||||
def _icon_stem(icon_name: str) -> str:
|
||||
"""Return the icon's name for theme lookup.
|
||||
|
||||
Only strips a real image extension (.png/.svg/.xpm). Reverse-DNS icon
|
||||
names like ``com.visualstudio.code`` or ``org.gnome.Files`` must be kept
|
||||
intact — Path.stem would wrongly treat the final dotted segment as an
|
||||
extension and truncate it.
|
||||
"""
|
||||
p = Path(icon_name)
|
||||
if p.suffix.lower() in _ICON_EXTS:
|
||||
return p.stem
|
||||
return p.name
|
||||
|
||||
|
||||
def resolve_icon(icon_name: str, size: int = 128) -> Optional[str]:
|
||||
"""Return absolute path to an icon file, or None if not found."""
|
||||
if not icon_name:
|
||||
return None
|
||||
|
||||
# Absolute path — use directly if it exists
|
||||
p = Path(icon_name)
|
||||
if p.is_absolute() and p.exists():
|
||||
return str(p)
|
||||
|
||||
# Strip extension for name-based search
|
||||
stem = _icon_stem(icon_name)
|
||||
|
||||
size_dirs = [f"{size}x{size}", f"{size}x{size}@2x", "scalable"]
|
||||
|
||||
for search_root in ICON_SEARCH_DIRS:
|
||||
if not search_root.is_dir():
|
||||
continue
|
||||
for theme in _THEME_ORDER:
|
||||
theme_dir = search_root / theme
|
||||
if not theme_dir.is_dir():
|
||||
continue
|
||||
for size_dir in size_dirs:
|
||||
for ctx in ("apps", "categories", "places", "status", "actions"):
|
||||
ctx_dir = theme_dir / size_dir / ctx
|
||||
if not ctx_dir.is_dir():
|
||||
continue
|
||||
for ext in _ICON_EXTS:
|
||||
candidate = ctx_dir / f"{stem}{ext}"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
# pixmaps fallback
|
||||
for search_root in ICON_SEARCH_DIRS:
|
||||
if not search_root.is_dir():
|
||||
continue
|
||||
if search_root.name == "pixmaps":
|
||||
for ext in _ICON_EXTS:
|
||||
candidate = search_root / f"{stem}{ext}"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
for ext in _ICON_EXTS:
|
||||
candidate = Path("/usr/share/pixmaps") / f"{stem}{ext}"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_branded(icon_name: str) -> bool:
|
||||
"""Return True if a NexusOS-branded PNG already exists for this icon name."""
|
||||
stem = _icon_stem(icon_name)
|
||||
return (_NEXUS_ICONS / "128x128" / "apps" / f"{stem}.png").exists()
|
||||
|
||||
|
||||
def scan_apps() -> list[dict]:
|
||||
"""Return list of installed apps with resolved icon paths."""
|
||||
seen: dict[str, dict] = {}
|
||||
|
||||
for desktop_dir in DESKTOP_DIRS:
|
||||
if not desktop_dir.is_dir():
|
||||
continue
|
||||
for desktop_file in sorted(desktop_dir.glob("*.desktop")):
|
||||
cfg = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
cfg.read(str(desktop_file), encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not cfg.has_section("Desktop Entry"):
|
||||
continue
|
||||
de = cfg["Desktop Entry"]
|
||||
|
||||
if de.get("NoDisplay", "false").lower() == "true":
|
||||
continue
|
||||
if de.get("Hidden", "false").lower() == "true":
|
||||
continue
|
||||
if de.get("Type", "") != "Application":
|
||||
continue
|
||||
|
||||
name = de.get("Name", desktop_file.stem)
|
||||
icon_name = de.get("Icon", "")
|
||||
if not icon_name:
|
||||
continue
|
||||
|
||||
icon_path = resolve_icon(icon_name)
|
||||
already_branded = _is_branded(icon_name)
|
||||
|
||||
key = name.lower()
|
||||
if key not in seen:
|
||||
seen[key] = {
|
||||
"name": name,
|
||||
"icon_name": _icon_stem(icon_name),
|
||||
"icon_path": icon_path,
|
||||
"already_branded": already_branded,
|
||||
"desktop_file": str(desktop_file),
|
||||
}
|
||||
|
||||
return sorted(seen.values(), key=lambda x: x["name"].lower())
|
||||
Regular → Executable
+411
-111
@@ -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
|
||||
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 model,
|
||||
model=meta.get("model") or final_model,
|
||||
tokens=meta.get("tokens"),
|
||||
)
|
||||
except Exception as e:
|
||||
yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n"
|
||||
return
|
||||
|
||||
# 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
|
||||
# -------------------------
|
||||
|
||||
Regular → Executable
+105
-31
@@ -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": "<section name, or one from the list above>", "text": "<concise fact about Jon, third person>"}}
|
||||
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": "<short section name>", "text": "<concise fact about Jon, third person>"}}
|
||||
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 []
|
||||
|
||||
Regular → Executable
+93
-10
@@ -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,
|
||||
)
|
||||
if result:
|
||||
item = MemoryItem(
|
||||
id=str(uuid.uuid4()),
|
||||
section=result["section"],
|
||||
text=result["text"],
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
# Embed existing facts once so each new fact can be matched against them.
|
||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||
# rather than appending a copy. Best effort: if embeddings are down we
|
||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||
existing_embeds: dict = {}
|
||||
if results and 0 < merge_threshold < 1:
|
||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||
|
||||
saved = []
|
||||
for result in results:
|
||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||
match_id, best = None, 0.0
|
||||
if new_vec:
|
||||
for eid, ev in existing_embeds.items():
|
||||
sim = _cosine(new_vec, ev)
|
||||
if sim > best:
|
||||
best, match_id = sim, eid
|
||||
if best < merge_threshold:
|
||||
match_id = None
|
||||
|
||||
target = store.get(match_id) if match_id else None
|
||||
if target:
|
||||
# Near-duplicate of an existing fact — overwrite with the newer
|
||||
# statement, keeping the original id/section/position.
|
||||
updated = MemoryItem(id=target.id, section=target.section,
|
||||
text=result["text"], tags=target.tags)
|
||||
store.update(updated)
|
||||
if new_vec:
|
||||
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
||||
saved.append({"id": updated.id, "section": updated.section,
|
||||
"text": updated.text, "updated": True})
|
||||
else:
|
||||
item = MemoryItem(id=str(uuid.uuid4()),
|
||||
section=result["section"], text=result["text"])
|
||||
store.add(item)
|
||||
return {"saved": True, "id": item.id, "section": item.section, "text": item.text}
|
||||
if new_vec:
|
||||
existing_embeds[item.id] = new_vec
|
||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||
if saved:
|
||||
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
||||
return {"saved": True, "items": saved, **first}
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
Regular → Executable
+300
-9
@@ -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]:
|
||||
|
||||
Regular → Executable
+12
-8
@@ -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) ---
|
||||
|
||||
Regular → Executable
+178
-42
@@ -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 `<arch>.block_count` from /api/show and adds 1 for the
|
||||
non-repeating output layer (Ollama reports e.g. 33 layers for a model
|
||||
with block_count=32). Cached per-model since it never changes.
|
||||
Returns None if the count can't be determined, so callers fall back
|
||||
to Auto (no num_gpu override).
|
||||
"""
|
||||
if model in self._layer_cache:
|
||||
return self._layer_cache[model]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(f"{self._api_base}/api/show", json={"model": model})
|
||||
r.raise_for_status()
|
||||
info = r.json().get("model_info", {}) or {}
|
||||
block_count = next(
|
||||
(v for k, v in info.items() if k.endswith(".block_count")), None
|
||||
)
|
||||
layers = int(block_count) + 1 if block_count is not None else None
|
||||
except Exception as e:
|
||||
_log.warning("get_model_layers(%s) failed: %s", model, e)
|
||||
layers = None
|
||||
if layers:
|
||||
self._layer_cache[model] = layers
|
||||
return layers
|
||||
|
||||
async def resolve_num_gpu(self, gpu_offload, model: str) -> int | None:
|
||||
"""Convert a stored gpu_offload setting into an Ollama `num_gpu` value.
|
||||
|
||||
`gpu_offload` is -1 for Auto (returns None → no override, Ollama auto-fits)
|
||||
or 0–100 for the percent of the model's layers to force onto the GPU
|
||||
(0 = all CPU/RAM). Layer count is model-specific, resolved from the live
|
||||
model. Returns None on anything unexpected so callers fall back to Auto.
|
||||
"""
|
||||
try:
|
||||
pct = int(gpu_offload)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if pct < 0:
|
||||
return None
|
||||
pct = min(pct, 100)
|
||||
layers = await self.get_model_layers(model)
|
||||
if not layers:
|
||||
return None
|
||||
return max(0, round(pct / 100 * layers))
|
||||
|
||||
async def _chat_stream(self, messages: list, model: str, start: float,
|
||||
temperature: float | None = None, num_gpu: int | None = None):
|
||||
"""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:
|
||||
|
||||
Regular → Executable
+7
-18
@@ -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()
|
||||
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
|
||||
Regular → Executable
Regular → Executable
-12
@@ -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)
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_SEARCH_TRIGGERS = frozenset({
|
||||
# Time-sensitive single words
|
||||
"news", "weather", "today", "tonight", "yesterday", "price", "stock",
|
||||
# Phrases that imply freshness or external lookup
|
||||
"latest ", "right now", "this week", "this month", "recently",
|
||||
"who is ", "what is ", "when did ", "how much does", "how much is",
|
||||
"look up", "search for", "find out", "release date",
|
||||
"just released", "just announced", "just launched",
|
||||
"current version", "current price",
|
||||
})
|
||||
|
||||
|
||||
def needs_web_search(message: str) -> bool:
|
||||
lower = message.lower()
|
||||
return any(kw in lower for kw in _SEARCH_TRIGGERS)
|
||||
|
||||
|
||||
def web_search(query: str, max_results: int = 4) -> str:
|
||||
"""Search DuckDuckGo and return formatted result snippets.
|
||||
|
||||
Returns an empty string on any failure so callers can treat it as
|
||||
optional context — a failed search should never break a chat response.
|
||||
"""
|
||||
try:
|
||||
from duckduckgo_search import DDGS
|
||||
with DDGS() as ddgs:
|
||||
results = list(ddgs.text(query, max_results=max_results))
|
||||
if not results:
|
||||
return ""
|
||||
parts = []
|
||||
for i, r in enumerate(results, 1):
|
||||
title = r.get("title", "").strip()
|
||||
body = r.get("body", "").strip()
|
||||
href = r.get("href", "").strip()
|
||||
parts.append(f"{i}. **{title}**\n{body}\nSource: {href}")
|
||||
return "\n\n".join(parts)
|
||||
except Exception:
|
||||
return ""
|
||||
Reference in New Issue
Block a user