feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""Curated memory extraction — read a finished conversation, save what's new.
|
||||
|
||||
This is the whole memory-writing path: pick up the messages the curator has not
|
||||
read yet, ask the model which permanent facts they contain, merge each result
|
||||
against what is already stored, and move the conversation's watermark.
|
||||
|
||||
It runs in-process. Extraction used to sit behind an HTTP call to a separate
|
||||
service on :8001 holding a second, smaller model, because that model could not
|
||||
share the GPU with the chat model. The curator is the chat model now — already
|
||||
resident, already warm — so the extra process bought nothing but a hop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
|
||||
from .store import store, MemoryItem
|
||||
from .extractor import extract_memory
|
||||
from ..ollama_manager import get_ollama_manager
|
||||
|
||||
_EXTRACT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _cosine(a: list, b: list) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
async def extract_for_conversation(conversation_id: str, project_id: str = "") -> list[dict]:
|
||||
"""Extract and save facts from a conversation's unread messages.
|
||||
|
||||
Returns the saved/updated items. Safe to call more than once: the watermark
|
||||
means a second run with no new messages does nothing and never reaches the
|
||||
model. Never raises — memory extraction must not take the caller down.
|
||||
"""
|
||||
messages, last_id = store.pending_extraction(conversation_id)
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
# Only global facts and this project's are in scope: another project's facts
|
||||
# must not be shown to the curator as "already known", or as a merge target.
|
||||
existing = [m for m in store.all() if m.project_id in ("", project_id)]
|
||||
existing_sections = list({i.section for i in existing})
|
||||
existing_texts = [i.text for i in existing]
|
||||
|
||||
settings = store.get_settings()
|
||||
mgr = get_ollama_manager()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", -1), model)
|
||||
try:
|
||||
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||
except (TypeError, ValueError):
|
||||
merge_threshold = 0.88
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
extract_memory(
|
||||
messages, existing_sections, existing_texts, mgr,
|
||||
model=model, num_gpu=num_gpu,
|
||||
),
|
||||
timeout=_EXTRACT_TIMEOUT,
|
||||
)
|
||||
except Exception:
|
||||
# Leave the watermark alone so the next sweep retries these messages.
|
||||
return []
|
||||
|
||||
saved = []
|
||||
try:
|
||||
# Embed existing facts once so each new fact can be matched against them.
|
||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||
# rather than appending a copy. Best effort: if embeddings are down we
|
||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||
existing_embeds: dict = {}
|
||||
if results and 0 < merge_threshold < 1:
|
||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||
|
||||
for result in results:
|
||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||
match_id, best = None, 0.0
|
||||
if new_vec:
|
||||
for eid, ev in existing_embeds.items():
|
||||
sim = _cosine(new_vec, ev)
|
||||
if sim > best:
|
||||
best, match_id = sim, eid
|
||||
if best < merge_threshold:
|
||||
match_id = None
|
||||
|
||||
target = store.get(match_id) if match_id else None
|
||||
if target:
|
||||
# Near-duplicate of an existing fact — overwrite with the newer
|
||||
# statement, keeping the original id/section/position.
|
||||
updated = MemoryItem(id=target.id, section=target.section,
|
||||
text=result["text"], tags=target.tags,
|
||||
project_id=target.project_id)
|
||||
store.update(updated)
|
||||
if new_vec:
|
||||
existing_embeds[updated.id] = new_vec
|
||||
saved.append({"id": updated.id, "section": updated.section,
|
||||
"text": updated.text, "updated": True})
|
||||
else:
|
||||
item = MemoryItem(id=str(uuid.uuid4()), section=result["section"],
|
||||
text=result["text"], project_id=project_id)
|
||||
store.add(item)
|
||||
if new_vec:
|
||||
existing_embeds[item.id] = new_vec
|
||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Watermark even when nothing was saved — the model has read these messages
|
||||
# and re-reading them would just spend another call to reach the same "no".
|
||||
store.set_extracted_through(conversation_id, last_id)
|
||||
return saved
|
||||
+55
-20
@@ -1,5 +1,12 @@
|
||||
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
|
||||
decide if it contains a new permanent personal fact worth saving."""
|
||||
"""Memory extraction — asks the curator model to read a finished conversation
|
||||
and decide which new permanent personal facts it contains.
|
||||
|
||||
Reads the whole transcript, not a single exchange. A fact is rarely complete in
|
||||
the turn that introduces it ("I have a Honda" at turn 3 becomes a 2006 Accord
|
||||
with 312k miles by turn 7), and the store is append-only for the curator, so
|
||||
extracting per-exchange could only ever accumulate fragments of the same fact.
|
||||
Waiting until the conversation is idle is also the only reliable signal that a
|
||||
statement is finished rather than half-said."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,13 +24,19 @@ _TR = "┅" * 55
|
||||
_PROMPT = """\
|
||||
You are a memory curator for a personal AI assistant named Nexus.
|
||||
|
||||
Extract EVERY new, permanent personal fact the USER stated in this exchange.
|
||||
There may be SEVERAL facts in one message — output one JSON object for each.
|
||||
Extract EVERY new, permanent personal fact the USER stated in this conversation.
|
||||
There may be SEVERAL facts across the conversation — output one JSON object for
|
||||
each.
|
||||
|
||||
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
|
||||
memory below are context to help you understand the USER line — never extract
|
||||
ONLY the USER lines are a source of facts. The ASSISTANT lines and the existing
|
||||
memory below are context to help you understand the USER lines — never extract
|
||||
anything from them. If the assistant said it and the user did not, it is NOT a
|
||||
fact. Copy what the user actually said; do not infer, embellish, or add a
|
||||
fact.
|
||||
|
||||
You are reading the WHOLE conversation, so record the FINAL, most complete form
|
||||
of each fact. If the user gives a detail early and refines it later, output one
|
||||
object with the refined version — never one per stage. If the user corrects or
|
||||
retracts something, keep only what they ended up saying. Copy what the user actually said; do not infer, embellish, or add a
|
||||
judgement the user did not make (never call something their "favorite",
|
||||
"main", or "best" unless the user used that word).
|
||||
|
||||
@@ -56,8 +69,7 @@ Only invent a new section if none fit, and make it a SHORT single word
|
||||
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
||||
|
||||
---
|
||||
USER: {user_message}
|
||||
ASSISTANT: {assistant_response}
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
||||
@@ -95,11 +107,11 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
prompt is worded (verified against mistral:7b):
|
||||
|
||||
1. Absence claims. It reads the existing-memory block and writes things like
|
||||
"Jon does not have any pets" - which contradicted four cats already on
|
||||
"the user does not have any pets" - which contradicted four cats already on
|
||||
file. Silence is not a fact.
|
||||
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
|
||||
attributes them to the user: a reply that echoed a stale memory row
|
||||
produced "Jon's main development machine is a MacBook Pro" off the user
|
||||
produced "the user's main development machine is a MacBook Pro" off the user
|
||||
message "What am I developing on?".
|
||||
|
||||
The grounding test only fires when a fact carries distinctive tokens and
|
||||
@@ -114,20 +126,44 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def render_transcript(messages: list[dict]) -> str:
|
||||
"""The conversation as the curator sees it. Assistant turns are truncated
|
||||
hard: they are context for reading the user's lines, never a fact source,
|
||||
and a long reply would otherwise crowd out the lines that matter."""
|
||||
lines = []
|
||||
for m in messages:
|
||||
role = (m.get("role") or "").upper()
|
||||
if role not in ("USER", "ASSISTANT"):
|
||||
continue
|
||||
text = (m.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
lines.append(f"{role}: {text[:3000] if role == 'USER' else text[:600]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def extract_memory(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
messages: list[dict],
|
||||
existing_sections: list[str],
|
||||
existing_texts: list[str],
|
||||
ollama_manager,
|
||||
model: str = DEFAULT_MEMORY_MODEL,
|
||||
num_gpu: int | None = 0,
|
||||
) -> list[dict]:
|
||||
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
||||
"""Extract saveable memory facts from a whole conversation.
|
||||
|
||||
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
||||
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
||||
`messages` is the transcript as [{role, content}]. Returns a list of
|
||||
{"section": ..., "text": ...} — possibly empty. One conversation can hold
|
||||
several facts, and the model emits one JSON object per fact.
|
||||
"""
|
||||
transcript = render_transcript(messages)
|
||||
if not transcript:
|
||||
return []
|
||||
# Grounding is checked against everything the user actually typed, so a fact
|
||||
# assembled from details spread across several of their turns still passes.
|
||||
user_text = "\n".join(
|
||||
(m.get("content") or "") for m in messages if (m.get("role") or "") == "user"
|
||||
)
|
||||
if existing_texts:
|
||||
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
||||
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
||||
@@ -148,8 +184,7 @@ async def extract_memory(
|
||||
prompt = _PROMPT.format(
|
||||
existing_texts=texts_block,
|
||||
existing_sections=sections_line,
|
||||
user_message=user_message[:3000],
|
||||
assistant_response=assistant_response[:800],
|
||||
transcript=transcript,
|
||||
)
|
||||
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
||||
# the same console as the frontline model, not just Python warnings on failure.
|
||||
@@ -194,7 +229,7 @@ async def extract_memory(
|
||||
def _keep(o):
|
||||
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
||||
fact = str(o["text"]).strip()
|
||||
reason = _reject_reason(fact, user_message)
|
||||
reason = _reject_reason(fact, user_text)
|
||||
if reason:
|
||||
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
||||
return
|
||||
@@ -217,7 +252,7 @@ async def extract_memory(
|
||||
else:
|
||||
_keep(obj)
|
||||
if not results:
|
||||
_log.warning("memory: nothing saved. mistral said: %.300r", text)
|
||||
_log.warning("memory: nothing saved. curator said: %.300r", text)
|
||||
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
||||
else:
|
||||
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
"""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 math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .store import store, MemoryItem
|
||||
from .extractor import extract_memory
|
||||
from ..ollama_manager import get_ollama_manager
|
||||
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
|
||||
|
||||
app = FastAPI(title="Nexus Memory Service", version="1.0")
|
||||
|
||||
# Same unauthenticated-local-only posture as the main backend: reject foreign
|
||||
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
|
||||
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
|
||||
if "*" not in ALLOWED_HOSTS:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _warm_curator():
|
||||
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
|
||||
extraction isn't a cold load that blows the timeout. Runs in the background
|
||||
so it never delays startup. keep_alive then holds it warm between messages."""
|
||||
async def _bg():
|
||||
try:
|
||||
mgr = get_ollama_manager()
|
||||
# Ollama is started by the Synapse backend (a separate process), so
|
||||
# at our startup it usually isn't reachable yet. Wait for it before
|
||||
# warming instead of failing with "All connection attempts failed" —
|
||||
# which leaves the curator cold and makes the first extraction slow.
|
||||
for _ in range(60): # up to ~2 min
|
||||
if await asyncio.to_thread(mgr.is_running):
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
return
|
||||
settings = store.get_settings()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||
await mgr.warm(model, num_gpu=num_gpu)
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(_bg())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def health():
|
||||
return {"status": "ok", "count": len(store.all())}
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
def _cosine(a: list, b: list) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
class ExtractRequest(BaseModel):
|
||||
user_message: str
|
||||
assistant_response: str
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
|
||||
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
|
||||
settings = store.get_settings()
|
||||
mgr = get_ollama_manager()
|
||||
model = settings.get("memory_model") or await mgr.select_best_model()
|
||||
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
||||
try:
|
||||
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
||||
except (TypeError, ValueError):
|
||||
merge_threshold = 0.88
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
extract_memory(
|
||||
req.user_message,
|
||||
req.assistant_response,
|
||||
existing_sections,
|
||||
existing_texts,
|
||||
mgr,
|
||||
model=model,
|
||||
num_gpu=num_gpu,
|
||||
),
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
# Embed existing facts once so each new fact can be matched against them.
|
||||
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
||||
# rather than appending a copy. Best effort: if embeddings are down we
|
||||
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
||||
existing_embeds: dict = {}
|
||||
if results and 0 < merge_threshold < 1:
|
||||
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
||||
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
||||
|
||||
saved = []
|
||||
for result in results:
|
||||
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
||||
match_id, best = None, 0.0
|
||||
if new_vec:
|
||||
for eid, ev in existing_embeds.items():
|
||||
sim = _cosine(new_vec, ev)
|
||||
if sim > best:
|
||||
best, match_id = sim, eid
|
||||
if best < merge_threshold:
|
||||
match_id = None
|
||||
|
||||
target = store.get(match_id) if match_id else None
|
||||
if target:
|
||||
# Near-duplicate of an existing fact — overwrite with the newer
|
||||
# statement, keeping the original id/section/position.
|
||||
updated = MemoryItem(id=target.id, section=target.section,
|
||||
text=result["text"], tags=target.tags)
|
||||
store.update(updated)
|
||||
if new_vec:
|
||||
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
||||
saved.append({"id": updated.id, "section": updated.section,
|
||||
"text": updated.text, "updated": True})
|
||||
else:
|
||||
item = MemoryItem(id=str(uuid.uuid4()),
|
||||
section=result["section"], text=result["text"])
|
||||
store.add(item)
|
||||
if new_vec:
|
||||
existing_embeds[item.id] = new_vec
|
||||
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
||||
if saved:
|
||||
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
||||
return {"saved": True, "items": saved, **first}
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return {"saved": False}
|
||||
+160
-14
@@ -30,6 +30,7 @@ class MemoryItem(BaseModel):
|
||||
text: str
|
||||
tags: List[str] = []
|
||||
position: int = 0
|
||||
project_id: str = "" # "" = global: injected into every chat
|
||||
|
||||
class MessageItem(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
@@ -44,6 +45,7 @@ class ConversationItem(BaseModel):
|
||||
created_at: float
|
||||
updated_at: float
|
||||
title: Optional[str] = None
|
||||
project_id: str = ""
|
||||
|
||||
@property
|
||||
def preview(self) -> str:
|
||||
@@ -127,6 +129,12 @@ class PersistentMemoryStore:
|
||||
if cur.fetchone()[0] == 0:
|
||||
cur.execute("UPDATE memory SET position = rowid")
|
||||
|
||||
# Migrate: scope a fact to a project ("" = global, applies everywhere).
|
||||
try:
|
||||
cur.execute("ALTER TABLE memory ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -145,6 +153,24 @@ class PersistentMemoryStore:
|
||||
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
# Migrate: high-water mark for memory extraction — the id of the last
|
||||
# message the curator has already read. Extraction runs once the
|
||||
# conversation goes idle rather than after every exchange, so this is
|
||||
# what makes it idempotent and restart-safe: a backend that dies with a
|
||||
# pending sweep resumes from here instead of re-reading the whole
|
||||
# transcript and re-saving facts it already saved.
|
||||
try:
|
||||
cur.execute("ALTER TABLE conversations ADD COLUMN extracted_through INTEGER NOT NULL DEFAULT 0")
|
||||
# Only reached the first time the column is added. Everything already
|
||||
# in the database was extracted per-exchange under the old design, so
|
||||
# watermark it as read — without this the first idle sweep would
|
||||
# re-read every historical conversation and re-save its facts.
|
||||
cur.execute(
|
||||
"UPDATE conversations SET extracted_through = "
|
||||
"COALESCE((SELECT MAX(id) FROM messages WHERE conversation_id = conversations.id), 0)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -204,6 +230,11 @@ class PersistentMemoryStore:
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
# projects.instructions — per-project system prompt ("" = none).
|
||||
try:
|
||||
cur.execute("ALTER TABLE projects ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
# documents.project_id — "" (or missing) means unscoped / All.
|
||||
try:
|
||||
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||
@@ -229,7 +260,7 @@ class PersistentMemoryStore:
|
||||
def _load_all_memory(self) -> Dict[str, MemoryItem]:
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
@@ -245,6 +276,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
)
|
||||
|
||||
return cache
|
||||
@@ -271,8 +303,10 @@ class PersistentMemoryStore:
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
|
||||
"INSERT OR REPLACE INTO memory (id, section, text, tags, position, project_id)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(item.id, item.section or "General", item.text, json.dumps(item.tags),
|
||||
item.position, item.project_id or "")
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -318,12 +352,12 @@ class PersistentMemoryStore:
|
||||
return item
|
||||
|
||||
def all(self) -> List[MemoryItem]:
|
||||
# Always read from DB — the memory service and backend run in separate processes
|
||||
# with separate caches, so the cache can be stale for facts extracted by the
|
||||
# memory service after this process started.
|
||||
# Always read from DB, never the cache: the CLI, the control panel and
|
||||
# the backend are separate processes with separate caches, so a fact
|
||||
# written by one is invisible to another's cache.
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
|
||||
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = []
|
||||
@@ -338,6 +372,7 @@ class PersistentMemoryStore:
|
||||
text=row["text"],
|
||||
tags=tags,
|
||||
position=row["position"] or 0,
|
||||
project_id=row["project_id"] or "",
|
||||
))
|
||||
return items
|
||||
|
||||
@@ -405,6 +440,18 @@ class PersistentMemoryStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_conversation_project(self, conversation_id: str, project_id: str):
|
||||
"""Move a conversation into a project ('' = unscoped)."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE conversations SET project_id = ? WHERE id = ?",
|
||||
(project_id or "", conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
|
||||
now = time.time()
|
||||
conn = self._connect()
|
||||
@@ -452,7 +499,7 @@ class PersistentMemoryStore:
|
||||
conn = self._connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT c.id, c.created_at, c.updated_at, c.title,
|
||||
SELECT c.id, c.created_at, c.updated_at, c.title, c.project_id,
|
||||
m.role, m.content, m.timestamp, m.model, m.tokens
|
||||
FROM conversations c
|
||||
LEFT JOIN messages m ON m.conversation_id = c.id
|
||||
@@ -471,6 +518,7 @@ class PersistentMemoryStore:
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
title=row["title"],
|
||||
project_id=row["project_id"] or "",
|
||||
)
|
||||
order.append(cid)
|
||||
if row["role"] is not None:
|
||||
@@ -479,10 +527,79 @@ class PersistentMemoryStore:
|
||||
)
|
||||
return [convs[cid] for cid in order]
|
||||
|
||||
def pending_extraction(self, conversation_id: str) -> tuple[list, int]:
|
||||
"""Messages the curator has not read yet, and the id to watermark to.
|
||||
|
||||
Returns ([{role, content}], last_id). An empty list means nothing new,
|
||||
so callers can skip the model call entirely.
|
||||
"""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT extracted_through FROM conversations WHERE id = ?", (conversation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return [], 0
|
||||
rows = conn.execute(
|
||||
"SELECT id, role, content FROM messages "
|
||||
"WHERE conversation_id = ? AND id > ? ORDER BY id ASC",
|
||||
(conversation_id, row["extracted_through"] or 0),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
return [], 0
|
||||
return ([{"role": r["role"], "content": r["content"]} for r in rows], rows[-1]["id"])
|
||||
|
||||
def set_extracted_through(self, conversation_id: str, message_id: int) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE conversations SET extracted_through = ? WHERE id = ?",
|
||||
(int(message_id), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def conversations_awaiting_extraction(self, idle_seconds: float) -> list[str]:
|
||||
"""Conversations with unread messages that have been quiet long enough
|
||||
to count as finished. Used to resume sweeps dropped by a restart."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT c.id FROM conversations c JOIN messages m ON m.conversation_id = c.id "
|
||||
"WHERE m.id > c.extracted_through GROUP BY c.id "
|
||||
"HAVING MAX(m.timestamp) < ?",
|
||||
(time.time() - idle_seconds,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
def delete_conversation(self, conversation_id: str):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
# Drop the embeddings first, while the message ids still resolve.
|
||||
# Stale vectors are inert (the search joins messages) but they still
|
||||
# occupy slots in the ANN over-fetch, so leaving them behind quietly
|
||||
# thins recall of the conversations that are still here.
|
||||
cur.execute(
|
||||
"DELETE FROM message_vectors WHERE message_id IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
# vec_enabled only says the extension loaded; the virtual table is
|
||||
# created lazily on the first semantic search, so check for it.
|
||||
if self.vec_enabled and cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
|
||||
).fetchone():
|
||||
cur.execute(
|
||||
"DELETE FROM vec_messages WHERE rowid IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
|
||||
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
conn.commit()
|
||||
@@ -810,17 +927,40 @@ class PersistentMemoryStore:
|
||||
def list_projects(self) -> List[dict]:
|
||||
conn = self._connect()
|
||||
rows = conn.execute("""
|
||||
SELECT p.id, p.name, p.created_at,
|
||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
|
||||
SELECT p.id, p.name, p.created_at, p.instructions,
|
||||
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs,
|
||||
(SELECT COUNT(*) FROM conversations c WHERE c.project_id = p.id) AS chats
|
||||
FROM projects p ORDER BY p.created_at ASC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def delete_project(self, project_id: str) -> bool:
|
||||
"""Delete a project; its documents survive but become unscoped ("")."""
|
||||
def set_project_instructions(self, project_id: str, instructions: str) -> bool:
|
||||
"""Per-project system prompt, layered into chats bound to the project."""
|
||||
conn = self._connect()
|
||||
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||
try:
|
||||
cur = conn.execute("UPDATE projects SET instructions = ? WHERE id = ?",
|
||||
(instructions or "", project_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def project_instructions(self, project_id: str) -> str:
|
||||
"""'' when the project has none, or doesn't exist."""
|
||||
if not project_id:
|
||||
return ""
|
||||
conn = self._connect()
|
||||
row = conn.execute("SELECT instructions FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
conn.close()
|
||||
return (row["instructions"] or "") if row else ""
|
||||
|
||||
def delete_project(self, project_id: str) -> bool:
|
||||
"""Delete a project; its documents, chats and facts survive but become
|
||||
unscoped ("")."""
|
||||
conn = self._connect()
|
||||
for table in ("documents", "conversations", "memory"):
|
||||
conn.execute(f"UPDATE {table} SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
@@ -1024,7 +1164,10 @@ class PersistentMemoryStore:
|
||||
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
|
||||
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
|
||||
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
|
||||
"memory_gpu_offload": 0,
|
||||
# -1 = Auto (let Ollama fit it). The curator is the resident chat model
|
||||
# now, so there is nothing to keep off the GPU; pinning it to CPU (0)
|
||||
# only bought coexistence with a second, separate curator model.
|
||||
"memory_gpu_offload": -1,
|
||||
# Similar-fact merge: when a newly extracted fact's embedding is at least
|
||||
# this cosine-similar to an existing fact, UPDATE that fact in place
|
||||
# instead of appending a duplicate ("edit with new info"). 0 disables
|
||||
@@ -1033,6 +1176,9 @@ class PersistentMemoryStore:
|
||||
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
|
||||
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
|
||||
"memory_merge_threshold": 0.80,
|
||||
# Seconds of quiet before the curator reads a conversation. This is the
|
||||
# "conversation is over" signal; each new message restarts the clock.
|
||||
"memory_extract_idle": 120,
|
||||
}
|
||||
|
||||
def get_settings(self) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user