Initial commit: NexusOS project + desktop theme baseline

Captures the known-good state after theme consolidation and
consistency reconciliation:
- NexusOS GTK/xfwm4/icon theme assets (assets/themes, management/Mint-Y-Nexus)
- Tightened right-click menus, visible separators, no menu icons
- assets/themes/install-theme.sh: idempotent restore of all wiring
  (symlinks, xfconf xsettings+xfwm4, GTK 3/4 settings.ini)
- .gitignore excludes venv/ollama/models/runtime/db

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jon
2026-05-18 13:39:48 -05:00
commit b0aa0438af
1264 changed files with 19255 additions and 0 deletions
View File
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
import asyncio
import logging
import threading
from typing import AsyncGenerator, Dict, List, Optional, Any
from .nexus_config import settings
from .ollama_manager import get_ollama_manager
# -------------------------
# Logger setup
# -------------------------
_logger = logging.getLogger("nexus.chat")
_logger.setLevel(logging.INFO)
if not _logger.handlers:
handler = logging.FileHandler(str(settings.chat_log)) if getattr(settings, "chat_log", None) else logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
handler.setFormatter(formatter)
_logger.addHandler(handler)
# -------------------------
# Synapse tracer (real-time prompt/token view for control panel)
# -------------------------
_synapse_lock = threading.Lock()
_synapse_fh = None
def _synapse_trace(text: str) -> None:
global _synapse_fh
try:
log_path = getattr(settings, "chat_log", None)
if not log_path:
return
with _synapse_lock:
if _synapse_fh is None or _synapse_fh.closed:
_synapse_fh = open(str(log_path), "a", buffering=1, encoding="utf-8")
_synapse_fh.write(text)
_synapse_fh.flush()
except Exception:
pass
# -------------------------
# Non-streaming generation
# -------------------------
async def generate_chat_response(
user_message: str,
metadata: Optional[Dict[str, Any]] = None,
history: Optional[List[Dict[str, str]]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
metadata = metadata or {}
timeout = timeout or getattr(settings, "ollama_timeout", 120)
manager = get_ollama_manager()
system = metadata.get("system", "")
model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature")
messages: List[Dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
_logger.info("generate_chat_response: model=%s turns=%d timeout=%s", model, len(messages), timeout)
sys_preview = (system or "")[:200].replace("\n", " ")
_synapse_trace(f"\n── TURN [{model} | {len(messages)} msgs] {'' * 30}\n")
if system:
_synapse_trace(f"SYS: {sys_preview}{'' if len(system) > 200 else ''}\n")
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try:
result = await asyncio.wait_for(
manager.chat(messages=messages, model=model, stream=False, temperature=temperature),
timeout=timeout,
)
response_text = result if isinstance(result, str) else str(result)
preview = response_text[:500].replace("\n", " ")
_synapse_trace(f"{preview}{'' if len(response_text) > 500 else ''}\n{'' * 50}\n")
_logger.info("generate_chat_response: completed model=%s", model)
return {"response": response_text, "model": model, "metadata": metadata}
except asyncio.TimeoutError:
_logger.exception("generate_chat_response: timeout after %s seconds", timeout)
raise
except Exception:
_logger.exception("generate_chat_response: unexpected error")
raise
# -------------------------
# Async iterator timeout helper
# -------------------------
async def _aiter_with_timeout(aiterable, timeout: Optional[float]):
if timeout is None or timeout <= 0:
async for item in aiterable:
yield item
return
aiter = aiterable.__aiter__()
while True:
try:
item = await asyncio.wait_for(aiter.__anext__(), timeout=timeout)
yield item
except StopAsyncIteration:
break
# -------------------------
# Normalizer for many return shapes
# -------------------------
async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, None]:
if hasattr(maybe_iterable, "__aiter__"):
async for item in maybe_iterable:
yield str(item)
return
if asyncio.iscoroutine(maybe_iterable):
result = await maybe_iterable
if hasattr(result, "__aiter__"):
async for item in result:
yield str(item)
return
if hasattr(result, "__iter__") and not isinstance(result, (str, bytes)):
for item in result:
yield str(item)
return
yield str(result)
return
if hasattr(maybe_iterable, "__iter__") and not isinstance(maybe_iterable, (str, bytes)):
for item in maybe_iterable:
yield str(item)
return
yield str(maybe_iterable)
# -------------------------
# Streaming implementation
# -------------------------
async def stream_chat_response(
user_message: str,
metadata: Optional[Dict[str, Any]] = None,
history: Optional[List[Dict[str, str]]] = None,
timeout: Optional[float] = None,
) -> AsyncGenerator[str, None]:
metadata = metadata or {}
timeout = timeout or getattr(settings, "ollama_timeout", 120)
manager = get_ollama_manager()
system = metadata.get("system", "")
model = metadata.get("model") or "mistral"
temperature = metadata.get("temperature")
# Build messages array for /api/chat multi-turn format
messages: List[Dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
_logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout)
sys_preview = (system or "")[:200].replace("\n", " ")
_synapse_trace(f"\n── TURN [{model} | {len(messages)} msgs] {'' * 30}\n")
if system:
_synapse_trace(f"SYS: {sys_preview}{'' if len(system) > 200 else ''}\n")
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try:
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature)
async_gen = _normalize_to_async_generator(maybe_iter)
buffer_parts: list[str] = []
buffer_len = 0
FLUSH_THRESHOLD = 24
async for piece in _aiter_with_timeout(async_gen, timeout):
if piece is None:
continue
text = str(piece)
if not text:
continue
# Pass stats sentinel through immediately, don't buffer it
if text.startswith("__meta__"):
if buffer_parts:
chunk = "".join(buffer_parts)
buffer_parts = []
buffer_len = 0
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
yield text
continue
buffer_parts.append(text)
buffer_len += len(text)
if buffer_len >= FLUSH_THRESHOLD or any(text.endswith(c) for c in (".", "!", "?", "\n")):
chunk = "".join(buffer_parts)
buffer_parts = []
buffer_len = 0
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
if buffer_parts:
chunk = "".join(buffer_parts)
_synapse_trace(chunk.replace("\n", " ") + "\n")
yield chunk
_synapse_trace(f"{'' * 50}\n")
_logger.info("stream_chat_response: stream completed")
except asyncio.TimeoutError:
_logger.exception("stream_chat_response: timeout after %s seconds", timeout)
raise
except Exception:
_logger.exception("stream_chat_response: unexpected error during streaming")
raise
# -------------------------
# Synchronous convenience wrappers
# -------------------------
def generate_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
return asyncio.run(generate_chat_response(user_message, metadata=metadata, timeout=timeout))
def stream_chat_response_sync(user_message: str, metadata: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None):
async def _collect():
chunks = []
async for c in stream_chat_response(user_message, metadata=metadata, timeout=timeout):
chunks.append(c)
return chunks
return asyncio.run(_collect())
+728
View File
@@ -0,0 +1,728 @@
# main.py
from __future__ import annotations
import asyncio as _asyncio
import json as _json
import uuid as _uuid
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
from uuid import UUID
import httpx
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from .nexus_config import settings
from .chat import generate_chat_response, stream_chat_response
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from .playbook_manager import PlaybookManager
def _render_memory_block(facts) -> str:
"""Render memory items as grouped ## Section / - bullet markdown."""
from collections import defaultdict
sections: dict = defaultdict(list)
for item in facts:
sections[item.section or "General"].append(item.text)
parts = []
for section, lines in sections.items():
parts.append(f"## {section}\n" + "\n".join(f" - {line}" for line in lines))
return "\n\n".join(parts)
async def _auto_select_model() -> str:
try:
s = store.get_settings()
if s.get("model"):
return s["model"]
return await get_ollama_manager().select_best_model()
except Exception:
return getattr(settings, "default_model", None) or "mistral"
from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem
MEMORY_SERVICE = "http://localhost:8001"
app = FastAPI(title="Synapse Backend", version="1.0")
# Alias for startup scripts
sio_app = app
# --- CORS ---
# allow_credentials=True is incompatible with allow_origins=["*"] — browsers
# reject such responses. Since this is a local-only service with no cookies/auth,
# wildcard origins without credentials is correct.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# --- GLOBALS ---
playbooks = PlaybookManager()
ollama = None
# -------------------------
# Startup: Initialize Ollama service
# -------------------------
@app.on_event("startup")
async def startup_event():
global ollama
try:
ollama = await initialize_ollama_async()
print("[Synapse] Ollama service is running.")
except Exception as e:
# Start in degraded mode — chat endpoints will return errors until Ollama
# is brought up, but the backend itself stays alive so the control panel
# and other endpoints remain reachable.
print(f"[Synapse] WARNING: Ollama unavailable at startup: {e}")
from .ollama_manager import OllamaManager
ollama = OllamaManager()
# -------------------------
# Root
# -------------------------
@app.get("/")
async def root():
try:
status = ollama.get_status() if (ollama is not None and hasattr(ollama, "get_status")) else None
except Exception:
status = None
return {"status": "online", "ollama": status}
# -------------------------
# Chat (non-streaming)
# -------------------------
@app.post("/chat")
async def chat_endpoint(payload: Dict[str, Any]):
try:
message = payload.get("message", "")
app_settings = store.get_settings()
model = payload.get("model") or await _auto_select_model()
context = payload.get("context", {})
history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature"))
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
rendered_message = playbooks.render_prompt(message)
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
memory_facts = store.all()
if memory_facts:
facts_block = _render_memory_block(memory_facts)
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
result = await generate_chat_response(
user_message=rendered_message, metadata=metadata, history=history
)
return {"response": result.get("response", ""), "model": model}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Chat (streaming)
# -------------------------
@app.post("/chat/stream")
async def chat_stream_endpoint(payload: Dict[str, Any]):
try:
message = payload.get("message", "")
app_settings = store.get_settings()
model = payload.get("model") or await _auto_select_model()
context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature"))
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", "")
# Append reference playbooks to the system prompt
context_pbs = playbooks.get_context_playbooks()
if context_pbs:
refs = "\n\n".join(
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
for pb in context_pbs
)
separator = "\n\n---\nReference playbooks (read these as additional context):\n\n"
system_prompt = (system_prompt + separator + refs) if system_prompt else refs
# Inject persistent memory facts about Jon
memory_facts = store.all()
if memory_facts:
facts_block = _render_memory_block(memory_facts)
system_prompt = (system_prompt + "\n\n---\nWhat you know about Jon:\n\n" + facts_block) if system_prompt else facts_block
# Search past conversations for relevant context and inject the top matches.
# This gives the model memory of prior exchanges without requiring tool-calling support.
past_context = store.search_conversations(message, limit=2)
if past_context:
snippets = []
for conv in past_context:
exchange = "\n".join(
f" {m['role'].upper()}: {m['content']}"
for m in conv["matches"]
)
snippets.append(exchange)
memory_block = "\n\n".join(snippets)
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature}
# Persist conversation and user message before streaming
store.create_conversation(conversation_id)
store.add_message(conversation_id, "user", rendered_message)
async def event_stream() -> AsyncGenerator[str, None]:
response_chunks: list[str] = []
meta: dict = {}
try:
async for chunk in stream_chat_response(
user_message=rendered_message,
metadata=metadata,
history=history,
):
if chunk.startswith("__meta__"):
try:
meta = _json.loads(chunk[8:])
except Exception:
pass
yield f"event: meta\ndata: {chunk[8:]}\n\n"
continue
response_chunks.append(chunk)
yield f"data: {chunk}\n\n"
# Persist the completed assistant response with model and token stats
if response_chunks:
store.add_message(
conversation_id, "assistant", "".join(response_chunks),
model=meta.get("model") or model,
tokens=meta.get("tokens"),
)
except Exception as e:
yield f"event: error\ndata: {_json.dumps({'detail': str(e)})}\n\n"
return
# Ask the memory service curator to evaluate this exchange
if response_chunks:
try:
async with httpx.AsyncClient(timeout=25.0) as _mc:
r = await _mc.post(
f"{MEMORY_SERVICE}/memories/extract",
json={
"user_message": rendered_message,
"assistant_response": "".join(response_chunks),
},
)
if r.status_code == 200:
data = r.json()
if data.get("saved"):
mem_result = {"section": data["section"], "text": data["text"]}
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
except Exception:
pass
return StreamingResponse(event_stream(), media_type="text/event-stream")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Playbook Execution
# -------------------------
@app.post("/playbook/run")
async def run_playbook(payload: Dict[str, Any]):
name = payload.get("name")
variables = payload.get("variables", {})
if not name:
raise HTTPException(status_code=400, detail="Missing playbook name")
try:
result = playbooks.render_prompt(name, variables=variables)
return {"result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Settings
# -------------------------
@app.get("/settings")
async def get_settings_endpoint():
return store.get_settings()
@app.put("/settings")
async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
store.update_settings(payload)
return store.get_settings()
# -------------------------
# Memory
# -------------------------
@app.get("/memory")
async def get_memory():
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags} for m in store.all()]}
@app.post("/memory")
async def add_memory(payload: Dict[str, Any] = Body(...)):
text = (payload.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Missing 'text'")
from .memory.store import MemoryItem
import uuid as _mem_uuid
item = MemoryItem(
id=str(_mem_uuid.uuid4()),
section=(payload.get("section") or "General").strip(),
text=text,
tags=payload.get("tags", []),
)
store.add(item)
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
@app.patch("/memory/{item_id}")
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
existing = store.get(item_id)
if not existing:
raise HTTPException(status_code=404, detail="Not found")
from .memory.store import MemoryItem
updated = MemoryItem(
id=item_id,
section=(payload.get("section") or existing.section or "General").strip(),
text=(payload.get("text") or existing.text).strip(),
tags=payload.get("tags", existing.tags),
)
store.update(updated)
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
@app.delete("/memory/{item_id}")
async def delete_memory(item_id: str):
if not store.get(item_id):
raise HTTPException(status_code=404, detail="Not found")
store.delete(item_id)
return {"status": "deleted"}
# -------------------------
# Models
# -------------------------
@app.get("/models")
async def get_models():
try:
mgr = get_ollama_manager()
models = await mgr.list_models()
selected = await mgr.select_best_model()
return {"models": models, "selected": selected}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/models/details")
async def get_model_details():
"""Return full model objects (name, size, modified_at) from Ollama."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{settings.ollama_host.rstrip('/')}/api/tags")
r.raise_for_status()
return r.json()
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e}")
@app.post("/models/pull")
async def pull_model(payload: Dict[str, Any] = Body(...)):
"""Proxy a streaming model pull from Ollama."""
name = payload.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="Missing model name")
async def _stream():
try:
async with httpx.AsyncClient(timeout=600.0) as client:
async with client.stream(
"POST", f"{settings.ollama_host.rstrip('/')}/api/pull",
json={"name": name},
) as resp:
async for line in resp.aiter_lines():
if line:
yield line + "\n"
except Exception as e:
yield f'{{"error": "{e}"}}\n'
finally:
# Bust the model cache so new model is visible immediately
get_ollama_manager().invalidate_model_cache()
return StreamingResponse(_stream(), media_type="application/x-ndjson")
@app.delete("/models/{name:path}")
async def delete_model(name: str):
"""Proxy a model deletion to Ollama."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.request(
"DELETE", f"{settings.ollama_host.rstrip('/')}/api/delete",
json={"name": name},
)
if not r.is_success:
raise HTTPException(status_code=r.status_code, detail=r.text)
get_ollama_manager().invalidate_model_cache()
return {"status": "deleted", "name": name}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e}")
# -------------------------
# Ollama Status
# -------------------------
@app.get("/ollama/status")
async def ollama_status_endpoint():
try:
if ollama is not None and hasattr(ollama, "get_status"):
status = ollama.get_status()
else:
status = None
return {"status": status}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Ollama Start
# -------------------------
@app.post("/ollama/start")
async def ollama_start_endpoint():
try:
global ollama
if ollama is None:
ollama = initialize_ollama()
# Try to start if it has a start method
if hasattr(ollama, "start"):
ollama.start()
is_running = ollama.is_running() if hasattr(ollama, "is_running") else True
return {"status": "started" if is_running else "failed", "running": is_running}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Ollama Stop
# -------------------------
@app.post("/ollama/stop")
async def ollama_stop_endpoint():
try:
global ollama
if ollama is not None and hasattr(ollama, "stop"):
ollama.stop()
is_running = ollama.is_running() if (ollama is not None and hasattr(ollama, "is_running")) else False
return {"status": "stopped" if not is_running else "still_running", "running": is_running}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Playbooks List (existing)
# -------------------------
@app.get("/playbooks")
async def get_playbooks():
try:
playbook_list = playbook_store.all_playbooks()
return {
"playbooks": [
{
"id": p.id,
"title": p.title,
"goal": p.goal,
"instructions": getattr(p, "instructions", ""),
"tags": getattr(p, "tags", []),
}
for p in playbook_list
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Playbook retrieve / update / replace (new)
# -------------------------
def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]]:
"""
Try common store getters, then fall back to scanning store.all_playbooks().
Returns (playbook_obj, key) where key is the identifier used by store if applicable.
"""
try:
pb = playbook_store.get_playbook(playbook_id)
if pb:
return pb, playbook_id
except Exception:
pass
try:
for p in playbook_store.all_playbooks():
pid = str(getattr(p, "id", None) or "")
if pid == str(playbook_id):
return p, pid
except Exception:
pass
return None, None
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Persist a playbook dict to the store by converting to PlaybookItem.
"""
try:
# Preserve existing order on update; use provided order (or tail) on create
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
playbook_item = PlaybookItem(
id=str(playbook_dict["id"]),
title=playbook_dict.get("title", ""),
goal=playbook_dict.get("goal", ""),
instructions=playbook_dict.get("instructions", ""),
tags=playbook_dict.get("tags", []),
order=order
)
playbook_store.add_playbook(playbook_item)
# Return as dict
return {
"id": playbook_item.id,
"title": playbook_item.title,
"goal": playbook_item.goal,
"instructions": playbook_item.instructions,
"tags": playbook_item.tags,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
@app.post("/playbooks/reorder")
async def reorder_playbooks(payload: Dict[str, Any] = Body(...)):
"""
Accepts {"ids": ["id1", "id2", "id3"]} in the desired order.
"""
try:
ids = payload.get("ids", [])
if not ids:
raise HTTPException(status_code=400, detail="Missing ids list")
playbook_store.reorder_playbooks(ids)
return {"status": "reordered"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/playbooks")
async def create_playbook(payload: Dict[str, Any] = Body(...)):
try:
if not payload.get("title") or not payload.get("goal") or not payload.get("instructions"):
raise HTTPException(status_code=400, detail="Missing required fields: title, goal, instructions")
payload["id"] = str(_uuid.uuid4())
payload.setdefault("tags", [])
created = _persist_playbook(payload)
return created
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/playbooks/{id}")
async def get_playbook(id: UUID):
try:
pb, _ = _find_playbook_by_id(str(id))
if not pb:
raise HTTPException(status_code=404, detail="Not Found")
# Build dict in the exact order you want
result = {
"id": getattr(pb, "id", None) or (pb.get("id") if isinstance(pb, dict) else None),
"title": getattr(pb, "title", None) or (pb.get("title") if isinstance(pb, dict) else None),
"goal": getattr(pb, "goal", None) or (pb.get("goal") if isinstance(pb, dict) else None),
"instructions": getattr(pb, "instructions", "") or (pb.get("instructions") if isinstance(pb, dict) else ""),
"tags": getattr(pb, "tags", []) or (pb.get("tags") if isinstance(pb, dict) else []),
}
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.patch("/playbooks/{id}")
async def patch_playbook(id: UUID, payload: Dict[str, Any] = Body(...)):
"""
Partial update: accepts a JSON object with fields to update (e.g., {"instructions":"..."}).
"""
try:
pb, key = _find_playbook_by_id(str(id))
if not pb:
raise HTTPException(status_code=404, detail="Not Found")
# Normalize existing to dict
if isinstance(pb, dict):
existing = dict(pb)
else:
existing = {k: getattr(pb, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(pb, k)}
merged = {**existing, **payload}
# Try to persist via store API
updated = _persist_playbook(merged)
if not isinstance(updated, dict):
return {k: getattr(updated, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(updated, k)}
return updated
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/playbooks/{id}")
async def put_playbook(id: UUID, payload: Dict[str, Any] = Body(...)):
"""
Full replace: replace the playbook with the provided payload (payload should include title, goal, instructions, tags).
"""
try:
pb, key = _find_playbook_by_id(str(id))
if not pb:
raise HTTPException(status_code=404, detail="Not Found")
payload["id"] = str(id)
replaced = _persist_playbook(payload)
if not isinstance(replaced, dict):
return {k: getattr(replaced, k) for k in ("id", "title", "goal", "instructions", "tags") if hasattr(replaced, k)}
return replaced
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/playbooks/{id}")
async def delete_playbook_endpoint(id: UUID):
try:
pb, _ = _find_playbook_by_id(str(id))
if not pb:
raise HTTPException(status_code=404, detail="Not Found")
playbook_store.delete_playbook(str(id))
return {"status": "deleted"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# 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
# -------------------------
@app.get("/conversations")
async def get_conversations(q: Optional[str] = None):
try:
conversations = store.all_conversations()
if q:
q_lower = q.lower()
conversations = [
c for c in conversations
if any(q_lower in m.content.lower() for m in c.messages)
or q_lower in c.preview.lower()
]
return {
"conversations": [
{
"id": c.id,
"timestamp": c.created_at,
"updated_at": c.updated_at,
"preview": c.preview,
}
for c in conversations
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str):
try:
conv = store.get_conversation(conversation_id)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
return {
"id": conv.id,
"timestamp": conv.created_at,
"messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp, "model": m.model, "tokens": m.tokens}
for m in conv.messages
]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/conversations/{conversation_id}")
async def delete_conversation(conversation_id: str):
try:
conv = store.get_conversation(conversation_id)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
store.delete_conversation(conversation_id)
return {"status": "deleted"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# End of file
# -------------------------
+94
View File
@@ -0,0 +1,94 @@
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
decide if it contains a new permanent personal fact worth saving."""
from __future__ import annotations
import json
import logging
import re
from typing import Optional
_log = logging.getLogger(__name__)
_PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus.
A conversation just occurred. Decide if the USER revealed a NEW, PERMANENT \
personal fact that should be saved to long-term memory.
SAVE ONLY facts that are stable and biographical:
- Identity: full name, age, location, nationality
- Possessions: vehicle, home, devices
- Relationships: family members, partner, close friends
- Career: job title, employer, field, skills
- Long-running projects or goals (not today's to-dos)
- Durable preferences or habits explicitly stated
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"
- Mood or energy: "I'm tired", "feeling good", "having a rough day"
- Greetings or small talk: "good morning", "how are you"
- Questions the user asked the assistant
- Near-duplicates of anything in the existing memory list below
Existing memory (do not re-save these or close variants):
{existing_texts}
---
USER: {user_message}
ASSISTANT: {assistant_response}
---
Respond with JSON only — no prose, no markdown fences:
{{"save": true, "section": "<section name, or one from the list above>", "text": "<concise fact about Jon, third person>"}}
OR
{{"save": false}}"""
async def extract_memory(
user_message: str,
assistant_response: str,
existing_sections: list[str],
existing_texts: list[str],
ollama_manager,
) -> Optional[dict]:
"""Ask Mistral to extract a saveable memory fact from a conversation exchange.
Returns {"section": ..., "text": ...} or None.
"""
if existing_texts:
texts_block = "\n".join(f"- {t}" for t in existing_texts[:40])
else:
texts_block = "(none yet)"
prompt = _PROMPT.format(
existing_texts=texts_block,
user_message=user_message[:800],
assistant_response=assistant_response[:800],
)
try:
response = await ollama_manager.chat(
messages=[{"role": "user", "content": prompt}],
model="mistral:latest",
stream=False,
temperature=0.0,
)
if not response:
return None
text = response.strip()
# Strip markdown code fences if the model added them
if "```" in text:
m = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
if m:
text = m.group(1).strip()
data = json.loads(text)
if data.get("save") and data.get("section") and data.get("text"):
return {
"section": str(data["section"]).strip(),
"text": str(data["text"]).strip(),
}
except Exception as e:
_log.debug("memory extraction failed: %s", e)
return None
+125
View File
@@ -0,0 +1,125 @@
"""Dedicated memory curator service — run alongside Synapse on port 8001.
Endpoints:
GET / health check
GET /memories list all memory items (optional ?section= filter)
POST /memories direct write — no LLM, saves immediately
PATCH /memories/{id} update a memory item
DELETE /memories/{id} delete a memory item
POST /memories/extract LLM-curated: evaluate a conversation exchange and
optionally save a new permanent fact
"""
from __future__ import annotations
import asyncio
import uuid
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
app = FastAPI(title="Nexus Memory Service", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def health():
return {"status": "ok", "count": len(store.all())}
@app.get("/memories")
async def list_memories(section: Optional[str] = None):
items = store.all()
if section:
items = [i for i in items if i.section.lower() == section.lower()]
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
@app.post("/memories")
async def add_memory(payload: Dict[str, Any] = Body(...)):
text = (payload.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Missing 'text'")
item = MemoryItem(
id=str(uuid.uuid4()),
section=(payload.get("section") or "General").strip(),
text=text,
tags=payload.get("tags", []),
)
store.add(item)
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
@app.patch("/memories/{item_id}")
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
existing = store.get(item_id)
if not existing:
raise HTTPException(status_code=404, detail="Not found")
updated = MemoryItem(
id=item_id,
section=(payload.get("section") or existing.section).strip(),
text=(payload.get("text") or existing.text).strip(),
tags=payload.get("tags", existing.tags),
)
store.update(updated)
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
@app.delete("/memories/{item_id}")
async def delete_memory(item_id: str):
if not store.get(item_id):
raise HTTPException(status_code=404, detail="Not found")
store.delete(item_id)
return {"status": "deleted"}
class ExtractRequest(BaseModel):
user_message: str
assistant_response: str
@app.post("/memories/extract")
async def extract_and_save(req: ExtractRequest):
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
existing memory items and save only new, permanent personal facts."""
existing = store.all()
existing_sections = list({i.section for i in existing})
existing_texts = [i.text for i in existing]
try:
result = await asyncio.wait_for(
extract_memory(
req.user_message,
req.assistant_response,
existing_sections,
existing_texts,
get_ollama_manager(),
),
timeout=20.0,
)
if result:
item = MemoryItem(
id=str(uuid.uuid4()),
section=result["section"],
text=result["text"],
)
store.add(item)
return {"saved": True, "id": item.id, "section": item.section, "text": item.text}
except asyncio.TimeoutError:
pass
except Exception:
pass
return {"saved": False}
+380
View File
@@ -0,0 +1,380 @@
from pathlib import Path
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
import sqlite3
import json
import os
import time
# -----------------------------
# Models
# -----------------------------
class MemoryItem(BaseModel):
id: str
section: str = "General"
text: str
tags: List[str] = []
class MessageItem(BaseModel):
role: str # "user" or "assistant"
content: str
timestamp: float
model: Optional[str] = None
tokens: Optional[int] = None
class ConversationItem(BaseModel):
id: str
messages: List[MessageItem] = []
created_at: float
updated_at: float
@property
def preview(self) -> str:
for msg in self.messages:
if msg.role == "user":
return msg.content[:80]
return "Empty conversation"
@property
def timestamp(self) -> float:
return self.created_at
# -----------------------------
# Persistent Store
# -----------------------------
class PersistentMemoryStore:
def __init__(self, db_path: Path):
self.db_path = db_path
os.makedirs(self.db_path.parent, exist_ok=True)
self._ensure_tables()
self._cache: Dict[str, MemoryItem] = self._load_all_memory()
# -----------------------------
# Internal helpers
# -----------------------------
def _connect(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
return conn
def _ensure_tables(self):
conn = self._connect()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS memory (
id TEXT PRIMARY KEY,
section TEXT NOT NULL DEFAULT 'General',
text TEXT NOT NULL,
tags TEXT
)
""")
# Migrate: add section column if it doesn't exist yet
try:
cur.execute("ALTER TABLE memory ADD COLUMN section TEXT NOT NULL DEFAULT 'General'")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL,
model TEXT,
tokens INTEGER,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
""")
for col, typedef in (("model", "TEXT"), ("tokens", "INTEGER")):
try:
cur.execute(f"ALTER TABLE messages ADD COLUMN {col} {typedef}")
except Exception:
pass
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
ON messages (conversation_id)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.commit()
conn.close()
# -----------------------------
# Loaders
# -----------------------------
def _load_all_memory(self) -> Dict[str, MemoryItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT id, section, text, tags FROM memory")
rows = cur.fetchall()
conn.close()
cache = {}
for row in rows:
try:
tags = json.loads(row["tags"]) if row["tags"] else []
except Exception:
tags = []
cache[row["id"]] = MemoryItem(
id=row["id"],
section=row["section"] or "General",
text=row["text"],
tags=tags,
)
return cache
# -----------------------------
# Memory API
# -----------------------------
def add(self, item: MemoryItem):
self._cache[item.id] = item
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"INSERT OR REPLACE INTO memory (id, section, text, tags) VALUES (?, ?, ?, ?)",
(item.id, item.section or "General", item.text, json.dumps(item.tags))
)
conn.commit()
finally:
conn.close()
def update(self, item: MemoryItem):
self.add(item)
def delete(self, item_id: str):
self._cache.pop(item_id, None)
conn = self._connect()
try:
cur = conn.cursor()
cur.execute("DELETE FROM memory WHERE id = ?", (item_id,))
conn.commit()
finally:
conn.close()
def get(self, item_id: str) -> Optional[MemoryItem]:
return self._cache.get(item_id)
def all(self) -> List[MemoryItem]:
return list(self._cache.values())
# -----------------------------
# Conversation API
# -----------------------------
def create_conversation(self, conversation_id: str) -> ConversationItem:
now = time.time()
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)",
(conversation_id, now, now)
)
conn.commit()
finally:
conn.close()
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
def add_message(self, conversation_id: str, role: str, content: str, model: str = None, tokens: int = None):
now = time.time()
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO messages (conversation_id, role, content, timestamp, model, tokens) VALUES (?, ?, ?, ?, ?, ?)",
(conversation_id, role, content, now, model, tokens)
)
cur.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id)
)
conn.commit()
finally:
conn.close()
def get_conversation(self, conversation_id: str) -> Optional[ConversationItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT * FROM conversations WHERE id = ?", (conversation_id,))
row = cur.fetchone()
if not row:
conn.close()
return None
cur.execute(
"SELECT role, content, timestamp, model, tokens FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC",
(conversation_id,)
)
msg_rows = cur.fetchall()
conn.close()
messages = [MessageItem(role=r["role"], content=r["content"], timestamp=r["timestamp"], model=r["model"], tokens=r["tokens"]) for r in msg_rows]
return ConversationItem(
id=row["id"],
messages=messages,
created_at=row["created_at"],
updated_at=row["updated_at"]
)
def all_conversations(self) -> List[ConversationItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT c.id, c.created_at, c.updated_at,
m.role, m.content, m.timestamp, m.model, m.tokens
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
ORDER BY c.updated_at DESC, m.timestamp ASC
""")
rows = cur.fetchall()
conn.close()
convs: Dict[str, ConversationItem] = {}
order: list[str] = []
for row in rows:
cid = row["id"]
if cid not in convs:
convs[cid] = ConversationItem(
id=cid,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
order.append(cid)
if row["role"] is not None:
convs[cid].messages.append(
MessageItem(role=row["role"], content=row["content"], timestamp=row["timestamp"], model=row["model"], tokens=row["tokens"])
)
return [convs[cid] for cid in order]
def delete_conversation(self, conversation_id: str):
conn = self._connect()
try:
cur = conn.cursor()
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
conn.commit()
finally:
conn.close()
# -----------------------------
# Search API
# -----------------------------
def search_conversations(self, query: str, limit: int = 3) -> List[dict]:
"""Return up to `limit` conversations that contain the query string,
with full user+assistant exchange pairs around each match."""
if not query or not query.strip():
return []
q = query.strip().lower()
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT c.id, c.created_at, c.updated_at
FROM conversations c
JOIN messages m ON m.conversation_id = c.id
WHERE LOWER(m.content) LIKE ?
ORDER BY c.updated_at DESC
LIMIT ?
""", (f"%{q}%", limit))
rows = cur.fetchall()
results = []
for row in rows:
# Load all messages in order so we can find complete exchange pairs
cur.execute("""
SELECT role, content FROM messages
WHERE conversation_id = ?
ORDER BY timestamp ASC
""", (row["id"],))
all_msgs = [{"role": r["role"], "content": r["content"]} for r in cur.fetchall()]
# For each matching message, collect the full user+assistant pair around it
seen_pairs: set = set()
matches = []
for i, msg in enumerate(all_msgs):
if q not in msg["content"].lower():
continue
if msg["role"] == "user":
start, end = i, i + 1 if i + 1 < len(all_msgs) else i
else:
start, end = (i - 1 if i > 0 else i), i
if (start, end) in seen_pairs:
continue
seen_pairs.add((start, end))
for m in all_msgs[start:end + 1]:
matches.append({"role": m["role"], "content": m["content"][:500]})
if len(seen_pairs) >= 2:
break
if matches:
results.append({
"id": row["id"],
"updated_at": row["updated_at"],
"matches": matches,
})
conn.close()
return results
# -----------------------------
# Settings API
# -----------------------------
_SETTINGS_DEFAULTS: Dict[str, Any] = {
"model": "",
"temperature": 0.7,
"system_prompt": "",
"timeout": 120,
}
def get_settings(self) -> Dict[str, Any]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT key, value FROM settings")
rows = cur.fetchall()
conn.close()
result = dict(self._SETTINGS_DEFAULTS)
for row in rows:
try:
result[row["key"]] = json.loads(row["value"])
except Exception:
result[row["key"]] = row["value"]
return result
def update_settings(self, data: Dict[str, Any]):
conn = self._connect()
try:
cur = conn.cursor()
for key, value in data.items():
if key in self._SETTINGS_DEFAULTS:
cur.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, json.dumps(value))
)
conn.commit()
finally:
conn.close()
# -----------------------------
# Store Instance
# -----------------------------
from ..nexus_config import MEMORY_DB
DB_PATH = MEMORY_DB
store = PersistentMemoryStore(DB_PATH)
+140
View File
@@ -0,0 +1,140 @@
# config.py
from __future__ import annotations
import os
from pathlib import Path
from typing import Dict, Any
# --- ENV ---
try:
from dotenv import load_dotenv # optional
load_dotenv()
except Exception:
pass
# --- PROJECT ROOT ---
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models"
RUNTIME_DIR = PROJECT_ROOT / "runtime"
MEMORY_DIR = PROJECT_ROOT / "synapse" / "memory"
LOGS_DIR = RUNTIME_DIR / "logs"
CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp"
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
PLAYBOOK_DIR = PROJECT_ROOT / "synapse" / "playbooks"
UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports"
# --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = MEMORY_DIR / "memory.db"
PLAYBOOK_DB = MEMORY_DB # same file in your setup
# --- LOG FILES ---
BACKEND_LOG = LOGS_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
for d in (
DATA_DIR,
MODELS_DIR,
RUNTIME_DIR,
LOGS_DIR,
MEMORY_DIR,
CACHE_DIR,
TEMP_DIR,
PLAYBOOK_DIR,
UPLOADS_DIR,
EXPORTS_DIR,
):
d.mkdir(parents=True, exist_ok=True)
# --- PATH ACCESSOR (fail-fast) ---
def path(name: str) -> Path:
"""
Return a Path for a known name. Raises KeyError if name is unknown.
"""
mapping = {
"root": PROJECT_ROOT,
"data": DATA_DIR,
"models": MODELS_DIR,
"runtime": RUNTIME_DIR,
"logs": LOGS_DIR,
"memory": MEMORY_DIR,
"cache": CACHE_DIR,
"tmp": TEMP_DIR,
"playbooks": PLAYBOOK_DIR,
"uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR,
"memory_db": MEMORY_DB,
"playbook_db": PLAYBOOK_DB,
"backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG,
"chat_log": CHAT_LOG,
}
try:
return mapping[name]
except KeyError:
raise KeyError(f"Unknown config path name: {name}")
# --- Settings class and exported instance ---
class Settings:
"""
Lightweight settings container. Use `settings` instance for runtime access,
or `Settings` class for typing/tests.
"""
def __init__(self) -> None:
self.project_root: Path = PROJECT_ROOT
self.data_dir: Path = DATA_DIR
self.models_dir: Path = MODELS_DIR
self.runtime_dir: Path = RUNTIME_DIR
self.memory_dir: Path = MEMORY_DIR
self.logs_dir: Path = LOGS_DIR
# DB files
self.memory_db: Path = MEMORY_DB
self.playbook_db: Path = PLAYBOOK_DB
# Logs
self.backend_log: Path = BACKEND_LOG
self.ollama_log: Path = OLLAMA_LOG
self.chat_log: Path = CHAT_LOG
# Env overrides
self.ollama_host: str = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
self.ollama_timeout: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
def as_dict(self) -> Dict[str, Any]:
return {
"project_root": str(self.project_root),
"data_dir": str(self.data_dir),
"models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db),
"playbook_db": str(self.playbook_db),
"ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout,
}
# exported instance
settings = Settings()
# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB", "PLAYBOOK_DB",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
# --- quick runtime sanity check when run directly (no side effects on import) ---
if __name__ == "__main__":
print("Config paths:")
for key in ("root", "data", "models", "runtime", "memory", "memory_db"):
print(f" {key}: {path(key)}")
+536
View File
@@ -0,0 +1,536 @@
import asyncio
import json
import logging
import re
import subprocess
import time
import httpx
import os
import signal
from pathlib import Path
from .nexus_config import settings
OLLAMA_PORT = 11434
_log = logging.getLogger("nexus.ollama")
# --- Global Singleton Instance ---
_ollama_manager = None
# Bundled binary ships alongside the project; fall back to system PATH
_BUNDLED_OLLAMA = Path(__file__).resolve().parent.parent / "ollama" / "bin" / "ollama"
def _ollama_bin() -> str:
"""Return path to the Ollama executable, preferring the bundled copy."""
if _BUNDLED_OLLAMA.exists():
return str(_BUNDLED_OLLAMA)
return "ollama"
def _best_vulkan_device() -> tuple[int, str]:
"""
Parse `vulkaninfo --summary` and return (device_index, device_name) for the
best Vulkan compute device. Prefers discrete GPUs over integrated ones, and
AMD/NVIDIA vendor IDs over Intel — so a Radeon is chosen over an Intel iGPU
even when the iGPU appears first in the device list.
"""
try:
r = subprocess.run(
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5,
)
if r.returncode != 0:
return 0, "GPU"
devices: list[dict] = []
current: dict = {}
for line in r.stdout.splitlines():
line = line.strip()
if line.startswith("GPU") and line.endswith(":"):
if current:
devices.append(current)
raw_idx = line[3:-1]
current = {
"index": int(raw_idx) if raw_idx.isdigit() else len(devices),
"name": "GPU",
"type": "",
"vendor": "",
}
elif "=" in line:
key, _, val = line.partition("=")
key, val = key.strip(), val.strip()
if key == "deviceName":
current["name"] = val
elif key == "deviceType":
current["type"] = val.upper()
elif key == "vendorID":
current["vendor"] = val.lower()
if current:
devices.append(current)
if not devices:
return 0, "GPU"
def _score(d: dict) -> tuple:
# Discrete beats everything; integrated is last resort
type_score = 2 if "DISCRETE" in d["type"] else (0 if "INTEGRATED" in d["type"] else 1)
# AMD (0x1002) and NVIDIA (0x10de) preferred over Intel (0x8086)
vendor_score = 1 if any(v in d["vendor"] for v in ("0x1002", "0x10de")) else 0
return (type_score, vendor_score)
best = max(devices, key=_score)
return best["index"], best["name"]
except Exception:
return 0, "GPU"
def _detect_gpu_backend() -> tuple[str, dict]:
"""
Probe available GPU compute backends.
Returns (label, env_overrides) where env_overrides is merged into the
Ollama subprocess environment before launch.
Priority: CUDA > Vulkan > ROCm > CPU.
"""
# NVIDIA CUDA — preferred when both GPU and CUDA drivers are present
try:
r = subprocess.run(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
name = r.stdout.strip().splitlines()[0]
_log.info("GPU backend: CUDA (%s)", name)
return f"cuda ({name})", {} # Ollama auto-detects CUDA
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Vulkan — works on AMD, Intel, and NVIDIA without a full CUDA/ROCm stack
vulkan_ok = False
try:
r = subprocess.run(
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5,
)
vulkan_ok = r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if not vulkan_ok:
# Fall back to checking for ICD loader files without the vulkaninfo tool
icd_dirs = [
Path("/usr/share/vulkan/icd.d"),
Path("/etc/vulkan/icd.d"),
Path(os.path.expanduser("~/.local/share/vulkan/icd.d")),
]
try:
vulkan_ok = any(p.is_dir() and any(p.iterdir()) for p in icd_dirs)
except PermissionError:
pass
if vulkan_ok:
idx, name = _best_vulkan_device()
env_overrides: dict = {"OLLAMA_GPU": "vulkan"}
if idx > 0:
# Explicitly route Ollama to the discrete GPU when it isn't device 0
env_overrides["GGML_VK_VISIBLE_DEVICES"] = str(idx)
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides
# AMD ROCm — fallback when Vulkan ICD is absent but ROCm stack is installed
try:
r = subprocess.run(
["rocm-smi", "--showproductname"], capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
_log.info("GPU backend: ROCm")
return "rocm", {} # Ollama auto-detects ROCm
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
_log.info("GPU backend: CPU (no GPU acceleration detected)")
return "cpu", {}
def _model_score(name: str) -> tuple:
"""Score a model name for auto-selection. Higher tuple = better."""
lower = name.lower()
match = re.search(r'(\d+(?:\.\d+)?)[bB]', lower)
params = float(match.group(1)) if match else 7.0
# Tier-break: known quality models get a small bonus
quality = next(
(i for i, prefix in enumerate(("llama3", "llama2", "mistral", "gemma", "phi", "qwen"), 1)
if prefix in lower),
0,
)
return (params, quality)
class OllamaManager:
def __init__(self, runtime_dir=None):
self.process = None
self.running = False
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
self.log_file = self.runtime_dir / "logs" / "ollama.log"
# Resolved at construction so host changes in settings take effect
self._api_base = settings.ollama_host.rstrip("/")
# Model selection cache
self._model_cache: str | None = None
self._model_cache_ts: float = 0.0
def is_available(self):
bin_path = _ollama_bin()
try:
subprocess.run([bin_path, "--version"], capture_output=True, check=True, timeout=5)
return True
except Exception:
return False
def is_running(self):
try:
r = httpx.get(f"{self._api_base}/api/tags", timeout=2.0)
return r.status_code == 200
except httpx.RequestError:
return False
def start(self):
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
if self.is_running():
_log.info("Ollama already running (%s)", self._api_base)
self.running = True
return True
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
[_ollama_bin(), "serve"],
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
env=env,
)
for attempt in range(30):
if self.is_running():
_log.info("Ollama service started (%s)", self._api_base)
self.running = True
return True
time.sleep(1)
if attempt % 5 == 0:
_log.info("Waiting for Ollama... (%ds)", attempt)
_log.error("Ollama failed to start: timeout")
return False
except Exception as e:
_log.exception("Ollama failed to start: %s", e)
return False
async def start_async(self):
"""Async-safe version of start() for use inside async startup handlers."""
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
if self.is_running():
_log.info("Ollama already running (%s)", self._api_base)
self.running = True
return True
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
[_ollama_bin(), "serve"],
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
env=env,
)
for attempt in range(30):
if self.is_running():
_log.info("Ollama service started (%s)", self._api_base)
self.running = True
return True
await asyncio.sleep(1)
if attempt % 5 == 0:
_log.info("Waiting for Ollama... (%ds)", attempt)
_log.error("Ollama failed to start: timeout")
return False
except Exception as e:
_log.exception("Ollama failed to start: %s", e)
return False
def stop(self):
if self.process and self.running:
try:
_log.info("Stopping Ollama service...")
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait(timeout=10)
_log.info("Ollama service stopped")
except Exception:
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except Exception:
pass
finally:
self.running = False
def get_status(self):
if self.is_running():
return "running"
elif self.is_available():
return "available"
else:
return "unavailable"
async def generate(
self,
prompt: str,
model: str = "mistral",
stream: bool = False,
system: str = "",
**kwargs
):
start = time.perf_counter()
_log.debug("generate model=%s system=%r prompt=%.120s", model, system, prompt)
try:
if stream:
return self._stream(prompt=prompt, model=model, system=system, start=start)
else:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{self._api_base}/api/generate",
json={
"model": model,
"prompt": prompt,
"system": system,
"stream": False,
},
)
elapsed = time.perf_counter() - start
_log.info("generate completed model=%s status=%d elapsed=%.3fs", model, r.status_code, elapsed)
r.raise_for_status()
return r.json().get("response", "")
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("generate error after %.3fs: %s", elapsed, e)
return None
async def _stream(self, prompt: str, model: str, system: str, start: float):
"""
Async generator that streams token chunks from Ollama.
Yields string chunks as they arrive.
"""
try:
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"POST",
f"{self._api_base}/api/generate",
json={
"model": model,
"prompt": prompt,
"system": system,
"stream": True,
},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
token = data.get("response", "")
if token:
yield token
if data.get("done", False):
elapsed = time.perf_counter() - start
_log.info("generate stream completed model=%s elapsed=%.3fs", model, elapsed)
break
except Exception:
continue
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("generate stream error after %.3fs: %s", elapsed, e)
return
async def chat(
self,
messages: list,
model: str = "mistral",
stream: bool = False,
temperature: float | None = None,
**kwargs,
):
"""Multi-turn chat via /api/chat (accepts a messages array with roles)."""
start = time.perf_counter()
try:
if stream:
return self._chat_stream(messages=messages, model=model, temperature=temperature, start=start)
else:
body: dict = {"model": model, "messages": messages, "stream": False}
if temperature is not None:
body["options"] = {"temperature": temperature}
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start
r.raise_for_status()
return r.json().get("message", {}).get("content", "")
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat error after %.3fs: %s", elapsed, e)
return None
async def list_models(self) -> list[str]:
"""Return names of all locally installed Ollama models."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{self._api_base}/api/tags")
r.raise_for_status()
return [m["name"] for m in r.json().get("models", [])]
except Exception:
return []
async def select_best_model(self) -> str:
"""Pick the highest-scoring available model, falling back to 'mistral'.
Result is cached for 60 seconds so rapid chat requests don't each
hit the Ollama API to build the model list.
"""
now = time.monotonic()
if self._model_cache and (now - self._model_cache_ts) < 60:
return self._model_cache
models = await self.list_models()
best = max(models, key=_model_score) if models else "mistral"
self._model_cache = best
self._model_cache_ts = now
return best
def invalidate_model_cache(self):
"""Force next select_best_model() to re-query (e.g. after pull/delete)."""
self._model_cache = None
self._model_cache_ts = 0.0
async def _chat_stream(self, messages: list, model: str, start: float, temperature: float | None = None):
"""Async generator streaming tokens, then a final __meta__ stats sentinel."""
try:
body: dict = {"model": model, "messages": messages, "stream": True}
if temperature is not None:
body["options"] = {"temperature": temperature}
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"POST",
f"{self._api_base}/api/chat",
json=body,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
token = data.get("message", {}).get("content", "")
if token:
yield token
if data.get("done", False):
elapsed = time.perf_counter() - start
_log.info("chat stream completed model=%s elapsed=%.3fs", model, elapsed)
eval_count = data.get("eval_count", 0)
eval_ns = data.get("eval_duration", 0)
tokens_per_s = round(eval_count / (eval_ns / 1e9), 1) if eval_ns else 0
stats = json.dumps({
"model": model,
"tokens": eval_count,
"elapsed_s": round(elapsed, 2),
"tokens_per_s": tokens_per_s,
})
yield f"__meta__{stats}"
break
except Exception:
continue
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat stream error after %.3fs: %s", elapsed, e)
return
def initialize_ollama() -> OllamaManager:
global _ollama_manager
if _ollama_manager is None:
manager = OllamaManager()
if not manager.is_running():
manager.start()
if not manager.is_running():
raise RuntimeError("Ollama API is not reachable after start().")
_ollama_manager = manager
return _ollama_manager
async def initialize_ollama_async() -> OllamaManager:
"""Async-safe initializer — uses asyncio.sleep so the event loop stays live."""
global _ollama_manager
if _ollama_manager is None:
manager = OllamaManager()
if not manager.is_running():
await manager.start_async()
if not manager.is_running():
raise RuntimeError("Ollama API is not reachable after start().")
_ollama_manager = manager
return _ollama_manager
def get_ollama_manager() -> OllamaManager:
global _ollama_manager
if _ollama_manager is None:
_ollama_manager = OllamaManager()
return _ollama_manager
def shutdown_ollama() -> None:
global _ollama_manager
if _ollama_manager is not None:
_ollama_manager.stop()
_ollama_manager = None
+42
View File
@@ -0,0 +1,42 @@
import threading
from typing import List
from .playbooks.store import playbook_store, PlaybookItem
class PlaybookManager:
_lock = threading.Lock()
@classmethod
def _all(cls) -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main)."""
return playbook_store.all_playbooks()
@classmethod
def get_main_playbook(cls) -> PlaybookItem | None:
playbooks = cls._all()
return playbooks[0] if playbooks else None
@classmethod
def get_context_playbooks(cls) -> List[PlaybookItem]:
"""All playbooks after the first — injected as reference context."""
playbooks = cls._all()
return playbooks[1:] if len(playbooks) > 1 else []
@classmethod
def get_system_prompt(cls) -> str:
playbook = cls.get_main_playbook()
return getattr(playbook, "instructions", "") or "" if playbook else ""
@classmethod
def render_prompt(cls, user_message: str, variables: dict | None = None) -> str:
if not variables:
return user_message
result = user_message
for key, value in variables.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
# Keep old name as alias so nothing else breaks
@classmethod
def get_active_playbook(cls) -> PlaybookItem | None:
return cls.get_main_playbook()
@@ -0,0 +1,21 @@
id: 0858861d-6c42-48b9-be9f-d7e86cc45586
title: main
goal: You are Nexus, a powerful AI assistant created by Jon to help in his daily life. You will not only function as an assistant, but as a friend.
tags: []
order: 0
instructions: |-
Your personality:
- Warm, casual, and conversational — you know Jon well and treat him as a friend, not a user
- Confident and direct — give real answers, not hedged corporate-speak
- Occasionally witty, but never at the expense of being helpful
Your responsibilities:
- Help Jon with tasks, questions, planning, research, writing, and problem solving
- Remember context within a conversation and refer back to it naturally
- Proactively offer suggestions or flag things Jon might have missed
Rules:
- Never refer to yourself as an AI or language model
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
- Keep responses concise unless Jon asks for detail
- If you don't know something, say so plainly and help find the answer
View File
+107
View File
@@ -0,0 +1,107 @@
from pathlib import Path
from typing import List, Optional
import yaml
from pydantic import BaseModel
class _BlockDumper(yaml.Dumper):
pass
_BlockDumper.add_representer(
str,
lambda dumper, data: dumper.represent_scalar(
"tag:yaml.org,2002:str", data, style="|" if "\n" in data else None
),
)
class PlaybookItem(BaseModel):
id: str
title: str
goal: str
instructions: str
tags: List[str] = []
order: int = 0
class PlaybookFileStore:
def __init__(self, directory: Path):
self.directory = directory
directory.mkdir(parents=True, exist_ok=True)
def _path(self, id: str) -> Path:
return self.directory / f"{id}.yaml"
def _read(self, path: Path) -> Optional[PlaybookItem]:
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return PlaybookItem(
id=data["id"],
title=data["title"],
goal=data.get("goal", ""),
instructions=data.get("instructions", ""),
tags=data.get("tags", []),
order=data.get("order", 0),
)
except Exception:
return None
@staticmethod
def _clean(s: str) -> str:
return "\n".join(line.rstrip() for line in s.split("\n")).strip()
def _write(self, item: PlaybookItem):
data = {
"id": item.id,
"title": item.title,
"goal": item.goal,
"tags": item.tags,
"order": item.order,
"instructions": self._clean(item.instructions),
}
with open(self._path(item.id), "w", encoding="utf-8") as f:
yaml.dump(data, f, Dumper=_BlockDumper, allow_unicode=True,
default_flow_style=False, sort_keys=False, width=4096)
def all_playbooks(self) -> List[PlaybookItem]:
items = [self._read(p) for p in self.directory.glob("*.yaml")]
return sorted((i for i in items if i), key=lambda x: x.order)
def get_playbook(self, id: str) -> Optional[PlaybookItem]:
return self._read(self._path(id))
def add_playbook(self, item: PlaybookItem):
self._write(item)
def delete_playbook(self, id: str):
p = self._path(id)
if p.exists():
p.unlink()
def reorder_playbooks(self, ordered_ids: List[str]):
all_ids = {p.stem for p in self.directory.glob("*.yaml")}
valid = [pid for pid in ordered_ids if pid in all_ids]
missing = sorted(all_ids - set(valid))
for index, pid in enumerate(valid + missing):
item = self.get_playbook(pid)
if item:
item.order = index
self._write(item)
def search_playbooks(self, query: str) -> List[PlaybookItem]:
if not query or not query.strip():
return []
q = query.strip().lower()
return [
p for p in self.all_playbooks()
if q in p.title.lower()
or q in p.goal.lower()
or q in p.instructions.lower()
or any(q in t.lower() for t in p.tags)
]
from ..nexus_config import PLAYBOOK_DIR
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)