f4f77c5196
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
6.9 KiB
Python
Executable File
169 lines
6.9 KiB
Python
Executable File
"""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
|
|
|
|
_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 revealed in this exchange.
|
|
There may be SEVERAL facts in one message — output one JSON object for each.
|
|
|
|
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. 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. Only skip a fact that is an EXACT
|
|
restatement of one already listed.
|
|
|
|
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 Jon, third person>"}}
|
|
If there is nothing new to save, output exactly:
|
|
{{"save": false}}"""
|
|
|
|
|
|
async def extract_memory(
|
|
user_message: str,
|
|
assistant_response: str,
|
|
existing_sections: list[str],
|
|
existing_texts: list[str],
|
|
ollama_manager,
|
|
model: str = "mistral:latest",
|
|
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"):
|
|
results.append({
|
|
"section": str(o["section"]).strip(),
|
|
"text": str(o["text"]).strip(),
|
|
})
|
|
|
|
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 []
|