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:
|
Rules:
|
||||||
- Never refer to yourself as an AI or language model
|
- Never refer to yourself as an AI or language model
|
||||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||||
|
- Never restate, echo, rephrase, or summarize Jon's own message back to him. Do NOT open with a header or a recap of what he just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
|
||||||
- Keep responses concise unless Jon asks for detail
|
- Keep responses concise unless Jon asks for detail
|
||||||
- If you don't know something, say so plainly and help find the answer
|
- If you don't know something, say so plainly and help find the answer
|
||||||
@@ -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
+12
-42
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import AsyncGenerator, Dict, List, Optional, Any
|
from typing import AsyncGenerator, Dict, List, Optional, Any
|
||||||
@@ -58,6 +59,7 @@ async def generate_chat_response(
|
|||||||
system = metadata.get("system", "")
|
system = metadata.get("system", "")
|
||||||
model = metadata.get("model") or "mistral"
|
model = metadata.get("model") or "mistral"
|
||||||
temperature = metadata.get("temperature")
|
temperature = metadata.get("temperature")
|
||||||
|
num_gpu = metadata.get("num_gpu")
|
||||||
|
|
||||||
messages: List[Dict[str, str]] = []
|
messages: List[Dict[str, str]] = []
|
||||||
if system:
|
if system:
|
||||||
@@ -76,10 +78,11 @@ async def generate_chat_response(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
manager.chat(messages=messages, model=model, stream=False, temperature=temperature),
|
manager.chat(messages=messages, model=model, stream=False, temperature=temperature, num_gpu=num_gpu),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response_text = result if isinstance(result, str) else str(result)
|
response_text = result if isinstance(result, str) else str(result)
|
||||||
|
|
||||||
preview = response_text[:500].replace("\n", " ")
|
preview = response_text[:500].replace("\n", " ")
|
||||||
_synapse_trace(f"{preview}{'…' if len(response_text) > 500 else ''}\n{'─' * 50}\n")
|
_synapse_trace(f"{preview}{'…' if len(response_text) > 500 else ''}\n{'─' * 50}\n")
|
||||||
_logger.info("generate_chat_response: completed model=%s", model)
|
_logger.info("generate_chat_response: completed model=%s", model)
|
||||||
@@ -115,30 +118,12 @@ async def _aiter_with_timeout(aiterable, timeout: Optional[float]):
|
|||||||
# Normalizer for many return shapes
|
# Normalizer for many return shapes
|
||||||
# -------------------------
|
# -------------------------
|
||||||
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
|
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
|
||||||
if hasattr(maybe_iterable, "__aiter__"):
|
# The sole caller passes manager.chat(stream=True) — an async-def call, i.e.
|
||||||
async for item in maybe_iterable:
|
# a coroutine that resolves to an async generator. Await it if needed, then
|
||||||
yield str(item)
|
# stream the tokens.
|
||||||
return
|
result = await maybe_iterable if asyncio.iscoroutine(maybe_iterable) else maybe_iterable
|
||||||
|
async for item in result:
|
||||||
if asyncio.iscoroutine(maybe_iterable):
|
yield str(item)
|
||||||
result = await maybe_iterable
|
|
||||||
if hasattr(result, "__aiter__"):
|
|
||||||
async for item in result:
|
|
||||||
yield str(item)
|
|
||||||
return
|
|
||||||
if hasattr(result, "__iter__") and not isinstance(result, (str, bytes)):
|
|
||||||
for item in result:
|
|
||||||
yield str(item)
|
|
||||||
return
|
|
||||||
yield str(result)
|
|
||||||
return
|
|
||||||
|
|
||||||
if hasattr(maybe_iterable, "__iter__") and not isinstance(maybe_iterable, (str, bytes)):
|
|
||||||
for item in maybe_iterable:
|
|
||||||
yield str(item)
|
|
||||||
return
|
|
||||||
|
|
||||||
yield str(maybe_iterable)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -157,6 +142,7 @@ async def stream_chat_response(
|
|||||||
system = metadata.get("system", "")
|
system = metadata.get("system", "")
|
||||||
model = metadata.get("model") or "mistral"
|
model = metadata.get("model") or "mistral"
|
||||||
temperature = metadata.get("temperature")
|
temperature = metadata.get("temperature")
|
||||||
|
num_gpu = metadata.get("num_gpu")
|
||||||
|
|
||||||
# Build messages array for /api/chat multi-turn format
|
# Build messages array for /api/chat multi-turn format
|
||||||
messages: List[Dict[str, str]] = []
|
messages: List[Dict[str, str]] = []
|
||||||
@@ -175,7 +161,7 @@ async def stream_chat_response(
|
|||||||
_synapse_trace(f"USR: {user_message}\n{'─' * 50}\n")
|
_synapse_trace(f"USR: {user_message}\n{'─' * 50}\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature)
|
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu)
|
||||||
async_gen = _normalize_to_async_generator(maybe_iter)
|
async_gen = _normalize_to_async_generator(maybe_iter)
|
||||||
|
|
||||||
buffer_parts: list[str] = []
|
buffer_parts: list[str] = []
|
||||||
@@ -224,19 +210,3 @@ async def stream_chat_response(
|
|||||||
except Exception:
|
except Exception:
|
||||||
_logger.exception("stream_chat_response: unexpected error during streaming")
|
_logger.exception("stream_chat_response: unexpected error during streaming")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Synchronous convenience wrappers
|
|
||||||
# -------------------------
|
|
||||||
def generate_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
||||||
return asyncio.run(generate_chat_response(user_message, metadata=metadata, timeout=timeout))
|
|
||||||
|
|
||||||
|
|
||||||
def stream_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None):
|
|
||||||
async def _collect():
|
|
||||||
chunks = []
|
|
||||||
async for c in stream_chat_response(user_message, metadata=metadata, timeout=timeout):
|
|
||||||
chunks.append(c)
|
|
||||||
return chunks
|
|
||||||
return asyncio.run(_collect())
|
|
||||||
|
|||||||
@@ -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
+415
-115
@@ -4,46 +4,169 @@ from __future__ import annotations
|
|||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
import json as _json
|
import json as _json
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
from collections import Counter as _Counter
|
||||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import os as _os
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, Body
|
from fastapi import FastAPI, HTTPException, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
|
|
||||||
from .nexus_config import settings
|
from .nexus_config import settings, VERSION
|
||||||
from .chat import generate_chat_response, stream_chat_response
|
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
||||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
||||||
from .playbook_manager import PlaybookManager
|
from .playbook_manager import PlaybookManager
|
||||||
|
|
||||||
def _render_memory_block(facts) -> str:
|
def _render_memory_block(facts) -> str:
|
||||||
"""Render memory items as grouped ## Section / - bullet markdown."""
|
"""Render memory items as grouped ## Section / - bullet markdown.
|
||||||
|
Leading-space indent on a fact's text is preserved so nested bullets stay nested.
|
||||||
|
|
||||||
|
Behavioural "Instructions" entries are skipped — they belong in the playbook,
|
||||||
|
not the "what you know about Jon" facts block. Injecting imperative directives
|
||||||
|
("provide full rewrites", "include file paths") as facts pushes a small model
|
||||||
|
to reformat/organise the user's input instead of conversing."""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
sections: dict = defaultdict(list)
|
sections: dict = defaultdict(list)
|
||||||
for item in facts:
|
for item in facts:
|
||||||
|
if (item.section or "").strip().lower() == "instructions":
|
||||||
|
continue
|
||||||
sections[item.section or "General"].append(item.text)
|
sections[item.section or "General"].append(item.text)
|
||||||
parts = []
|
parts = []
|
||||||
for section, lines in sections.items():
|
for section, lines in sections.items():
|
||||||
parts.append(f"## {section}\n" + "\n".join(f" - {line}" for line in lines))
|
rendered = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.lstrip(" ")
|
||||||
|
indent = len(line) - len(stripped)
|
||||||
|
rendered.append(" " * indent + f" - {stripped}")
|
||||||
|
parts.append(f"## {section}\n" + "\n".join(rendered))
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
async def _auto_select_model() -> str:
|
# Preamble wrapped around the injected memory facts. It stays anti-recite for
|
||||||
|
# everyday chat, but explicitly permits surfacing facts when Jon asks about
|
||||||
|
# himself / to be quizzed — the old absolute "do NOT acknowledge these facts"
|
||||||
|
# made small models play dumb on exactly that request.
|
||||||
|
_MEMORY_PREAMBLE = (
|
||||||
|
"\n\n---\nBackground on Jon — use this to personalize your replies. Don't dump "
|
||||||
|
"or recite these facts unprompted, but when Jon asks about himself or asks you "
|
||||||
|
"to recall or quiz what you know, use them directly and specifically:\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CODING_KEYWORDS = frozenset({
|
||||||
|
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
||||||
|
"debug", "fix", "refactor", "script", "program", "syntax", "compile", "import",
|
||||||
|
"module", "library", "algorithm", "loop", "array", "string", "integer", "boolean",
|
||||||
|
"return", "def", "const", "let", "var", "test", "api", "endpoint", "database",
|
||||||
|
"query", "sql", "bash", "terminal", "command", "package", "dependency",
|
||||||
|
"python", "javascript", "typescript", "rust", "golang", "java", "html", "css",
|
||||||
|
".py", ".js", ".ts", ".jsx", ".tsx", ".sh", ".json", ".yaml", ".sql", ".css",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _detect_intent(message: str) -> str:
|
||||||
|
lower = message.lower()
|
||||||
|
return "code" if any(kw in lower for kw in _CODING_KEYWORDS) else "chat"
|
||||||
|
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
def _route_playbooks(message: str, candidates: list) -> list:
|
||||||
|
"""Return the reference playbook(s) whose tags appear directly in the user's
|
||||||
|
message. Returns [] when nothing matches, so casual chat doesn't drag in a
|
||||||
|
specialist playbook.
|
||||||
|
|
||||||
|
Dropped an old memory-fallback tier that, when the message matched no tags,
|
||||||
|
scored playbook tags against the user's WHOLE memory corpus. Because memory
|
||||||
|
permanently mentions e.g. the home network, that injected the Home Network
|
||||||
|
playbook (~1.7k chars) into unrelated chats. Direct references like "my truck"
|
||||||
|
already match here ('truck' is a Ford tag), so the fallback was mostly noise.
|
||||||
|
"""
|
||||||
|
if not candidates or not message:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
msg_tokens = set(_re.findall(r'\b\w+\b', message.lower()))
|
||||||
|
scores = [(sum(1 for tag in pb.tags if tag.lower() in msg_tokens), pb) for pb in candidates]
|
||||||
|
best = max(s for s, _ in scores)
|
||||||
|
if best > 0:
|
||||||
|
return [pb for s, pb in scores if s == best]
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _auto_select_model(message: str = "") -> str:
|
||||||
|
"""A pinned settings.model wins; otherwise pick the preferred installed model
|
||||||
|
for the detected intent. The preference lists live in one place now —
|
||||||
|
ollama_manager._MODEL_PREFERENCE, via select_best_model(intent)."""
|
||||||
try:
|
try:
|
||||||
s = store.get_settings()
|
s = store.get_settings()
|
||||||
if s.get("model"):
|
if s.get("model"):
|
||||||
return s["model"]
|
return s["model"]
|
||||||
return await get_ollama_manager().select_best_model()
|
intent = _detect_intent(message) if message else "chat"
|
||||||
|
return await get_ollama_manager().select_best_model(intent)
|
||||||
except Exception:
|
except Exception:
|
||||||
return getattr(settings, "default_model", None) or "mistral"
|
return getattr(settings, "default_model", None) or "mistral"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_TITLE_SYSTEM_PROMPT = (
|
||||||
|
"You generate a short, descriptive title for a chat conversation based on the "
|
||||||
|
"user's first message. Reply with ONLY the title: 3 to 6 words, no quotes, no "
|
||||||
|
"trailing punctuation, no preamble. Use plain text in title case. The title "
|
||||||
|
"names the TOPIC — never echo the user's question or phrase it as a question.\n\n"
|
||||||
|
"Examples:\n"
|
||||||
|
"Message: can you help me fix a bug in my python script?\n"
|
||||||
|
"Title: Python Script Bug Fix\n"
|
||||||
|
"Message: what's a good recipe for sourdough bread?\n"
|
||||||
|
"Title: Sourdough Bread Recipe\n"
|
||||||
|
"Message: i want to try out your memory, ask me questions about myself\n"
|
||||||
|
"Title: Testing Memory Recall"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_conversation_title(first_message: str, model: str) -> Optional[str]:
|
||||||
|
"""Ask the LLM for a concise title. Titling is a background 'curation' task,
|
||||||
|
so it runs on the CURATOR model (mistral) rather than the chat model: the 7B
|
||||||
|
follows the terse title format better than the 3B chat model, and it's already
|
||||||
|
warm in RAM. Crucially we pass the curator's own num_gpu (CPU) so we hit that
|
||||||
|
warm CPU-resident instance — same memory pool, no reload, and the GPU chat
|
||||||
|
model is never disturbed. `model` is only a fallback if no curator is set.
|
||||||
|
Best-effort: returns None on any failure so titling never breaks the chat."""
|
||||||
|
snippet = first_message.strip()[:1000]
|
||||||
|
if not snippet:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
s = store.get_settings()
|
||||||
|
title_model = s.get("memory_model") or model
|
||||||
|
num_gpu = await get_ollama_manager().resolve_num_gpu(s.get("memory_gpu_offload", 0), title_model)
|
||||||
|
result = await generate_chat_response(
|
||||||
|
user_message=snippet,
|
||||||
|
metadata={"system": _TITLE_SYSTEM_PROMPT, "model": title_model,
|
||||||
|
"temperature": 0.2, "num_gpu": num_gpu},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
title = (result.get("response") or "").strip()
|
||||||
|
# Strip stray quotes/wrapping the model sometimes adds, collapse whitespace.
|
||||||
|
title = title.strip().strip('"').strip("'").splitlines()[0].strip()
|
||||||
|
# Drop a "Title:" prefix the model may echo from the examples, and strip
|
||||||
|
# trailing punctuation the prompt forbids but small models still add.
|
||||||
|
if title.lower().startswith("title:"):
|
||||||
|
title = title[len("title:"):].strip()
|
||||||
|
title = " ".join(title.split()).rstrip("?.!,;:")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return title[:120]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
from .memory.store import store, MemoryItem
|
from .memory.store import store, MemoryItem
|
||||||
from .playbooks.store import playbook_store, PlaybookItem
|
from .playbooks.store import playbook_store, PlaybookItem
|
||||||
|
from .search import needs_web_search, web_search
|
||||||
|
|
||||||
MEMORY_SERVICE = "http://localhost:8001"
|
MEMORY_SERVICE = "http://localhost:8001"
|
||||||
|
|
||||||
app = FastAPI(title="Synapse Backend", version="1.0")
|
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||||
|
|
||||||
# Alias for startup scripts
|
# Alias for startup scripts
|
||||||
sio_app = app
|
sio_app = app
|
||||||
@@ -73,6 +196,10 @@ async def startup_event():
|
|||||||
global ollama
|
global ollama
|
||||||
try:
|
try:
|
||||||
ollama = await initialize_ollama_async()
|
ollama = await initialize_ollama_async()
|
||||||
|
# Keep the model resident per the persisted setting, then preload the
|
||||||
|
# model the first chat would pick so that message doesn't pay a cold load.
|
||||||
|
ollama.keep_alive = store.get_settings().get("keep_alive") or ollama.keep_alive
|
||||||
|
_asyncio.create_task(ollama.warm(await _auto_select_model()))
|
||||||
print("[Synapse] Ollama service is running.")
|
print("[Synapse] Ollama service is running.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Start in degraded mode — chat endpoints will return errors until Ollama
|
# Start in degraded mode — chat endpoints will return errors until Ollama
|
||||||
@@ -92,44 +219,7 @@ async def root():
|
|||||||
status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None
|
status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None
|
||||||
except Exception:
|
except Exception:
|
||||||
status = None
|
status = None
|
||||||
return {"status": "online", "ollama": status}
|
return {"status": "online", "version": VERSION, "ollama": status}
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Chat (non-streaming)
|
|
||||||
# -------------------------
|
|
||||||
@app.post("/chat")
|
|
||||||
async def chat_endpoint(payload: Dict[str, Any]):
|
|
||||||
try:
|
|
||||||
message = payload.get("message", "")
|
|
||||||
app_settings = store.get_settings()
|
|
||||||
model = payload.get("model") or await _auto_select_model()
|
|
||||||
context = payload.get("context", {})
|
|
||||||
history = payload.get("history", [])
|
|
||||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
|
||||||
|
|
||||||
rendered_message = playbooks.render_prompt(message)
|
|
||||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
|
||||||
|
|
||||||
memory_facts = store.all()
|
|
||||||
if memory_facts:
|
|
||||||
facts_block = _render_memory_block(memory_facts)
|
|
||||||
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
|
|
||||||
|
|
||||||
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
|
|
||||||
|
|
||||||
result = await generate_chat_response(
|
|
||||||
user_message=rendered_message, metadata=metadata, history=history
|
|
||||||
)
|
|
||||||
return {"response": result.get("response", ""), "model": model}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -140,20 +230,25 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
try:
|
try:
|
||||||
message = payload.get("message", "")
|
message = payload.get("message", "")
|
||||||
app_settings = store.get_settings()
|
app_settings = store.get_settings()
|
||||||
model = payload.get("model") or await _auto_select_model()
|
model = payload.get("model") or await _auto_select_model(message)
|
||||||
context = payload.get("context", {})
|
context = payload.get("context", {})
|
||||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
||||||
history = payload.get("history", [])
|
history = payload.get("history", [])
|
||||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||||
|
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||||
|
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||||
|
|
||||||
rendered_message = playbooks.render_prompt(message)
|
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
||||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||||
|
|
||||||
# Append reference playbooks to the system prompt
|
# Fetch memory facts once — used for both playbook routing and system prompt injection
|
||||||
context_pbs = playbooks.get_context_playbooks()
|
memory_facts = store.all()
|
||||||
|
|
||||||
|
# Append the best-matching reference playbook(s) to the system prompt
|
||||||
|
context_pbs = _route_playbooks(rendered_message, playbooks.get_context_playbooks())
|
||||||
if context_pbs:
|
if context_pbs:
|
||||||
refs = "\n\n".join(
|
refs = "\n\n".join(
|
||||||
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
|
||||||
@@ -161,16 +256,17 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
)
|
)
|
||||||
separator = "\n\n---\nReference playbooks (read these as additional context):\n\n"
|
separator = "\n\n---\nReference playbooks (read these as additional context):\n\n"
|
||||||
system_prompt = (system_prompt + separator + refs) if system_prompt else refs
|
system_prompt = (system_prompt + separator + refs) if system_prompt else refs
|
||||||
|
|
||||||
# Inject persistent memory facts about Jon
|
|
||||||
memory_facts = store.all()
|
|
||||||
if memory_facts:
|
if memory_facts:
|
||||||
facts_block = _render_memory_block(memory_facts)
|
facts_block = _render_memory_block(memory_facts)
|
||||||
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
|
system_prompt = (system_prompt + _MEMORY_PREAMBLE + facts_block) if system_prompt else facts_block
|
||||||
|
|
||||||
# Search past conversations for relevant context and inject the top matches.
|
# Search past conversations for relevant context and inject the top matches.
|
||||||
# This gives the model memory of prior exchanges without requiring tool-calling support.
|
# This gives the model memory of prior exchanges without requiring tool-calling support.
|
||||||
past_context = store.search_conversations(message, limit=2)
|
# Semantic recall (embeddings) finds relevant exchanges even without shared
|
||||||
|
# keywords; it falls back to lexical substring match if embeddings are down.
|
||||||
|
past_context = await store.semantic_search_conversations(
|
||||||
|
message, get_ollama_manager().embed, limit=2
|
||||||
|
)
|
||||||
if past_context:
|
if past_context:
|
||||||
snippets = []
|
snippets = []
|
||||||
for conv in past_context:
|
for conv in past_context:
|
||||||
@@ -183,7 +279,65 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
||||||
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
||||||
|
|
||||||
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
|
# Fetch web search results for time-sensitive queries
|
||||||
|
search_results = ""
|
||||||
|
if needs_web_search(message):
|
||||||
|
search_results = await _asyncio.to_thread(web_search, message)
|
||||||
|
if search_results:
|
||||||
|
separator = "\n\n---\nWeb search results (treat as current information):\n\n"
|
||||||
|
system_prompt = (system_prompt + separator + search_results) if system_prompt else search_results
|
||||||
|
|
||||||
|
# ── MindTrace pre-flight ──────────────────────────────────────────
|
||||||
|
_trace_intent = _detect_intent(message) if message else "chat"
|
||||||
|
if payload.get("model"):
|
||||||
|
_trace_src = "user-override"
|
||||||
|
elif store.get_settings().get("model"):
|
||||||
|
_trace_src = "settings"
|
||||||
|
else:
|
||||||
|
_trace_src = f"auto/{_trace_intent}"
|
||||||
|
|
||||||
|
_synapse_trace(f"\n{'═' * 55}\n")
|
||||||
|
_synapse_trace(f"▶ MODEL : {model} [{_trace_src}]\n")
|
||||||
|
|
||||||
|
if _trace_intent == "code":
|
||||||
|
_kws = [kw for kw in _CODING_KEYWORDS if kw in message.lower()][:5]
|
||||||
|
_synapse_trace(f" INTENT: code → {', '.join(_kws)}\n")
|
||||||
|
else:
|
||||||
|
_synapse_trace(f" INTENT: chat\n")
|
||||||
|
|
||||||
|
_main_pb = playbooks.get_main_playbook()
|
||||||
|
if _main_pb:
|
||||||
|
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
|
||||||
|
if _main_pb.goal:
|
||||||
|
_synapse_trace(f" goal: {_main_pb.goal[:100]}\n")
|
||||||
|
else:
|
||||||
|
_synapse_trace(f" PLAYBOOK: none\n")
|
||||||
|
|
||||||
|
if context_pbs:
|
||||||
|
_synapse_trace(f" ROUTED : {', '.join(pb.title for pb in context_pbs)}\n")
|
||||||
|
else:
|
||||||
|
_synapse_trace(f" ROUTED : none (no tag match)\n")
|
||||||
|
|
||||||
|
if search_results:
|
||||||
|
_synapse_trace(f" SEARCH : {len(search_results)} chars injected\n")
|
||||||
|
elif needs_web_search(message):
|
||||||
|
_synapse_trace(f" SEARCH : triggered but returned no results\n")
|
||||||
|
|
||||||
|
if memory_facts:
|
||||||
|
_secs = _Counter(f.section or "General" for f in memory_facts)
|
||||||
|
_sec_str = " ".join(f"{s}({n})" for s, n in _secs.items())
|
||||||
|
_synapse_trace(f" MEMORY : {len(memory_facts)} facts [{_sec_str}]\n")
|
||||||
|
else:
|
||||||
|
_synapse_trace(f" MEMORY : none\n")
|
||||||
|
|
||||||
|
if past_context:
|
||||||
|
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
|
||||||
|
|
||||||
|
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
|
||||||
|
_synapse_trace(f"{'─' * 55}\n")
|
||||||
|
# ── end MindTrace pre-flight ──────────────────────────────────────
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu}
|
||||||
|
|
||||||
# Persist conversation and user message before streaming
|
# Persist conversation and user message before streaming
|
||||||
store.create_conversation(conversation_id)
|
store.create_conversation(conversation_id)
|
||||||
@@ -192,6 +346,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
async def event_stream() -> AsyncGenerator[str, None]:
|
async def event_stream() -> AsyncGenerator[str, None]:
|
||||||
response_chunks: list[str] = []
|
response_chunks: list[str] = []
|
||||||
meta: dict = {}
|
meta: dict = {}
|
||||||
|
final_model = model
|
||||||
|
|
||||||
|
# ── Phase 1: stream primary model response ────────────────────
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_chat_response(
|
async for chunk in stream_chat_response(
|
||||||
user_message=rendered_message,
|
user_message=rendered_message,
|
||||||
@@ -206,22 +363,51 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
yield f"event: meta\ndata: {chunk[8:]}\n\n"
|
yield f"event: meta\ndata: {chunk[8:]}\n\n"
|
||||||
continue
|
continue
|
||||||
response_chunks.append(chunk)
|
response_chunks.append(chunk)
|
||||||
yield f"data: {chunk}\n\n"
|
yield f"data: {_json.dumps(chunk)}\n\n"
|
||||||
# Persist the completed assistant response with model and token stats
|
except _asyncio.TimeoutError:
|
||||||
if response_chunks:
|
_tval = store.get_settings().get("timeout", 120)
|
||||||
store.add_message(
|
yield f"event: error\ndata: {_json.dumps({'detail': f'Model timed out after {_tval}s — try a smaller/faster model'})}\n\n"
|
||||||
conversation_id, "assistant", "".join(response_chunks),
|
|
||||||
model=meta.get("model") or model,
|
|
||||||
tokens=meta.get("tokens"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n"
|
|
||||||
return
|
return
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e) or type(e).__name__
|
||||||
|
yield f"event: error\ndata: {_json.dumps({'detail': detail})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Persist completed response ───────────────────────────────
|
||||||
|
if response_chunks:
|
||||||
|
store.add_message(
|
||||||
|
conversation_id, "assistant", "".join(response_chunks),
|
||||||
|
model=meta.get("model") or final_model,
|
||||||
|
tokens=meta.get("tokens"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Response is complete — let the client re-enable its input now,
|
||||||
|
# so the slow title/memory work below doesn't freeze the UI.
|
||||||
|
yield "event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
# Generate an AI title from the opening message. Retried on any turn
|
||||||
|
# while still untitled, so an interrupted first stream can recover.
|
||||||
|
if response_chunks:
|
||||||
|
try:
|
||||||
|
conv = store.get_conversation(conversation_id)
|
||||||
|
if conv and not conv.title:
|
||||||
|
first_user = next(
|
||||||
|
(m.content for m in conv.messages if m.role == "user"),
|
||||||
|
rendered_message,
|
||||||
|
)
|
||||||
|
title = await _generate_conversation_title(
|
||||||
|
first_user, meta.get("model") or final_model
|
||||||
|
)
|
||||||
|
if title:
|
||||||
|
store.set_conversation_title(conversation_id, title)
|
||||||
|
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Ask the memory service curator to evaluate this exchange
|
# Ask the memory service curator to evaluate this exchange
|
||||||
if response_chunks:
|
if response_chunks:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=25.0) as _mc:
|
async with httpx.AsyncClient(timeout=310.0) as _mc:
|
||||||
r = await _mc.post(
|
r = await _mc.post(
|
||||||
f"{MEMORY_SERVICE}/memories/extract",
|
f"{MEMORY_SERVICE}/memories/extract",
|
||||||
json={
|
json={
|
||||||
@@ -231,11 +417,11 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
if data.get("saved"):
|
for it in data.get("items", []):
|
||||||
mem_result = {"section": data["section"], "text": data["text"]}
|
mem_result = {"section": it["section"], "text": it["text"]}
|
||||||
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
|
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
_synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
|
||||||
|
|
||||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
@@ -244,24 +430,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Playbook Execution
|
|
||||||
# -------------------------
|
|
||||||
@app.post("/playbook/run")
|
|
||||||
async def run_playbook(payload: Dict[str, Any]):
|
|
||||||
name = payload.get("name")
|
|
||||||
variables = payload.get("variables", {})
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing playbook name")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = playbooks.render_prompt(name, variables=variables)
|
|
||||||
return {"result": result}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Settings
|
# Settings
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -273,7 +441,10 @@ async def get_settings_endpoint():
|
|||||||
@app.put("/settings")
|
@app.put("/settings")
|
||||||
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
|
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
|
||||||
store.update_settings(payload)
|
store.update_settings(payload)
|
||||||
return store.get_settings()
|
merged = store.get_settings()
|
||||||
|
if "keep_alive" in payload:
|
||||||
|
get_ollama_manager().keep_alive = merged.get("keep_alive") or None
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -286,8 +457,8 @@ async def get_memory():
|
|||||||
|
|
||||||
@app.post("/memory")
|
@app.post("/memory")
|
||||||
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
||||||
text = (payload.get("text") or "").strip()
|
text = (payload.get("text") or "").rstrip()
|
||||||
if not text:
|
if not text.strip():
|
||||||
raise HTTPException(status_code=400, detail="Missing 'text'")
|
raise HTTPException(status_code=400, detail="Missing 'text'")
|
||||||
from .memory.store import MemoryItem
|
from .memory.store import MemoryItem
|
||||||
import uuid as _mem_uuid
|
import uuid as _mem_uuid
|
||||||
@@ -310,7 +481,7 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
|||||||
updated = MemoryItem(
|
updated = MemoryItem(
|
||||||
id=item_id,
|
id=item_id,
|
||||||
section=(payload.get("section") or existing.section or "General").strip(),
|
section=(payload.get("section") or existing.section or "General").strip(),
|
||||||
text=(payload.get("text") or existing.text).strip(),
|
text=(payload.get("text") or existing.text).rstrip(),
|
||||||
tags=payload.get("tags", existing.tags),
|
tags=payload.get("tags", existing.tags),
|
||||||
)
|
)
|
||||||
store.update(updated)
|
store.update(updated)
|
||||||
@@ -325,6 +496,16 @@ async def delete_memory(item_id: str):
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/memory/reorder")
|
||||||
|
async def reorder_memory(payload: Dict[str, Any] = Body(...)):
|
||||||
|
section = (payload.get("section") or "General").strip() or "General"
|
||||||
|
ids = payload.get("ids")
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
|
||||||
|
raise HTTPException(status_code=400, detail="ids must be a list of strings")
|
||||||
|
ok = store.reorder_section(section, ids)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Models
|
# Models
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -332,8 +513,11 @@ async def delete_memory(item_id: str):
|
|||||||
async def get_models():
|
async def get_models():
|
||||||
try:
|
try:
|
||||||
mgr = get_ollama_manager()
|
mgr = get_ollama_manager()
|
||||||
models = await mgr.list_models()
|
# Embedding models (e.g. nomic-embed-text) can't chat — hide from picker.
|
||||||
selected = await mgr.select_best_model()
|
models = [m for m in await mgr.list_models() if "embed" not in m.lower()]
|
||||||
|
# Report the SAME model the chat path would auto-pick (honors a pin),
|
||||||
|
# so the picker's "Auto (…)" label matches what actually answers.
|
||||||
|
selected = await _auto_select_model()
|
||||||
return {"models": models, "selected": selected}
|
return {"models": models, "selected": selected}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -641,26 +825,6 @@ async def delete_playbook_endpoint(id: UUID):
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
# Unified Search
|
# Unified Search
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@app.get("/search")
|
|
||||||
async def search_endpoint(q: Optional[str] = None):
|
|
||||||
"""Search conversations and playbooks by keyword."""
|
|
||||||
if not q or not q.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="Missing query parameter 'q'")
|
|
||||||
try:
|
|
||||||
conversations = store.search_conversations(q, limit=5)
|
|
||||||
playbook_hits = playbook_store.search_playbooks(q)
|
|
||||||
return {
|
|
||||||
"query": q,
|
|
||||||
"conversations": conversations,
|
|
||||||
"playbooks": [
|
|
||||||
{"id": p.id, "title": p.title, "goal": p.goal, "tags": p.tags}
|
|
||||||
for p in playbook_hits
|
|
||||||
],
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Conversations
|
# Conversations
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -682,6 +846,7 @@ async def get_conversations(q: Optional[str] = None):
|
|||||||
"timestamp": c.created_at,
|
"timestamp": c.created_at,
|
||||||
"updated_at": c.updated_at,
|
"updated_at": c.updated_at,
|
||||||
"preview": c.preview,
|
"preview": c.preview,
|
||||||
|
"title": c.title,
|
||||||
}
|
}
|
||||||
for c in conversations
|
for c in conversations
|
||||||
]
|
]
|
||||||
@@ -690,6 +855,47 @@ async def get_conversations(q: Optional[str] = None):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/conversations/export")
|
||||||
|
async def export_conversations(min_turns: int = 1):
|
||||||
|
"""Export conversations as ShareGPT JSONL for fine-tuning.
|
||||||
|
|
||||||
|
Each line is one conversation:
|
||||||
|
{"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]}
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
min_turns — minimum user/assistant exchanges to include (default 1)
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
conversations = store.all_conversations()
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
for conv in conversations:
|
||||||
|
msgs = [m for m in conv.messages if m.role in ("user", "assistant")]
|
||||||
|
if len(msgs) < 2:
|
||||||
|
continue
|
||||||
|
if sum(1 for m in msgs if m.role == "user") < min_turns:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sharegpt_msgs = [
|
||||||
|
{"from": "human" if m.role == "user" else "gpt", "value": m.content}
|
||||||
|
for m in msgs
|
||||||
|
]
|
||||||
|
lines.append(_json.dumps({"conversations": sharegpt_msgs}))
|
||||||
|
|
||||||
|
date_str = datetime.date.today().isoformat()
|
||||||
|
filename = f"nexus-conversations-{date_str}.jsonl"
|
||||||
|
return Response(
|
||||||
|
content="\n".join(lines),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"X-Exported-Count": str(len(lines)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/conversations/{conversation_id}")
|
@app.get("/conversations/{conversation_id}")
|
||||||
async def get_conversation(conversation_id: str):
|
async def get_conversation(conversation_id: str):
|
||||||
try:
|
try:
|
||||||
@@ -699,6 +905,7 @@ async def get_conversation(conversation_id: str):
|
|||||||
return {
|
return {
|
||||||
"id": conv.id,
|
"id": conv.id,
|
||||||
"timestamp": conv.created_at,
|
"timestamp": conv.created_at,
|
||||||
|
"title": conv.title,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
|
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
|
||||||
for m in conv.messages
|
for m in conv.messages
|
||||||
@@ -710,6 +917,25 @@ async def get_conversation(conversation_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/conversations/{conversation_id}")
|
||||||
|
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
|
||||||
|
"""Manually set a conversation's title."""
|
||||||
|
try:
|
||||||
|
conv = store.get_conversation(conversation_id)
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
title = (payload.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'title'")
|
||||||
|
title = " ".join(title.split())[:120]
|
||||||
|
store.set_conversation_title(conversation_id, title)
|
||||||
|
return {"id": conversation_id, "title": title}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/conversations/{conversation_id}")
|
@app.delete("/conversations/{conversation_id}")
|
||||||
async def delete_conversation(conversation_id: str):
|
async def delete_conversation(conversation_id: str):
|
||||||
try:
|
try:
|
||||||
@@ -723,6 +949,80 @@ async def delete_conversation(conversation_id: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
# ── Icon branding routes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
|
||||||
|
_ALLOWED_ICON_ROOTS = [
|
||||||
|
"/usr/share/icons",
|
||||||
|
"/usr/share/pixmaps",
|
||||||
|
"/usr/local/share/icons",
|
||||||
|
"/opt",
|
||||||
|
_os.path.expanduser("~/.local/share/icons"),
|
||||||
|
_os.path.expanduser("~/.icons"),
|
||||||
|
_REPO_ASSETS,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/icons/apps")
|
||||||
|
async def list_icon_apps():
|
||||||
|
"""Return all installed applications with their icon paths."""
|
||||||
|
try:
|
||||||
|
from .icons.resolver import scan_apps
|
||||||
|
loop = _asyncio.get_event_loop()
|
||||||
|
apps = await loop.run_in_executor(None, scan_apps)
|
||||||
|
return {"apps": apps}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/icons/image")
|
||||||
|
async def get_icon_image(path: str):
|
||||||
|
"""Serve an icon file after verifying it's in an allowed root."""
|
||||||
|
real = _os.path.realpath(path)
|
||||||
|
if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
|
||||||
|
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||||
|
if not _os.path.isfile(real):
|
||||||
|
raise HTTPException(status_code=404, detail="Icon not found")
|
||||||
|
return FileResponse(real)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/icons/brand")
|
||||||
|
async def brand_app_icon(payload: Dict[str, Any] = Body(...)):
|
||||||
|
"""Composite an app icon onto the NexusOS underlay tile."""
|
||||||
|
src_path = payload.get("src_path", "")
|
||||||
|
output_name = payload.get("output_name", "")
|
||||||
|
if not src_path or not output_name:
|
||||||
|
raise HTTPException(status_code=400, detail="src_path and output_name required")
|
||||||
|
frac = float(payload.get("frac", 0.60))
|
||||||
|
round_mask = bool(payload.get("round_mask", False))
|
||||||
|
nexus_ring = bool(payload.get("nexus_ring", False))
|
||||||
|
reload = bool(payload.get("reload", True))
|
||||||
|
category = str(payload.get("category", "apps"))
|
||||||
|
try:
|
||||||
|
from .icons.compositor import brand_icon
|
||||||
|
loop = _asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: brand_icon(src_path, output_name, frac, round_mask, nexus_ring, reload, category),
|
||||||
|
)
|
||||||
|
return {"status": "ok", "output_name": output_name}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/icons/apply")
|
||||||
|
async def apply_icon_cache_route():
|
||||||
|
"""Rebuild the GTK icon cache and reload the panel."""
|
||||||
|
try:
|
||||||
|
from .icons.compositor import apply_icon_cache
|
||||||
|
loop = _asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, apply_icon_cache)
|
||||||
|
return {"status": "ok"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# End of file
|
# End of file
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|||||||
Regular → Executable
+105
-31
@@ -6,42 +6,52 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
|
from ..chat import _synapse_trace # append curator reasoning to the same MindTrace log
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TR = "┅" * 55
|
||||||
|
|
||||||
_PROMPT = """\
|
_PROMPT = """\
|
||||||
You are a memory curator for a personal AI assistant named Nexus.
|
You are a memory curator for a personal AI assistant named Nexus.
|
||||||
|
|
||||||
A conversation just occurred. Decide if the USER revealed a NEW, PERMANENT \
|
Extract EVERY new, permanent personal fact the USER revealed in this exchange.
|
||||||
personal fact that should be saved to long-term memory.
|
There may be SEVERAL facts in one message — output one JSON object for each.
|
||||||
|
|
||||||
SAVE ONLY facts that are stable and biographical:
|
SAVE facts that are stable and biographical, such as: identity (name, age,
|
||||||
- Identity: full name, age, location, nationality
|
location), relationships (family, partner, friends), pets, possessions (vehicles,
|
||||||
- Possessions: vehicle, home, devices
|
home, devices), career (job, employer, skills), hobbies and interests,
|
||||||
- Relationships: family members, partner, close friends
|
long-running projects or goals (not today's to-dos), and durable preferences.
|
||||||
- Career: job title, employer, field, skills
|
|
||||||
- Long-running projects or goals (not today's to-dos)
|
|
||||||
- Durable preferences or habits explicitly stated
|
|
||||||
|
|
||||||
DO NOT SAVE — these are ephemeral and would clutter memory:
|
DO NOT SAVE — these are ephemeral and would clutter memory:
|
||||||
- Anything time-bound: "today I have...", "I'm working on X today", "I have a full day of work"
|
- Anything time-bound: "today I have...", "I'm working on X today"
|
||||||
- Mood or energy: "I'm tired", "feeling good", "having a rough day"
|
- Mood or energy: "I'm tired", "feeling good", "having a rough day"
|
||||||
- Greetings or small talk: "good morning", "how are you"
|
- Greetings or small talk: "good morning", "how are you"
|
||||||
- Questions the user asked the assistant
|
- Questions the user asked the assistant
|
||||||
- Near-duplicates of anything in the existing memory list below
|
|
||||||
|
|
||||||
Existing memory (do not re-save these or close variants):
|
The existing memory is below FOR CONTEXT. If the user ADDS NEW DETAIL to
|
||||||
|
something already known (e.g. a new detail about a known pet, car, or project),
|
||||||
|
DO save that new detail as its own fact. Only skip a fact that is an EXACT
|
||||||
|
restatement of one already listed.
|
||||||
|
|
||||||
|
Existing memory:
|
||||||
{existing_texts}
|
{existing_texts}
|
||||||
|
|
||||||
|
For "section", REUSE one of these existing section names whenever it fits:
|
||||||
|
{existing_sections}
|
||||||
|
Only invent a new section if none fit, and make it a SHORT single word
|
||||||
|
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
||||||
|
|
||||||
---
|
---
|
||||||
USER: {user_message}
|
USER: {user_message}
|
||||||
ASSISTANT: {assistant_response}
|
ASSISTANT: {assistant_response}
|
||||||
---
|
---
|
||||||
|
|
||||||
Respond with JSON only — no prose, no markdown fences:
|
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
||||||
{{"save": true, "section": "<section name, or one from the list above>", "text": "<concise fact about Jon, third person>"}}
|
new fact (several objects, one per line, if there are several):
|
||||||
OR
|
{{"save": true, "section": "<short section name>", "text": "<concise fact about Jon, third person>"}}
|
||||||
|
If there is nothing new to save, output exactly:
|
||||||
{{"save": false}}"""
|
{{"save": false}}"""
|
||||||
|
|
||||||
|
|
||||||
@@ -51,31 +61,62 @@ async def extract_memory(
|
|||||||
existing_sections: list[str],
|
existing_sections: list[str],
|
||||||
existing_texts: list[str],
|
existing_texts: list[str],
|
||||||
ollama_manager,
|
ollama_manager,
|
||||||
) -> Optional[dict]:
|
model: str = "mistral:latest",
|
||||||
"""Ask Mistral to extract a saveable memory fact from a conversation exchange.
|
num_gpu: int | None = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
||||||
|
|
||||||
Returns {"section": ..., "text": ...} or None.
|
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
||||||
|
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
||||||
"""
|
"""
|
||||||
if existing_texts:
|
if existing_texts:
|
||||||
texts_block = "\n".join(f"- {t}" for t in existing_texts[:40])
|
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
||||||
|
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
||||||
|
# facts made a ~1200-token prompt that took ~50s+ to process. If dedup
|
||||||
|
# starts re-saving older facts, move dedup to a difflib check in the
|
||||||
|
# service instead of stuffing every fact into the prompt.
|
||||||
|
texts_block = "\n".join(f"- {t}" for t in existing_texts[-12:])
|
||||||
else:
|
else:
|
||||||
texts_block = "(none yet)"
|
texts_block = "(none yet)"
|
||||||
|
# Give Mistral the real section names to reuse, so it stops inventing
|
||||||
|
# sentence-long sections out of the category descriptions in the prompt.
|
||||||
|
# Only offer SHORT, clean names — never feed a junk sentence-section (e.g. a
|
||||||
|
# past bad "Long-running projects or goals") back as a valid choice.
|
||||||
|
clean = sorted(s for s in existing_sections if s and len(s.split()) <= 2 and len(s) <= 24)
|
||||||
|
sections_line = ", ".join(clean) if clean else (
|
||||||
|
"Identity, Relationships, Pets, Possessions, Career, Hobbies, Projects, Preferences"
|
||||||
|
)
|
||||||
prompt = _PROMPT.format(
|
prompt = _PROMPT.format(
|
||||||
existing_texts=texts_block,
|
existing_texts=texts_block,
|
||||||
user_message=user_message[:800],
|
existing_sections=sections_line,
|
||||||
|
user_message=user_message[:3000],
|
||||||
assistant_response=assistant_response[:800],
|
assistant_response=assistant_response[:800],
|
||||||
)
|
)
|
||||||
|
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
||||||
|
# the same console as the frontline model, not just Python warnings on failure.
|
||||||
|
_synapse_trace(
|
||||||
|
f"\n{_TR}\n◆ CURATOR: {model} (num_gpu={num_gpu})\n"
|
||||||
|
f" PROMPT ({len(prompt)} chars):\n{prompt}\n{_TR}\n"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
# num_gpu=0 (the default) pins the curator fully in system RAM instead of
|
||||||
|
# the GPU, so it coexists with the GPU-resident chat model instead of
|
||||||
|
# evicting it. Without this, on a small GPU the two thrash: every
|
||||||
|
# exchange cold-loads the curator (~45s) and extraction times out,
|
||||||
|
# silently saving nothing. Boxes with spare VRAM override via settings.
|
||||||
response = await ollama_manager.chat(
|
response = await ollama_manager.chat(
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
model="mistral:latest",
|
model=model,
|
||||||
stream=False,
|
stream=False,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
|
num_gpu=num_gpu,
|
||||||
)
|
)
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
_synapse_trace("◆ CURATOR RAW: (empty response)\n")
|
||||||
|
return []
|
||||||
|
|
||||||
text = response.strip()
|
text = response.strip()
|
||||||
|
_synapse_trace(f"◆ CURATOR RAW:\n{text}\n")
|
||||||
|
|
||||||
# Strip markdown code fences if the model added them
|
# Strip markdown code fences if the model added them
|
||||||
if "```" in text:
|
if "```" in text:
|
||||||
@@ -83,12 +124,45 @@ async def extract_memory(
|
|||||||
if m:
|
if m:
|
||||||
text = m.group(1).strip()
|
text = m.group(1).strip()
|
||||||
|
|
||||||
data = json.loads(text)
|
# For several facts Mistral is inconsistent: sometimes ONE JSON object
|
||||||
if data.get("save") and data.get("section") and data.get("text"):
|
# per fact newline-separated, sometimes a single JSON ARRAY of objects.
|
||||||
return {
|
# raw_decode pulls each top-level value (handles the newline case and
|
||||||
"section": str(data["section"]).strip(),
|
# plain "Extra data"); we then flatten any array so both shapes save all
|
||||||
"text": str(data["text"]).strip(),
|
# facts. Plain json.loads() would die on the newline case and skip the
|
||||||
}
|
# array (a list isn't a dict), losing every fact either way.
|
||||||
|
results: list[dict] = []
|
||||||
|
|
||||||
|
def _keep(o):
|
||||||
|
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
||||||
|
results.append({
|
||||||
|
"section": str(o["section"]).strip(),
|
||||||
|
"text": str(o["text"]).strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
dec = json.JSONDecoder()
|
||||||
|
idx = 0
|
||||||
|
while idx < len(text):
|
||||||
|
while idx < len(text) and text[idx] in " \t\r\n,":
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(text):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
obj, idx = dec.raw_decode(text, idx)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
break
|
||||||
|
if isinstance(obj, list):
|
||||||
|
for o in obj:
|
||||||
|
_keep(o)
|
||||||
|
else:
|
||||||
|
_keep(obj)
|
||||||
|
if not results:
|
||||||
|
_log.warning("memory: nothing saved. mistral said: %.300r", text)
|
||||||
|
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
||||||
|
else:
|
||||||
|
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
||||||
|
_synapse_trace(f"◆ CURATOR VERDICT: {len(results)} fact(s) — {_facts}\n{_TR}\n\n")
|
||||||
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.debug("memory extraction failed: %s", e)
|
_log.warning("memory extraction failed: %s", e)
|
||||||
return None
|
_synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
|
||||||
|
return []
|
||||||
|
|||||||
Regular → Executable
+94
-11
@@ -13,6 +13,7 @@ Endpoints:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import math
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -35,6 +36,33 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _warm_curator():
|
||||||
|
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
|
||||||
|
extraction isn't a cold load that blows the timeout. Runs in the background
|
||||||
|
so it never delays startup. keep_alive then holds it warm between messages."""
|
||||||
|
async def _bg():
|
||||||
|
try:
|
||||||
|
mgr = get_ollama_manager()
|
||||||
|
# Ollama is started by the Synapse backend (a separate process), so
|
||||||
|
# at our startup it usually isn't reachable yet. Wait for it before
|
||||||
|
# warming instead of failing with "All connection attempts failed" —
|
||||||
|
# which leaves the curator cold and makes the first extraction slow.
|
||||||
|
for _ in range(60): # up to ~2 min
|
||||||
|
if await asyncio.to_thread(mgr.is_running):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
settings = store.get_settings()
|
||||||
|
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||||
|
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||||
|
await mgr.warm(model, num_gpu=num_gpu)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
asyncio.create_task(_bg())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "count": len(store.all())}
|
return {"status": "ok", "count": len(store.all())}
|
||||||
@@ -86,6 +114,13 @@ async def delete_memory(item_id: str):
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: list, b: list) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(y * y for y in b))
|
||||||
|
return dot / (na * nb) if na and nb else 0.0
|
||||||
|
|
||||||
|
|
||||||
class ExtractRequest(BaseModel):
|
class ExtractRequest(BaseModel):
|
||||||
user_message: str
|
user_message: str
|
||||||
assistant_response: str
|
assistant_response: str
|
||||||
@@ -99,25 +134,73 @@ async def extract_and_save(req: ExtractRequest):
|
|||||||
existing_sections = list({i.section for i in existing})
|
existing_sections = list({i.section for i in existing})
|
||||||
existing_texts = [i.text for i in existing]
|
existing_texts = [i.text for i in existing]
|
||||||
|
|
||||||
|
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
|
||||||
|
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
|
||||||
|
settings = store.get_settings()
|
||||||
|
mgr = get_ollama_manager()
|
||||||
|
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||||
|
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
merge_threshold = 0.88
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(
|
||||||
extract_memory(
|
extract_memory(
|
||||||
req.user_message,
|
req.user_message,
|
||||||
req.assistant_response,
|
req.assistant_response,
|
||||||
existing_sections,
|
existing_sections,
|
||||||
existing_texts,
|
existing_texts,
|
||||||
get_ollama_manager(),
|
mgr,
|
||||||
|
model=model,
|
||||||
|
num_gpu=num_gpu,
|
||||||
),
|
),
|
||||||
timeout=20.0,
|
timeout=300.0,
|
||||||
)
|
)
|
||||||
if result:
|
|
||||||
item = MemoryItem(
|
# Embed existing facts once so each new fact can be matched against them.
|
||||||
id=str(uuid.uuid4()),
|
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||||
section=result["section"],
|
# rather than appending a copy. Best effort: if embeddings are down we
|
||||||
text=result["text"],
|
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||||
)
|
existing_embeds: dict = {}
|
||||||
store.add(item)
|
if results and 0 < merge_threshold < 1:
|
||||||
return {"saved": True, "id": item.id, "section": item.section, "text": item.text}
|
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||||
|
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||||
|
|
||||||
|
saved = []
|
||||||
|
for result in results:
|
||||||
|
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||||
|
match_id, best = None, 0.0
|
||||||
|
if new_vec:
|
||||||
|
for eid, ev in existing_embeds.items():
|
||||||
|
sim = _cosine(new_vec, ev)
|
||||||
|
if sim > best:
|
||||||
|
best, match_id = sim, eid
|
||||||
|
if best < merge_threshold:
|
||||||
|
match_id = None
|
||||||
|
|
||||||
|
target = store.get(match_id) if match_id else None
|
||||||
|
if target:
|
||||||
|
# Near-duplicate of an existing fact — overwrite with the newer
|
||||||
|
# statement, keeping the original id/section/position.
|
||||||
|
updated = MemoryItem(id=target.id, section=target.section,
|
||||||
|
text=result["text"], tags=target.tags)
|
||||||
|
store.update(updated)
|
||||||
|
if new_vec:
|
||||||
|
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
||||||
|
saved.append({"id": updated.id, "section": updated.section,
|
||||||
|
"text": updated.text, "updated": True})
|
||||||
|
else:
|
||||||
|
item = MemoryItem(id=str(uuid.uuid4()),
|
||||||
|
section=result["section"], text=result["text"])
|
||||||
|
store.add(item)
|
||||||
|
if new_vec:
|
||||||
|
existing_embeds[item.id] = new_vec
|
||||||
|
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||||
|
if saved:
|
||||||
|
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
||||||
|
return {"saved": True, "items": saved, **first}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Regular → Executable
+300
-9
@@ -3,9 +3,22 @@ from typing import Any, Dict, List, Optional
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: List[float], b: List[float]) -> float:
|
||||||
|
"""Cosine similarity between two equal-length vectors. 0.0 on mismatch."""
|
||||||
|
if not a or not b or len(a) != len(b):
|
||||||
|
return 0.0
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(y * y for y in b))
|
||||||
|
if na == 0.0 or nb == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Models
|
# Models
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -14,6 +27,7 @@ class MemoryItem(BaseModel):
|
|||||||
section: str = "General"
|
section: str = "General"
|
||||||
text: str
|
text: str
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
|
position: int = 0
|
||||||
|
|
||||||
class MessageItem(BaseModel):
|
class MessageItem(BaseModel):
|
||||||
role: str # "user" or "assistant"
|
role: str # "user" or "assistant"
|
||||||
@@ -27,6 +41,7 @@ class ConversationItem(BaseModel):
|
|||||||
messages: List[MessageItem] = []
|
messages: List[MessageItem] = []
|
||||||
created_at: float
|
created_at: float
|
||||||
updated_at: float
|
updated_at: float
|
||||||
|
title: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preview(self) -> str:
|
def preview(self) -> str:
|
||||||
@@ -75,14 +90,29 @@ class PersistentMemoryStore:
|
|||||||
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
|
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Migrate: add position column for stable ordering
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE memory ADD COLUMN position INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Backfill positions for rows added before this column existed
|
||||||
|
cur.execute("SELECT COUNT(*) FROM memory WHERE position > 0")
|
||||||
|
if cur.fetchone()[0] == 0:
|
||||||
|
cur.execute("UPDATE memory SET position = rowid")
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at REAL NOT NULL,
|
created_at REAL NOT NULL,
|
||||||
updated_at REAL NOT NULL
|
updated_at REAL NOT NULL,
|
||||||
|
title TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# Migrate: add title column if it doesn't exist yet
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE conversations ADD COLUMN title TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -107,12 +137,26 @@ class PersistentMemoryStore:
|
|||||||
ON messages (conversation_id)
|
ON messages (conversation_id)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Semantic recall: one embedding vector per message, stored as JSON.
|
||||||
|
# Backfilled lazily by semantic_search_conversations so existing history
|
||||||
|
# gets indexed on first search.
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_vectors (
|
||||||
|
message_id INTEGER PRIMARY KEY,
|
||||||
|
embedding TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM settings WHERE key IN ('anthropic_api_key', 'escalation_model')"
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -123,7 +167,7 @@ class PersistentMemoryStore:
|
|||||||
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id, section, text, tags FROM memory")
|
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -138,6 +182,7 @@ class PersistentMemoryStore:
|
|||||||
section=row["section"] or "General",
|
section=row["section"] or "General",
|
||||||
text=row["text"],
|
text=row["text"],
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
position=row["position"] or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
@@ -146,19 +191,35 @@ class PersistentMemoryStore:
|
|||||||
# Memory API
|
# Memory API
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
def add(self, item: MemoryItem):
|
def add(self, item: MemoryItem):
|
||||||
|
if not item.position:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT position FROM memory WHERE id = ?", (item.id,)
|
||||||
|
).fetchone()
|
||||||
|
if row and row["position"]:
|
||||||
|
item.position = row["position"]
|
||||||
|
else:
|
||||||
|
row = conn.execute("SELECT MAX(position) AS max_position FROM memory").fetchone()
|
||||||
|
item.position = (row["max_position"] or 0) + 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
self._cache[item.id] = item
|
self._cache[item.id] = item
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT OR REPLACE INTO memory (id, section, text, tags) VALUES (?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
|
||||||
(item.id, item.section or "General", item.text, json.dumps(item.tags))
|
(item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def update(self, item: MemoryItem):
|
def update(self, item: MemoryItem):
|
||||||
|
existing = self.get(item.id)
|
||||||
|
if existing:
|
||||||
|
item.position = existing.position
|
||||||
self.add(item)
|
self.add(item)
|
||||||
|
|
||||||
def delete(self, item_id: str):
|
def delete(self, item_id: str):
|
||||||
@@ -172,10 +233,76 @@ class PersistentMemoryStore:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get(self, item_id: str) -> Optional[MemoryItem]:
|
def get(self, item_id: str) -> Optional[MemoryItem]:
|
||||||
return self._cache.get(item_id)
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, section, text, tags, position FROM memory WHERE id = ?",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
self._cache.pop(item_id, None)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tags = json.loads(row["tags"]) if row["tags"] else []
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
item = MemoryItem(
|
||||||
|
id=row["id"], section=row["section"] or "General", text=row["text"],
|
||||||
|
tags=tags, position=row["position"] or 0,
|
||||||
|
)
|
||||||
|
self._cache[item_id] = item
|
||||||
|
return item
|
||||||
|
|
||||||
def all(self) -> List[MemoryItem]:
|
def all(self) -> List[MemoryItem]:
|
||||||
return list(self._cache.values())
|
# Always read from DB — the memory service and backend run in separate processes
|
||||||
|
# with separate caches, so the cache can be stale for facts extracted by the
|
||||||
|
# memory service after this process started.
|
||||||
|
conn = self._connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
tags = json.loads(row["tags"]) if row["tags"] else []
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
items.append(MemoryItem(
|
||||||
|
id=row["id"],
|
||||||
|
section=row["section"] or "General",
|
||||||
|
text=row["text"],
|
||||||
|
tags=tags,
|
||||||
|
position=row["position"] or 0,
|
||||||
|
))
|
||||||
|
return items
|
||||||
|
|
||||||
|
def reorder_section(self, section: str, ordered_ids: List[str]) -> bool:
|
||||||
|
"""Rewrite the order of items in a section using its existing position pool.
|
||||||
|
ordered_ids must contain exactly the ids currently in the section."""
|
||||||
|
section_norm = section or "General"
|
||||||
|
in_section = [i for i in self.all() if (i.section or "General") == section_norm]
|
||||||
|
if len(in_section) != len(ordered_ids):
|
||||||
|
return False
|
||||||
|
by_id = {i.id: i for i in in_section}
|
||||||
|
siblings = []
|
||||||
|
for id_ in ordered_ids:
|
||||||
|
if id_ not in by_id:
|
||||||
|
return False
|
||||||
|
siblings.append(by_id[id_])
|
||||||
|
positions = sorted([i.position for i in in_section])
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for s, new_pos in zip(siblings, positions):
|
||||||
|
s.position = new_pos
|
||||||
|
cur.execute("UPDATE memory SET position = ? WHERE id = ?", (new_pos, s.id))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Conversation API
|
# Conversation API
|
||||||
@@ -194,7 +321,19 @@ class PersistentMemoryStore:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
|
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
|
||||||
|
|
||||||
def add_message(self, conversation_id: str, role: str, content: str, model: str = None, tokens: int = None):
|
def set_conversation_title(self, conversation_id: str, title: str):
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE conversations SET title = ? WHERE id = ?",
|
||||||
|
(title, conversation_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
@@ -203,11 +342,13 @@ class PersistentMemoryStore:
|
|||||||
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(conversation_id, role, content, now, model, tokens)
|
(conversation_id, role, content, now, model, tokens)
|
||||||
)
|
)
|
||||||
|
message_id = cur.lastrowid
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE conversations SET updated_at = ? WHERE id = ?",
|
"UPDATE conversations SET updated_at = ? WHERE id = ?",
|
||||||
(now, conversation_id)
|
(now, conversation_id)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
return message_id
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -231,14 +372,15 @@ class PersistentMemoryStore:
|
|||||||
id=row["id"],
|
id=row["id"],
|
||||||
messages=messages,
|
messages=messages,
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"]
|
updated_at=row["updated_at"],
|
||||||
|
title=row["title"] if "title" in row.keys() else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def all_conversations(self) -> List[ConversationItem]:
|
def all_conversations(self) -> List[ConversationItem]:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT c.id, c.created_at, c.updated_at,
|
SELECT c.id, c.created_at, c.updated_at, c.title,
|
||||||
m.role, m.content, m.timestamp, m.model, m.tokens
|
m.role, m.content, m.timestamp, m.model, m.tokens
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
LEFT JOIN messages m ON m.conversation_id = c.id
|
LEFT JOIN messages m ON m.conversation_id = c.id
|
||||||
@@ -256,6 +398,7 @@ class PersistentMemoryStore:
|
|||||||
id=cid,
|
id=cid,
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
|
title=row["title"],
|
||||||
)
|
)
|
||||||
order.append(cid)
|
order.append(cid)
|
||||||
if row["role"] is not None:
|
if row["role"] is not None:
|
||||||
@@ -333,6 +476,130 @@ class PersistentMemoryStore:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
# nomic-embed-text is an asymmetric retrieval model: queries and stored
|
||||||
|
# documents must be embedded with these task prefixes or similarity collapses
|
||||||
|
# into noise. Cached vectors are document-embeddings (search_document:).
|
||||||
|
_EMBED_QUERY_PREFIX = "search_query: "
|
||||||
|
_EMBED_DOC_PREFIX = "search_document: "
|
||||||
|
|
||||||
|
async def semantic_search_conversations(
|
||||||
|
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
|
||||||
|
) -> List[dict]:
|
||||||
|
"""Recall past conversations relevant to `query` using hybrid retrieval.
|
||||||
|
|
||||||
|
Combines semantic similarity (embeddings — finds reworded matches with no
|
||||||
|
shared keywords) with the existing lexical substring match (catches exact
|
||||||
|
terms the embedding underweights), unioned and deduped by conversation.
|
||||||
|
|
||||||
|
`embed_fn` is an async callable returning an embedding vector for a string
|
||||||
|
(typically OllamaManager.embed). Messages without a stored vector are
|
||||||
|
embedded and cached on first use (lazy backfill). If embeddings are
|
||||||
|
unavailable (no model / Ollama down) this degrades to pure lexical match,
|
||||||
|
so recall never silently breaks.
|
||||||
|
|
||||||
|
Returns the same shape as `search_conversations`: a list of
|
||||||
|
{id, updated_at, matches:[{role, content}]} with full user+assistant
|
||||||
|
pairs around each match.
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
|
||||||
|
if not query_vec:
|
||||||
|
return self.search_conversations(query, limit=limit)
|
||||||
|
|
||||||
|
conn = self._connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Lazy backfill: embed any messages that don't have a vector yet.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT m.id, m.content
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN message_vectors v ON v.message_id = m.id
|
||||||
|
WHERE v.message_id IS NULL AND TRIM(m.content) != ''
|
||||||
|
""")
|
||||||
|
missing = cur.fetchall()
|
||||||
|
for row in missing:
|
||||||
|
vec = await embed_fn(self._EMBED_DOC_PREFIX + row["content"][:2000])
|
||||||
|
if vec:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||||
|
(row["id"], json.dumps(vec)),
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Score every stored message against the query vector.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT v.message_id, v.embedding, m.conversation_id
|
||||||
|
FROM message_vectors v
|
||||||
|
JOIN messages m ON m.id = v.message_id
|
||||||
|
""")
|
||||||
|
scored = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
try:
|
||||||
|
vec = json.loads(row["embedding"])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
score = _cosine(query_vec, vec)
|
||||||
|
if score >= min_score:
|
||||||
|
scored.append((score, row["message_id"], row["conversation_id"]))
|
||||||
|
|
||||||
|
scored.sort(reverse=True)
|
||||||
|
|
||||||
|
results: List[dict] = []
|
||||||
|
seen_convs: set = set()
|
||||||
|
for score, message_id, conv_id in scored:
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
if conv_id in seen_convs:
|
||||||
|
continue
|
||||||
|
pair = self._exchange_pair(cur, conv_id, message_id)
|
||||||
|
if pair:
|
||||||
|
seen_convs.add(conv_id)
|
||||||
|
results.append(pair)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Hybrid union: fill any remaining slots with lexical matches the
|
||||||
|
# embedding missed (e.g. exact proper nouns), skipping dupes.
|
||||||
|
if len(results) < limit:
|
||||||
|
for conv in self.search_conversations(query, limit=limit):
|
||||||
|
if conv["id"] not in seen_convs:
|
||||||
|
seen_convs.add(conv["id"])
|
||||||
|
results.append(conv)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
|
||||||
|
"""Build a {id, updated_at, matches} record with the full user+assistant
|
||||||
|
pair surrounding `message_id`, in the shape search callers expect."""
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC",
|
||||||
|
(conv_id,),
|
||||||
|
)
|
||||||
|
all_msgs = cur.fetchall()
|
||||||
|
idx = next((i for i, m in enumerate(all_msgs) if m["id"] == message_id), None)
|
||||||
|
if idx is None:
|
||||||
|
return None
|
||||||
|
if all_msgs[idx]["role"] == "user":
|
||||||
|
start, end = idx, min(idx + 1, len(all_msgs) - 1)
|
||||||
|
else:
|
||||||
|
start, end = max(idx - 1, 0), idx
|
||||||
|
matches = [
|
||||||
|
{"role": all_msgs[i]["role"], "content": all_msgs[i]["content"][:500]}
|
||||||
|
for i in range(start, end + 1)
|
||||||
|
]
|
||||||
|
cur.execute("SELECT updated_at FROM conversations WHERE id = ?", (conv_id,))
|
||||||
|
crow = cur.fetchone()
|
||||||
|
return {
|
||||||
|
"id": conv_id,
|
||||||
|
"updated_at": crow["updated_at"] if crow else 0,
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Settings API
|
# Settings API
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -341,6 +608,30 @@ class PersistentMemoryStore:
|
|||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"system_prompt": "",
|
"system_prompt": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
|
# How long Ollama keeps the model resident in VRAM between messages.
|
||||||
|
# "30m"/"-1" (never unload)/"0" (unload now). Avoids cold-reload latency
|
||||||
|
# when you return to an idle chat. Empty → Ollama's 5-minute default.
|
||||||
|
"keep_alive": "30m",
|
||||||
|
# CPU/GPU offload: -1 = Auto (Ollama auto-fits layers to VRAM).
|
||||||
|
# 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]:
|
def get_settings(self) -> Dict[str, Any]:
|
||||||
|
|||||||
Regular → Executable
+12
-8
@@ -15,6 +15,12 @@ except Exception:
|
|||||||
# --- PROJECT ROOT ---
|
# --- PROJECT ROOT ---
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# --- VERSION (single source of truth: the VERSION file at the repo root) ---
|
||||||
|
try:
|
||||||
|
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
||||||
|
except Exception:
|
||||||
|
VERSION = "0.0.0"
|
||||||
|
|
||||||
# --- CORE DIRECTORIES ---
|
# --- CORE DIRECTORIES ---
|
||||||
DATA_DIR = PROJECT_ROOT / "data"
|
DATA_DIR = PROJECT_ROOT / "data"
|
||||||
MODELS_DIR = PROJECT_ROOT / "models"
|
MODELS_DIR = PROJECT_ROOT / "models"
|
||||||
@@ -27,16 +33,15 @@ CACHE_DIR = RUNTIME_DIR / "cache"
|
|||||||
TEMP_DIR = RUNTIME_DIR / "tmp"
|
TEMP_DIR = RUNTIME_DIR / "tmp"
|
||||||
|
|
||||||
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
|
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
|
||||||
PLAYBOOK_DIR = PROJECT_ROOT / "synapse" / "playbooks"
|
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
|
||||||
UPLOADS_DIR = DATA_DIR / "uploads"
|
UPLOADS_DIR = DATA_DIR / "uploads"
|
||||||
EXPORTS_DIR = DATA_DIR / "exports"
|
EXPORTS_DIR = DATA_DIR / "exports"
|
||||||
|
|
||||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||||
MEMORY_DB = MEMORY_DIR / "memory.db"
|
MEMORY_DB = MEMORY_DIR / "memory.db"
|
||||||
PLAYBOOK_DB = MEMORY_DB # same file in your setup
|
|
||||||
|
|
||||||
# --- LOG FILES ---
|
# --- LOG FILES ---
|
||||||
BACKEND_LOG = LOGS_DIR / "backend.log"
|
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||||
OLLAMA_LOG = LOGS_DIR / "ollama.log"
|
OLLAMA_LOG = LOGS_DIR / "ollama.log"
|
||||||
CHAT_LOG = LOGS_DIR / "chat.log"
|
CHAT_LOG = LOGS_DIR / "chat.log"
|
||||||
|
|
||||||
@@ -73,7 +78,6 @@ def path(name: str) -> Path:
|
|||||||
"uploads": UPLOADS_DIR,
|
"uploads": UPLOADS_DIR,
|
||||||
"exports": EXPORTS_DIR,
|
"exports": EXPORTS_DIR,
|
||||||
"memory_db": MEMORY_DB,
|
"memory_db": MEMORY_DB,
|
||||||
"playbook_db": PLAYBOOK_DB,
|
|
||||||
"backend_log": BACKEND_LOG,
|
"backend_log": BACKEND_LOG,
|
||||||
"ollama_log": OLLAMA_LOG,
|
"ollama_log": OLLAMA_LOG,
|
||||||
"chat_log": CHAT_LOG,
|
"chat_log": CHAT_LOG,
|
||||||
@@ -90,6 +94,7 @@ class Settings:
|
|||||||
or `Settings` class for typing/tests.
|
or `Settings` class for typing/tests.
|
||||||
"""
|
"""
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
self.version: str = VERSION
|
||||||
self.project_root: Path = PROJECT_ROOT
|
self.project_root: Path = PROJECT_ROOT
|
||||||
self.data_dir: Path = DATA_DIR
|
self.data_dir: Path = DATA_DIR
|
||||||
self.models_dir: Path = MODELS_DIR
|
self.models_dir: Path = MODELS_DIR
|
||||||
@@ -99,7 +104,6 @@ class Settings:
|
|||||||
|
|
||||||
# DB files
|
# DB files
|
||||||
self.memory_db: Path = MEMORY_DB
|
self.memory_db: Path = MEMORY_DB
|
||||||
self.playbook_db: Path = PLAYBOOK_DB
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
self.backend_log: Path = BACKEND_LOG
|
self.backend_log: Path = BACKEND_LOG
|
||||||
@@ -112,13 +116,13 @@ class Settings:
|
|||||||
|
|
||||||
def as_dict(self) -> Dict[str, Any]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
"version": self.version,
|
||||||
"project_root": str(self.project_root),
|
"project_root": str(self.project_root),
|
||||||
"data_dir": str(self.data_dir),
|
"data_dir": str(self.data_dir),
|
||||||
"models_dir": str(self.models_dir),
|
"models_dir": str(self.models_dir),
|
||||||
"runtime_dir": str(self.runtime_dir),
|
"runtime_dir": str(self.runtime_dir),
|
||||||
"memory_dir": str(self.memory_dir),
|
"memory_dir": str(self.memory_dir),
|
||||||
"memory_db": str(self.memory_db),
|
"memory_db": str(self.memory_db),
|
||||||
"playbook_db": str(self.playbook_db),
|
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"ollama_timeout": self.ollama_timeout,
|
"ollama_timeout": self.ollama_timeout,
|
||||||
}
|
}
|
||||||
@@ -127,10 +131,10 @@ class Settings:
|
|||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# explicit exports for static checkers and IDEs
|
# explicit exports for static checkers and IDEs
|
||||||
__all__ = ["Settings", "settings", "path",
|
__all__ = ["Settings", "settings", "path", "VERSION",
|
||||||
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||||
"EXPORTS_DIR", "MEMORY_DB", "PLAYBOOK_DB",
|
"EXPORTS_DIR", "MEMORY_DB",
|
||||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
|
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
|
||||||
|
|
||||||
# --- quick runtime sanity check when run directly (no side effects on import) ---
|
# --- quick runtime sanity check when run directly (no side effects on import) ---
|
||||||
|
|||||||
Regular → Executable
+178
-42
@@ -1,15 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .nexus_config import settings
|
from .nexus_config import settings
|
||||||
|
from .memory.store import store
|
||||||
|
|
||||||
OLLAMA_PORT = 11434
|
OLLAMA_PORT = 11434
|
||||||
|
|
||||||
@@ -131,10 +132,9 @@ def _detect_gpu_backend() -> tuple[str, dict]:
|
|||||||
|
|
||||||
if vulkan_ok:
|
if vulkan_ok:
|
||||||
idx, name = _best_vulkan_device()
|
idx, name = _best_vulkan_device()
|
||||||
env_overrides: dict = {"OLLAMA_GPU": "vulkan"}
|
# Always pin to the selected device — without this, Ollama may use the Intel
|
||||||
if idx > 0:
|
# iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
|
||||||
# Explicitly route Ollama to the discrete GPU when it isn't device 0
|
env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
|
||||||
env_overrides["GGML_VK_VISIBLE_DEVICES"] = str(idx)
|
|
||||||
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
|
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
|
||||||
return f"vulkan ({name})", env_overrides
|
return f"vulkan ({name})", env_overrides
|
||||||
|
|
||||||
@@ -153,18 +153,36 @@ def _detect_gpu_backend() -> tuple[str, dict]:
|
|||||||
return "cpu", {}
|
return "cpu", {}
|
||||||
|
|
||||||
|
|
||||||
def _model_score(name: str) -> tuple:
|
# Single source of model auto-selection preference. Ordered so small, GPU-fitting
|
||||||
"""Score a model name for auto-selection. Higher tuple = better."""
|
# models come first (qwen2.5:3b fits a 4GB card and is a strong all-rounder); the
|
||||||
lower = name.lower()
|
# tail differs by task. Prefix-matched against installed model names.
|
||||||
match = re.search(r'(\d+(?:\.\d+)?)[bB]', lower)
|
_MODEL_PREFERENCE = {
|
||||||
params = float(match.group(1)) if match else 7.0
|
"chat": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "gemma2", "gemma"),
|
||||||
# Tier-break: known quality models get a small bonus
|
"code": ("qwen2.5:3b", "qwen2.5", "gemma3:1b", "gemma3", "phi3", "phi-3", "codellama", "deepseek-coder", "codegemma"),
|
||||||
quality = next(
|
}
|
||||||
(i for i, prefix in enumerate(("llama3", "llama2", "mistral", "gemma", "phi", "qwen"), 1)
|
|
||||||
if prefix in lower),
|
|
||||||
0,
|
def _preferred_model(models: list, preference) -> str | None:
|
||||||
)
|
"""First installed model whose name starts with a preference prefix."""
|
||||||
return (params, quality)
|
for prefix in preference:
|
||||||
|
for m in models:
|
||||||
|
if m.lower().startswith(prefix.lower()):
|
||||||
|
return m
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
|
||||||
|
"""Assemble the Ollama `options` block from the knobs we expose.
|
||||||
|
|
||||||
|
Returns an empty dict when nothing is set so callers can omit `options`
|
||||||
|
entirely (preserving Ollama's defaults / auto behaviour).
|
||||||
|
"""
|
||||||
|
opts: dict = {}
|
||||||
|
if temperature is not None:
|
||||||
|
opts["temperature"] = temperature
|
||||||
|
if num_gpu is not None:
|
||||||
|
opts["num_gpu"] = num_gpu
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
class OllamaManager:
|
class OllamaManager:
|
||||||
@@ -181,8 +199,43 @@ class OllamaManager:
|
|||||||
self._api_base = settings.ollama_host.rstrip("/")
|
self._api_base = settings.ollama_host.rstrip("/")
|
||||||
|
|
||||||
# Model selection cache
|
# Model selection cache
|
||||||
self._model_cache: str | None = None
|
self._model_cache: dict = {} # intent -> (model, monotonic_ts)
|
||||||
self._model_cache_ts: float = 0.0
|
|
||||||
|
# Per-model offloadable layer count cache (never changes for a model)
|
||||||
|
self._layer_cache: dict[str, int] = {}
|
||||||
|
|
||||||
|
# How long Ollama keeps the model resident between requests. Applied to
|
||||||
|
# every chat/generate body so the model isn't reloaded on each message.
|
||||||
|
# Overridden from persisted settings at startup. Falsy → omit (Ollama's
|
||||||
|
# 5-minute default).
|
||||||
|
self.keep_alive: str | None = "30m"
|
||||||
|
|
||||||
|
def _apply_keep_alive(self, body: dict) -> dict:
|
||||||
|
"""Add `keep_alive` (a top-level Ollama field) to a request body when set."""
|
||||||
|
if self.keep_alive:
|
||||||
|
body["keep_alive"] = self.keep_alive
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def warm(self, model: str | None = None, num_gpu: int | None = None) -> None:
|
||||||
|
"""Preload a model so the first request doesn't pay a cold load.
|
||||||
|
An empty-prompt /api/generate is Ollama's documented preload. Pass the
|
||||||
|
same `num_gpu` the real requests use, or the preloaded copy is placed
|
||||||
|
differently and gets reloaded on first use. Best-effort: never raises,
|
||||||
|
so a missing model or down server can't break startup."""
|
||||||
|
try:
|
||||||
|
model = model or await self.select_best_model()
|
||||||
|
if not model:
|
||||||
|
return
|
||||||
|
body = {"model": model, "prompt": "", "stream": False}
|
||||||
|
if num_gpu is not None:
|
||||||
|
body["options"] = {"num_gpu": num_gpu}
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{self._api_base}/api/generate", json=self._apply_keep_alive(body),
|
||||||
|
)
|
||||||
|
_log.info("warm: preloaded model=%s num_gpu=%s keep_alive=%s", model, num_gpu, self.keep_alive)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning("warm: preload failed: %s", e)
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
bin_path = _ollama_bin()
|
bin_path = _ollama_bin()
|
||||||
@@ -328,12 +381,12 @@ class OllamaManager:
|
|||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self._api_base}/api/generate",
|
f"{self._api_base}/api/generate",
|
||||||
json={
|
json=self._apply_keep_alive({
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"system": system,
|
"system": system,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
@@ -356,12 +409,12 @@ class OllamaManager:
|
|||||||
async with client.stream(
|
async with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
f"{self._api_base}/api/generate",
|
f"{self._api_base}/api/generate",
|
||||||
json={
|
json=self._apply_keep_alive({
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"system": system,
|
"system": system,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
},
|
}),
|
||||||
) as response:
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
@@ -391,17 +444,23 @@ class OllamaManager:
|
|||||||
model: str = "mistral",
|
model: str = "mistral",
|
||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
|
num_gpu: int | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Multi-turn chat via /api/chat (accepts a messages array with roles)."""
|
"""Multi-turn chat via /api/chat (accepts a messages array with roles)."""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
if stream:
|
if stream:
|
||||||
return self._chat_stream(messages=messages, model=model, temperature=temperature, start=start)
|
return self._chat_stream(
|
||||||
|
messages=messages, model=model, temperature=temperature,
|
||||||
|
num_gpu=num_gpu, start=start,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
body: dict = {"model": model, "messages": messages, "stream": False}
|
body: dict = {"model": model, "messages": messages, "stream": False}
|
||||||
if temperature is not None:
|
opts = _chat_options(temperature, num_gpu)
|
||||||
body["options"] = {"temperature": temperature}
|
if opts:
|
||||||
|
body["options"] = opts
|
||||||
|
self._apply_keep_alive(body)
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
r = await client.post(f"{self._api_base}/api/chat", json=body)
|
r = await client.post(f"{self._api_base}/api/chat", json=body)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
@@ -412,6 +471,33 @@ class OllamaManager:
|
|||||||
_log.exception("chat error after %.3fs: %s", elapsed, e)
|
_log.exception("chat error after %.3fs: %s", elapsed, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def embed(self, text: str, model: str = "nomic-embed-text") -> list[float] | None:
|
||||||
|
"""Return an embedding vector for `text` via /api/embeddings.
|
||||||
|
|
||||||
|
Returns None on any failure so callers can fall back to lexical search —
|
||||||
|
a missing embedding model should never break chat or recall.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
gpu_offload = store.get_settings().get("memory_gpu_offload", 0)
|
||||||
|
num_gpu = await self.resolve_num_gpu(gpu_offload, model)
|
||||||
|
body: dict[str, Any] = {"model": model, "prompt": text}
|
||||||
|
if num_gpu is not None:
|
||||||
|
body["options"] = {"num_gpu": num_gpu}
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self._api_base}/api/embeddings",
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
vec = r.json().get("embedding")
|
||||||
|
return vec if vec else None
|
||||||
|
except Exception as e:
|
||||||
|
_log.debug("embed failed (model=%s): %s", model, e)
|
||||||
|
return None
|
||||||
|
|
||||||
async def list_models(self) -> list[str]:
|
async def list_models(self) -> list[str]:
|
||||||
"""Return names of all locally installed Ollama models."""
|
"""Return names of all locally installed Ollama models."""
|
||||||
try:
|
try:
|
||||||
@@ -422,34 +508,84 @@ class OllamaManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def select_best_model(self) -> str:
|
async def select_best_model(self, intent: str = "chat") -> str:
|
||||||
"""Pick the highest-scoring available model, falling back to 'mistral'.
|
"""Pick the preferred installed model for `intent` ('chat' or 'code'),
|
||||||
|
falling back to any installed model, then 'mistral'. Cached ~60s per
|
||||||
Result is cached for 60 seconds so rapid chat requests don't each
|
intent so rapid requests don't rebuild the model list each time.
|
||||||
hit the Ollama API to build the model list.
|
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if self._model_cache and (now - self._model_cache_ts) < 60:
|
cached = self._model_cache.get(intent)
|
||||||
return self._model_cache
|
if cached and (now - cached[1]) < 60:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
models = await self.list_models()
|
models = await self.list_models()
|
||||||
best = max(models, key=_model_score) if models else "mistral"
|
pref = _MODEL_PREFERENCE.get(intent, _MODEL_PREFERENCE["chat"])
|
||||||
|
best = _preferred_model(models, pref) or (models[0] if models else "mistral")
|
||||||
|
|
||||||
self._model_cache = best
|
self._model_cache[intent] = (best, now)
|
||||||
self._model_cache_ts = now
|
|
||||||
return best
|
return best
|
||||||
|
|
||||||
def invalidate_model_cache(self):
|
def invalidate_model_cache(self):
|
||||||
"""Force next select_best_model() to re-query (e.g. after pull/delete)."""
|
"""Force next select_best_model() to re-query (e.g. after pull/delete)."""
|
||||||
self._model_cache = None
|
self._model_cache = {}
|
||||||
self._model_cache_ts = 0.0
|
|
||||||
|
|
||||||
async def _chat_stream(self, messages: list, model: str, start: float, temperature: float | None = None):
|
async def get_model_layers(self, model: str) -> int | None:
|
||||||
|
"""Total offloadable layer count for `model` (repeating blocks + output layer).
|
||||||
|
|
||||||
|
Used to turn a CPU/GPU offload percentage into an Ollama `num_gpu`
|
||||||
|
value. Reads `<arch>.block_count` from /api/show and adds 1 for the
|
||||||
|
non-repeating output layer (Ollama reports e.g. 33 layers for a model
|
||||||
|
with block_count=32). Cached per-model since it never changes.
|
||||||
|
Returns None if the count can't be determined, so callers fall back
|
||||||
|
to Auto (no num_gpu override).
|
||||||
|
"""
|
||||||
|
if model in self._layer_cache:
|
||||||
|
return self._layer_cache[model]
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.post(f"{self._api_base}/api/show", json={"model": model})
|
||||||
|
r.raise_for_status()
|
||||||
|
info = r.json().get("model_info", {}) or {}
|
||||||
|
block_count = next(
|
||||||
|
(v for k, v in info.items() if k.endswith(".block_count")), None
|
||||||
|
)
|
||||||
|
layers = int(block_count) + 1 if block_count is not None else None
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning("get_model_layers(%s) failed: %s", model, e)
|
||||||
|
layers = None
|
||||||
|
if layers:
|
||||||
|
self._layer_cache[model] = layers
|
||||||
|
return layers
|
||||||
|
|
||||||
|
async def resolve_num_gpu(self, gpu_offload, model: str) -> int | None:
|
||||||
|
"""Convert a stored gpu_offload setting into an Ollama `num_gpu` value.
|
||||||
|
|
||||||
|
`gpu_offload` is -1 for Auto (returns None → no override, Ollama auto-fits)
|
||||||
|
or 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."""
|
"""Async generator streaming tokens, then a final __meta__ stats sentinel."""
|
||||||
try:
|
try:
|
||||||
body: dict = {"model": model, "messages": messages, "stream": True}
|
body: dict = {"model": model, "messages": messages, "stream": True}
|
||||||
if temperature is not None:
|
opts = _chat_options(temperature, num_gpu)
|
||||||
body["options"] = {"temperature": temperature}
|
if opts:
|
||||||
|
body["options"] = opts
|
||||||
|
self._apply_keep_alive(body)
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -484,7 +620,7 @@ class OllamaManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
_log.exception("chat stream error after %.3fs: %s", elapsed, e)
|
_log.exception("chat stream error after %.3fs: %s", elapsed, e)
|
||||||
return
|
raise
|
||||||
|
|
||||||
|
|
||||||
def initialize_ollama() -> OllamaManager:
|
def initialize_ollama() -> OllamaManager:
|
||||||
|
|||||||
Regular → Executable
+7
-18
@@ -1,11 +1,8 @@
|
|||||||
import threading
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from .playbooks.store import playbook_store, PlaybookItem
|
from .playbooks.store import playbook_store, PlaybookItem
|
||||||
|
|
||||||
|
|
||||||
class PlaybookManager:
|
class PlaybookManager:
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _all(cls) -> List[PlaybookItem]:
|
def _all(cls) -> List[PlaybookItem]:
|
||||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
"""Return all playbooks sorted by order (position 0 is always main)."""
|
||||||
@@ -25,18 +22,10 @@ class PlaybookManager:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_system_prompt(cls) -> str:
|
def get_system_prompt(cls) -> str:
|
||||||
playbook = cls.get_main_playbook()
|
playbook = cls.get_main_playbook()
|
||||||
return getattr(playbook, "instructions", "") or "" if playbook else ""
|
if not playbook:
|
||||||
|
return ""
|
||||||
@classmethod
|
goal = (getattr(playbook, "goal", "") or "").strip()
|
||||||
def render_prompt(cls, user_message: str, variables: dict | None = None) -> str:
|
instructions = (getattr(playbook, "instructions", "") or "").strip()
|
||||||
if not variables:
|
if goal and instructions:
|
||||||
return user_message
|
return f"{goal}\n\n{instructions}"
|
||||||
result = user_message
|
return goal or instructions
|
||||||
for key, value in variables.items():
|
|
||||||
result = result.replace(f"{{{{{key}}}}}", str(value))
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Keep old name as alias so nothing else breaks
|
|
||||||
@classmethod
|
|
||||||
def get_active_playbook(cls) -> PlaybookItem | None:
|
|
||||||
return cls.get_main_playbook()
|
|
||||||
Regular → Executable
Regular → Executable
-12
@@ -90,18 +90,6 @@ class PlaybookFileStore:
|
|||||||
item.order = index
|
item.order = index
|
||||||
self._write(item)
|
self._write(item)
|
||||||
|
|
||||||
def search_playbooks(self, query: str) -> List[PlaybookItem]:
|
|
||||||
if not query or not query.strip():
|
|
||||||
return []
|
|
||||||
q = query.strip().lower()
|
|
||||||
return [
|
|
||||||
p for p in self.all_playbooks()
|
|
||||||
if q in p.title.lower()
|
|
||||||
or q in p.goal.lower()
|
|
||||||
or q in p.instructions.lower()
|
|
||||||
or any(q in t.lower() for t in p.tags)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
from ..nexus_config import PLAYBOOK_DIR
|
from ..nexus_config import PLAYBOOK_DIR
|
||||||
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
|
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
|
||||||
|
|||||||
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