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:
@@ -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)
|
||||
Reference in New Issue
Block a user