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)
265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""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
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from ..chat import _synapse_trace # append curator reasoning to the same MindTrace log
|
|
from ..nexus_config import DEFAULT_MEMORY_MODEL
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_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 conversation.
|
|
There may be SEVERAL facts across the conversation — output one JSON object for
|
|
each.
|
|
|
|
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.
|
|
|
|
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).
|
|
|
|
Never save a statement about what is unknown, unspecified, or absent — no
|
|
"X is unknown", "has not said", "has no Y". Silence is not a fact.
|
|
|
|
SAVE facts that are stable and biographical, such as: identity (name, age,
|
|
location), relationships (family, partner, friends), pets, possessions (vehicles,
|
|
home, devices), career (job, employer, skills), hobbies and interests,
|
|
long-running projects or goals (not today's to-dos), and durable preferences.
|
|
|
|
DO NOT SAVE — these are ephemeral and would clutter memory:
|
|
- Anything time-bound: "today I have...", "I'm working on X today"
|
|
- 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
|
|
|
|
The existing memory is below FOR CONTEXT ONLY — it is already saved, so never
|
|
output anything from it, and never comment on what it does or does not contain.
|
|
If the user ADDS NEW DETAIL to something already known (e.g. a new detail about
|
|
a known pet, car, or project), DO save that new detail as its own fact. Skip
|
|
anything already covered by a listed fact, even when the wording differs.
|
|
|
|
Existing memory:
|
|
{existing_texts}
|
|
|
|
For "section", REUSE one of these existing section names whenever it fits:
|
|
{existing_sections}
|
|
Only invent a new section if none fit, and make it a SHORT single word
|
|
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
|
|
|
---
|
|
{transcript}
|
|
---
|
|
|
|
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
|
new fact (several objects, one per line, if there are several):
|
|
{{"save": true, "section": "<short section name>", "text": "<concise fact about the user, third person>"}}
|
|
If there is nothing new to save, output exactly:
|
|
{{"save": false}}"""
|
|
|
|
|
|
_ABSENCE = re.compile(
|
|
r"\b(?:no longer|does ?n[o']t|do ?n[o']t|did ?n[o']t|has not|hasn't|have not|"
|
|
r"haven't|is not|isn't|are not|aren't|has no|have no|not specified|"
|
|
r"unspecified|unknown|not mentioned|no pets|as per the|according to the "
|
|
r"(?:current )?memory)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# The subject's name and the assistant's are in every fact by instruction, so
|
|
# they prove nothing about grounding. "The"/"User" are here because the regex
|
|
# below treats any capitalised word as distinctive.
|
|
_STOP_TOKENS = {"jon", "nexus", "the", "user"}
|
|
|
|
|
|
def _distinctive(text: str) -> set[str]:
|
|
"""Proper nouns and numbers in a fact - the parts that can't be invented
|
|
from thin air without showing up in what the user actually typed."""
|
|
tokens = set(re.findall(r"\b[A-Z][A-Za-z0-9.+-]{2,}\b|\b\d[\d.]*\w*\b", text))
|
|
return {t for t in tokens if t.lower() not in _STOP_TOKENS}
|
|
|
|
|
|
def _reject_reason(fact: str, user_message: str) -> str | None:
|
|
"""Why this fact must not be saved, or None to keep it.
|
|
|
|
Two failure classes the curator model keeps producing no matter how the
|
|
prompt is worded (verified against mistral:7b):
|
|
|
|
1. Absence claims. It reads the existing-memory block and writes things like
|
|
"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 "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
|
|
NONE of them appear in the user's own message. A fact with no proper nouns
|
|
or numbers ("prefers casual conversation") is left to the prompt.
|
|
"""
|
|
if _ABSENCE.search(fact):
|
|
return "absence claim"
|
|
marks = _distinctive(fact)
|
|
if marks and not any(m.lower() in user_message.lower() for m in marks):
|
|
return f"ungrounded in the user message (invented {sorted(marks)[:3]})"
|
|
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(
|
|
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]:
|
|
"""Extract saveable memory facts from a whole conversation.
|
|
|
|
`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
|
|
# facts made a ~1200-token prompt that took ~50s+ to process. If dedup
|
|
# starts re-saving older facts, move dedup to a difflib check in the
|
|
# service instead of stuffing every fact into the prompt.
|
|
texts_block = "\n".join(f"- {t}" for t in existing_texts[-12:])
|
|
else:
|
|
texts_block = "(none yet)"
|
|
# Give Mistral the real section names to reuse, so it stops inventing
|
|
# sentence-long sections out of the category descriptions in the prompt.
|
|
# Only offer SHORT, clean names — never feed a junk sentence-section (e.g. a
|
|
# past bad "Long-running projects or goals") back as a valid choice.
|
|
clean = sorted(s for s in existing_sections if s and len(s.split()) <= 2 and len(s) <= 24)
|
|
sections_line = ", ".join(clean) if clean else (
|
|
"Identity, Relationships, Pets, Possessions, Career, Hobbies, Projects, Preferences"
|
|
)
|
|
prompt = _PROMPT.format(
|
|
existing_texts=texts_block,
|
|
existing_sections=sections_line,
|
|
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.
|
|
_synapse_trace(
|
|
f"\n{_TR}\n◆ CURATOR: {model} (num_gpu={num_gpu})\n"
|
|
f" PROMPT ({len(prompt)} chars):\n{prompt}\n{_TR}\n"
|
|
)
|
|
try:
|
|
# num_gpu=0 (the default) pins the curator fully in system RAM instead of
|
|
# the GPU, so it coexists with the GPU-resident chat model instead of
|
|
# evicting it. Without this, on a small GPU the two thrash: every
|
|
# exchange cold-loads the curator (~45s) and extraction times out,
|
|
# silently saving nothing. Boxes with spare VRAM override via settings.
|
|
response = await ollama_manager.chat(
|
|
messages=[{"role": "user", "content": prompt}],
|
|
model=model,
|
|
stream=False,
|
|
temperature=0.0,
|
|
num_gpu=num_gpu,
|
|
)
|
|
if not response:
|
|
_synapse_trace("◆ CURATOR RAW: (empty response)\n")
|
|
return []
|
|
|
|
text = response.strip()
|
|
_synapse_trace(f"◆ CURATOR RAW:\n{text}\n")
|
|
|
|
# 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()
|
|
|
|
# For several facts Mistral is inconsistent: sometimes ONE JSON object
|
|
# per fact newline-separated, sometimes a single JSON ARRAY of objects.
|
|
# raw_decode pulls each top-level value (handles the newline case and
|
|
# plain "Extra data"); we then flatten any array so both shapes save all
|
|
# facts. Plain json.loads() would die on the newline case and skip the
|
|
# array (a list isn't a dict), losing every fact either way.
|
|
results: list[dict] = []
|
|
|
|
def _keep(o):
|
|
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
|
fact = str(o["text"]).strip()
|
|
reason = _reject_reason(fact, user_text)
|
|
if reason:
|
|
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
|
return
|
|
results.append({"section": str(o["section"]).strip(), "text": fact})
|
|
|
|
dec = json.JSONDecoder()
|
|
idx = 0
|
|
while idx < len(text):
|
|
while idx < len(text) and text[idx] in " \t\r\n,":
|
|
idx += 1
|
|
if idx >= len(text):
|
|
break
|
|
try:
|
|
obj, idx = dec.raw_decode(text, idx)
|
|
except json.JSONDecodeError:
|
|
break
|
|
if isinstance(obj, list):
|
|
for o in obj:
|
|
_keep(o)
|
|
else:
|
|
_keep(obj)
|
|
if not results:
|
|
_log.warning("memory: nothing saved. 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)
|
|
_synapse_trace(f"◆ CURATOR VERDICT: {len(results)} fact(s) — {_facts}\n{_TR}\n\n")
|
|
return results
|
|
except Exception as e:
|
|
_log.warning("memory extraction failed: %s", e)
|
|
_synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
|
|
return []
|