forked from enderofwings/NexusOS
Ported from downstream development. Four independent defects.
1. The memory dump was unrestorable. iterdump() serializes sqlite_vec virtual
tables as a raw INSERT INTO sqlite_master(...) followed by inserts into a
table the replaying connection cannot see, so replaying memory.db.sql died
on "no such table: vec_messages" and left ZERO tables behind. dump_db() now
loads the vec0 extension and filters the derived vec tables out of the
iterdump stream, matched on each statement's target table rather than as a
substring - a chat message whose text mentions vec_messages is an
INSERT INTO "messages" and has to survive.
compare() reported an unreadable dump as "diverged", which read like a real
verdict and made both guards refuse backup AND restore, locking the machine
out of syncing in either direction. Unreadable is now its own verdict.
_extra() compared updated_at against a "" default, but the column is REAL,
so the comparison raises TypeError on the first conversation the other side
lacks - exactly the case it counts. It tests membership first now. The
direction test declared updated_at TEXT, which is why this survived: the
test compared str to str while the field compared str to float.
2. The memory curator invented facts. It attributed the ASSISTANT's words to
the user, wrote absence claims read off the existing-memory block, and added
judgements ("favorite") the user never used. The prompt now scopes the USER
line as the only source, and two deterministic guards drop absence claims
and facts whose distinctive tokens appear nowhere in the user's message -
prompt wording alone did not hold on a 7B curator.
3. _best_vulkan_device scored Mesa's llvmpipe above an integrated GPU, pinning
Ollama to a software rasterizer advertising 31 GiB of "VRAM" - CPU inference
with Vulkan overhead on top. Software rasterizers are dropped.
4. Models.jsx compared catalog names to installed names literally, but Ollama
resolves a bare name to ":latest", so an untagged entry (nomic-embed-text)
read as missing forever and the Required gate never opened. Chatbot.jsx
fetched the model list once on mount although App keeps the page mounted
behind display:none, so a newly pulled model never appeared in the picker
until a full browser reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
9.9 KiB
Python
230 lines
9.9 KiB
Python
"""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 ..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 exchange.
|
|
There may be SEVERAL facts in one message — 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
|
|
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
|
|
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.
|
|
|
|
---
|
|
USER: {user_message}
|
|
ASSISTANT: {assistant_response}
|
|
---
|
|
|
|
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
|
|
"Jon 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
|
|
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
|
|
|
|
|
|
async def extract_memory(
|
|
user_message: str,
|
|
assistant_response: str,
|
|
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.
|
|
|
|
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
|
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
|
"""
|
|
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,
|
|
user_message=user_message[:3000],
|
|
assistant_response=assistant_response[:800],
|
|
)
|
|
# 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_message)
|
|
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. mistral 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 []
|