refactor(synapse): backend updates, add icons module, relocate playbooks to data/playbooks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Regular → Executable
+105
-31
@@ -6,42 +6,52 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
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.
|
||||
|
||||
A conversation just occurred. Decide if the USER revealed a NEW, PERMANENT \
|
||||
personal fact that should be saved to long-term memory.
|
||||
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 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
|
||||
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", "I have a full day of work"
|
||||
- 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
|
||||
- Near-duplicates of anything in the existing memory list below
|
||||
|
||||
Existing memory (do not re-save these or close variants):
|
||||
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:
|
||||
{{"save": true, "section": "<section name, or one from the list above>", "text": "<concise fact about Jon, third person>"}}
|
||||
OR
|
||||
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}}"""
|
||||
|
||||
|
||||
@@ -51,31 +61,62 @@ async def extract_memory(
|
||||
existing_sections: list[str],
|
||||
existing_texts: list[str],
|
||||
ollama_manager,
|
||||
) -> Optional[dict]:
|
||||
"""Ask Mistral to extract a saveable memory fact from a conversation exchange.
|
||||
model: str = "mistral:latest",
|
||||
num_gpu: int | None = 0,
|
||||
) -> list[dict]:
|
||||
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
||||
|
||||
Returns {"section": ..., "text": ...} or None.
|
||||
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:
|
||||
texts_block = "\n".join(f"- {t}" for t in existing_texts[:40])
|
||||
# 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,
|
||||
user_message=user_message[:800],
|
||||
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="mistral:latest",
|
||||
model=model,
|
||||
stream=False,
|
||||
temperature=0.0,
|
||||
num_gpu=num_gpu,
|
||||
)
|
||||
if not response:
|
||||
return None
|
||||
_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:
|
||||
@@ -83,12 +124,45 @@ async def extract_memory(
|
||||
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(),
|
||||
}
|
||||
# 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.debug("memory extraction failed: %s", e)
|
||||
return None
|
||||
_log.warning("memory extraction failed: %s", e)
|
||||
_synapse_trace(f"◆ CURATOR ERROR: {e}\n{_TR}\n\n")
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user