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:
+242
@@ -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
@@ -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
|
||||
# -------------------------
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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)}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user