mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-23 08:32:20 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -17,6 +17,8 @@ import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,7 +36,7 @@ def _fingerprint_entries(entries) -> str:
|
||||
only on id+text+category. Any add/edit/delete invalidates it."""
|
||||
items = sorted(
|
||||
(str(e.get("id", "")), e.get("text", ""), e.get("category", ""))
|
||||
for e in entries
|
||||
for e in _memory_dicts(entries)
|
||||
)
|
||||
h = hashlib.sha256()
|
||||
for triple in items:
|
||||
@@ -42,10 +44,16 @@ def _fingerprint_entries(entries) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _memory_dicts(entries):
|
||||
for entry in entries or []:
|
||||
if isinstance(entry, dict):
|
||||
yield entry
|
||||
|
||||
|
||||
def _load_tidy_state(memory_manager) -> dict:
|
||||
path = _tidy_state_path(memory_manager)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
@@ -57,7 +65,7 @@ def _save_tidy_state(memory_manager, owner: Optional[str], fingerprint: str) ->
|
||||
state = _load_tidy_state(memory_manager)
|
||||
state[owner or ""] = {"fingerprint": fingerprint}
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not persist tidy fingerprint: {e}")
|
||||
@@ -82,6 +90,29 @@ EXTRACT_SYSTEM_PROMPT = (
|
||||
# How many recent messages to include for extraction
|
||||
CONTEXT_WINDOW = 6
|
||||
|
||||
PERSONA_MEMORY_SYSTEM_PROMPT = (
|
||||
"You maintain concise continuity notes for one active chat persona. "
|
||||
"Update the existing notes using only durable details established in the transcript. "
|
||||
"Keep details that help the same persona stay consistent in future conversations: "
|
||||
"relationship context, names, preferences, recurring story details, boundaries, and unresolved threads. "
|
||||
"Do not store generic chat events, temporary wording, assistant reasoning, or one-off requests. "
|
||||
"Never invent details. Return only the updated notes as short bullet points, max 12 bullets. "
|
||||
"If there is nothing worth keeping, return the existing notes unchanged or an empty string."
|
||||
)
|
||||
|
||||
HEALTH_PERSONA_MEMORY_SYSTEM_PROMPT = (
|
||||
"You maintain a cautious health-record brief for a medical reasoning persona. "
|
||||
"Update the existing brief using only medically durable information from the transcript. "
|
||||
"Keep facts that may matter in future health conversations: confirmed diagnoses, chronic conditions, "
|
||||
"surgeries/procedures, allergies, regular medications/supplements, important test results, clinicians/hospitals, "
|
||||
"ongoing symptoms or care plans, and the user's preferences for medical explanations. "
|
||||
"Use uncertainty labels when needed: 'reported', 'possible', 'asked about', 'unclear'. "
|
||||
"Do not turn guesses into diagnoses. Do not store casual one-off symptoms unless they are recurring, severe, "
|
||||
"or tied to an ongoing episode. Never invent facts. Return only the updated brief with these headings when useful: "
|
||||
"Medical profile, Medications/allergies, Episodes/open questions, Preferences. Max 16 concise bullets total. "
|
||||
"If nothing medically durable changed, return the existing brief unchanged or an empty string."
|
||||
)
|
||||
|
||||
AUDIT_SYSTEM_PROMPT = (
|
||||
"You are a memory database curator. Be CONSERVATIVE: remove only TRUE "
|
||||
"duplicates and clearly useless entries. Every distinct fact must survive. "
|
||||
@@ -104,6 +135,20 @@ AUDIT_SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
AUDIT_INTERVAL = 5 # audit every N new memories added
|
||||
AUTO_PINNED_IDENTITY_LIMIT = 5
|
||||
|
||||
|
||||
def _is_owner_memory(entry, owner):
|
||||
if owner:
|
||||
return entry.get("owner") == owner or entry.get("owner") is None
|
||||
return True
|
||||
|
||||
|
||||
def _is_auto_pinned_identity(entry):
|
||||
return (
|
||||
bool(entry.get("pinned"))
|
||||
and (entry.get("category") or "").lower() in {"identity", "contact"}
|
||||
)
|
||||
_extractions_since_audit = 0
|
||||
|
||||
|
||||
@@ -186,11 +231,19 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
if place:
|
||||
add(f"User lives in {place}.", "identity")
|
||||
|
||||
m = re.search(r"\bi (?:prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
|
||||
m = re.search(r"\bi (prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
|
||||
if m:
|
||||
preference = _clean_memory_value(m.group(1), 100)
|
||||
preference = _clean_memory_value(m.group(2), 100)
|
||||
if preference:
|
||||
add(f"User prefers {preference}.", "preference")
|
||||
# The same pattern catches likes and dislikes; keep the stored
|
||||
# sentiment faithful instead of recording every match as a
|
||||
# preference ("I hate cilantro" must not become "User prefers
|
||||
# cilantro").
|
||||
verb = m.group(1).lower()
|
||||
if verb in ("hate", "do not like", "don't like"):
|
||||
add(f"User dislikes {preference}.", "preference")
|
||||
else:
|
||||
add(f"User prefers {preference}.", "preference")
|
||||
|
||||
m = re.search(
|
||||
r"\bi (?:(?:want|would like|plan|hope) to|wanna) "
|
||||
@@ -211,7 +264,7 @@ def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) ->
|
||||
new_tokens = set(new_text.lower().split())
|
||||
if not new_tokens:
|
||||
return False
|
||||
for entry in existing:
|
||||
for entry in _memory_dicts(existing):
|
||||
old_tokens = set(entry.get("text", "").lower().split())
|
||||
if not old_tokens:
|
||||
continue
|
||||
@@ -222,6 +275,43 @@ def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) ->
|
||||
return False
|
||||
|
||||
|
||||
def _parse_extraction_json(raw: str) -> list:
|
||||
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||
reasoning-model noise.
|
||||
|
||||
The model emits <think>…</think> (and sometimes a prose preamble or a
|
||||
```json fence) AROUND the JSON array; without stripping it, json.loads
|
||||
bombs and the run silently yields "0 candidates". Pure str -> list (no
|
||||
LLM/network); returns [] on any parse failure instead of raising.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
try:
|
||||
from src.text_helpers import strip_think as _strip_think
|
||||
text = _strip_think(text, prose=True, prompt_echo=True).strip()
|
||||
except Exception:
|
||||
pass
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
# JSON may still be embedded in surrounding commentary (leading prose or
|
||||
# trailing remarks like "[...] Done!") — slice from the first '[' to the
|
||||
# last ']' whenever both exist. Slice unconditionally: a reply that starts
|
||||
# with '[' can still carry trailing commentary that breaks json.loads.
|
||||
_start = text.find("[")
|
||||
_end = text.rfind("]")
|
||||
if 0 <= _start < _end:
|
||||
text = text[_start : _end + 1]
|
||||
|
||||
try:
|
||||
facts = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
|
||||
return []
|
||||
except Exception:
|
||||
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
|
||||
return []
|
||||
return facts if isinstance(facts, list) else []
|
||||
|
||||
|
||||
async def extract_and_store(
|
||||
session,
|
||||
memory_manager,
|
||||
@@ -235,6 +325,10 @@ async def extract_and_store(
|
||||
Designed to run as a background task (asyncio.create_task).
|
||||
Errors are logged, never raised.
|
||||
"""
|
||||
if not endpoint_url or not model:
|
||||
logger.debug("[memory-extract] No model or URL provided, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
@@ -245,11 +339,55 @@ async def extract_and_store(
|
||||
if len(recent) < 2:
|
||||
return # Need at least a user message and assistant response
|
||||
|
||||
fallback_facts = _fallback_memory_candidates(recent)
|
||||
# Strip media (images/audio) from messages — background memory extraction
|
||||
# only needs the text. The VL-generated descriptions are already in the
|
||||
# text content of the messages. This avoids sending image tokens to
|
||||
# non-vision models and prevents accidental "vision grounding" triggers.
|
||||
stripped_recent = []
|
||||
for msg in recent:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Filter out multimodal blocks that aren't text
|
||||
text_only = [b for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||
if not text_only and content:
|
||||
continue
|
||||
content = text_only
|
||||
stripped_recent.append({"role": role, "content": content})
|
||||
|
||||
if not stripped_recent:
|
||||
return
|
||||
|
||||
fallback_facts = _fallback_memory_candidates(stripped_recent)
|
||||
|
||||
# Flatten the window into a SINGLE user message instead of appending the
|
||||
# raw alternating role messages. Passed as raw chat messages, the model
|
||||
# treats the window as a conversation to CONTINUE rather than a transcript
|
||||
# to ANALYZE, so it reliably extracts nothing — typically returning `[]`
|
||||
# (and, depending on the input, sometimes an empty or <think>-only
|
||||
# completion when the window ends on an assistant turn). This was the real
|
||||
# cause of auto-memory logging "0 candidates" on every run. Reframing it as
|
||||
# one "analyze this transcript, return the JSON array" user message makes
|
||||
# the model actually extract. Controlled repro on this model: 0/6 trials
|
||||
# with the old structure vs 6/6 with this one. The skill extractor flattens
|
||||
# for the same reason.
|
||||
def _flatten_msg(m):
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, list):
|
||||
c = " ".join(
|
||||
b.get("text", "") for b in c
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
)
|
||||
return f"{m.get('role', '?')}: {c}"
|
||||
|
||||
transcript = "\n\n".join(_flatten_msg(m) for m in stripped_recent)
|
||||
extraction_messages = [
|
||||
{"role": "system", "content": EXTRACT_SYSTEM_PROMPT},
|
||||
] + recent
|
||||
{"role": "user", "content": (
|
||||
"Conversation to analyze:\n\n" + transcript
|
||||
+ "\n\nReturn the JSON array of durable facts now (or [] if none)."
|
||||
)},
|
||||
]
|
||||
|
||||
facts = []
|
||||
try:
|
||||
@@ -258,19 +396,20 @@ async def extract_and_store(
|
||||
model,
|
||||
extraction_messages,
|
||||
temperature=0.1,
|
||||
max_tokens=500,
|
||||
# A reasoning model spends most of its budget on <think> tokens
|
||||
# BEFORE emitting the JSON, so the old 500 truncated the response
|
||||
# before any JSON appeared → every run logged "0 candidates". The
|
||||
# audit path hit the same wall and raised to 16384; extraction's
|
||||
# output (a short facts list) is small, so an ample ceiling is
|
||||
# enough once thinking has room.
|
||||
max_tokens=4096,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Parse JSON from response (handle markdown fences if model wraps them)
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
|
||||
try:
|
||||
facts = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Memory extraction returned non-JSON")
|
||||
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
|
||||
# ```json fence, and leading/trailing commentary). See
|
||||
# _parse_extraction_json — returns [] rather than raising.
|
||||
facts = _parse_extraction_json(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM memory extraction failed; using fallback candidates if available: {e}")
|
||||
|
||||
@@ -287,8 +426,18 @@ async def extract_and_store(
|
||||
# Get owner from session
|
||||
_owner = getattr(session, 'owner', None)
|
||||
|
||||
existing = memory_manager.load_all()
|
||||
# Strict load: this is a read-modify-write. Degrading to [] here would
|
||||
# save only the newly extracted facts and drop the entire store.
|
||||
try:
|
||||
existing = memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Skipping auto memory extraction, store unreadable: %s", e)
|
||||
return
|
||||
added = 0
|
||||
auto_pinned_identity_count = sum(
|
||||
1 for entry in existing
|
||||
if _is_owner_memory(entry, _owner) and _is_auto_pinned_identity(entry)
|
||||
)
|
||||
|
||||
for fact in facts:
|
||||
if isinstance(fact, str):
|
||||
@@ -296,19 +445,37 @@ async def extract_and_store(
|
||||
category = "fact"
|
||||
elif isinstance(fact, dict):
|
||||
fact_text = fact.get("text", "").strip()
|
||||
category = fact.get("category", "fact")
|
||||
category = str(fact.get("category", "fact") or "fact")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not fact_text or len(fact_text) < 5:
|
||||
continue
|
||||
|
||||
# Dedup: check vector similarity first (fast), then exact text match
|
||||
# Dedup: check vector similarity first (fast), then exact text match.
|
||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||
# remote endpoint down) must not abort the whole batch — fall through
|
||||
# to the text/fuzzy dedup below instead of losing every validated
|
||||
# fact extracted this session. (`.healthy` is only set at init, so
|
||||
# it does not catch failures that develop later.)
|
||||
if memory_vector and memory_vector.healthy:
|
||||
existing_id = memory_vector.find_similar(fact_text, threshold=0.72)
|
||||
try:
|
||||
existing_id = memory_vector.find_similar(fact_text, threshold=0.72)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory dedup (vector) unavailable, using text fallback: {e}")
|
||||
existing_id = None
|
||||
if existing_id:
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
# The vector store is a single shared collection with no
|
||||
# owner metadata, so find_similar can return ANOTHER
|
||||
# tenant's memory. Only treat it as a duplicate when the
|
||||
# match is this user's own (or a legacy unowned) memory —
|
||||
# otherwise the user's freshly-extracted fact would be
|
||||
# silently dropped. Mirror the owner predicate used by the
|
||||
# text dedup below; cross-tenant/stale matches fall through.
|
||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
|
||||
# Text dedup fallback: exact match + fuzzy similarity
|
||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||
@@ -320,9 +487,15 @@ async def extract_and_store(
|
||||
continue
|
||||
|
||||
entry = memory_manager.add_entry(fact_text, source="auto", category=category, owner=_owner)
|
||||
# Auto-pin identity facts (name, job, location) — core context
|
||||
if category == "identity":
|
||||
# Auto-pin only the first few identity/contact facts. Extra identity
|
||||
# memories are still saved, but they must be recalled by relevance
|
||||
# instead of riding along in every prompt forever.
|
||||
if (
|
||||
category.lower() in {"identity", "contact"}
|
||||
and auto_pinned_identity_count < AUTO_PINNED_IDENTITY_LIMIT
|
||||
):
|
||||
entry["pinned"] = True
|
||||
auto_pinned_identity_count += 1
|
||||
if hasattr(session, "session_id"):
|
||||
entry["session_id"] = session.session_id
|
||||
elif hasattr(session, "name"):
|
||||
@@ -330,9 +503,14 @@ async def extract_and_store(
|
||||
|
||||
existing.append(entry)
|
||||
|
||||
# Add to vector index
|
||||
# Add to vector index. The JSON store (saved below) is the source of
|
||||
# truth and the keyword path can still retrieve this entry, so a vector
|
||||
# write failure must not drop the fact or abort the remaining batch.
|
||||
if memory_vector and memory_vector.healthy:
|
||||
memory_vector.add(entry["id"], fact_text)
|
||||
try:
|
||||
memory_vector.add(entry["id"], fact_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory vector add failed for {entry['id']}: {e}")
|
||||
|
||||
added += 1
|
||||
|
||||
@@ -361,6 +539,88 @@ async def extract_and_store(
|
||||
logger.error(f"Memory extraction failed: {e}")
|
||||
|
||||
|
||||
async def update_persona_memory(
|
||||
session,
|
||||
preset_manager,
|
||||
character_name: str,
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[dict] = None,
|
||||
schema: str = "general",
|
||||
):
|
||||
"""Update the active persona's continuity notes from recent conversation.
|
||||
|
||||
Persona memory is stored with the persona/template data, not in the global
|
||||
memory DB, so deleting a saved persona also deletes its notes.
|
||||
"""
|
||||
character_name = (character_name or "").strip()
|
||||
if not character_name or not endpoint_url or not model or preset_manager is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.text_helpers import strip_think
|
||||
|
||||
custom = {}
|
||||
try:
|
||||
custom = preset_manager.presets.get("custom", {}) if isinstance(preset_manager.presets, dict) else {}
|
||||
except Exception:
|
||||
custom = {}
|
||||
existing_memory = ""
|
||||
if isinstance(custom, dict) and custom.get("character_name") == character_name:
|
||||
existing_memory = custom.get("persona_memory", "") or ""
|
||||
|
||||
messages = session.get_context_messages()
|
||||
recent = messages[-CONTEXT_WINDOW:] if len(messages) > CONTEXT_WINDOW else messages
|
||||
if len(recent) < 2:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for msg in recent:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
b.get("text", "") for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
)
|
||||
content = str(content or "").strip()
|
||||
if content:
|
||||
lines.append(f"{role}: {content}")
|
||||
if not lines:
|
||||
return
|
||||
|
||||
system_prompt = HEALTH_PERSONA_MEMORY_SYSTEM_PROMPT if schema == "health" else PERSONA_MEMORY_SYSTEM_PROMPT
|
||||
raw = await llm_call_async(
|
||||
endpoint_url,
|
||||
model,
|
||||
[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": (
|
||||
f"Persona name: {character_name}\n\n"
|
||||
f"Existing continuity notes:\n{existing_memory or '(none)'}\n\n"
|
||||
"Recent transcript:\n"
|
||||
+ "\n\n".join(lines)
|
||||
+ "\n\nReturn only the updated continuity notes."
|
||||
)},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=1200,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
||||
updated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", updated, flags=re.I | re.S).strip()
|
||||
if len(updated) > 6000:
|
||||
updated = updated[:6000].rstrip()
|
||||
if updated == existing_memory:
|
||||
return
|
||||
if preset_manager.update_persona_memory(character_name, updated):
|
||||
logger.info("Updated persona memory for %s", character_name)
|
||||
except Exception as e:
|
||||
logger.warning("Persona memory update failed: %s", e)
|
||||
|
||||
|
||||
async def audit_memories(
|
||||
memory_manager,
|
||||
memory_vector,
|
||||
@@ -503,24 +763,38 @@ async def audit_memories(
|
||||
|
||||
# Merge audited entries back with other users' entries
|
||||
if owner:
|
||||
all_entries = memory_manager.load_all()
|
||||
# Strict load: the merge below reconstructs the whole file. If this
|
||||
# degraded to [] we would save only this owner's audited slice and
|
||||
# destroy every other tenant's memories.
|
||||
try:
|
||||
all_entries = memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Aborting memory audit save, store unreadable: %s", e)
|
||||
return {
|
||||
"before": before_count,
|
||||
"after": before_count,
|
||||
"error": "store_unreadable",
|
||||
}
|
||||
audited_ids = {e["id"] for e in final_entries}
|
||||
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
||||
# Also keep legacy entries that weren't part of this audit
|
||||
for e in all_entries:
|
||||
if e.get("owner") is None and e["id"] not in audited_ids and e["id"] not in {o["id"] for o in other_entries}:
|
||||
other_entries.append(e)
|
||||
memory_manager.save(final_entries + other_entries)
|
||||
saved_entries = final_entries + other_entries
|
||||
else:
|
||||
memory_manager.save(final_entries)
|
||||
saved_entries = final_entries
|
||||
memory_manager.save(saved_entries)
|
||||
logger.info(
|
||||
f"Memory audit complete: {before_count} -> {after_count} entries "
|
||||
f"({before_count - after_count} removed/merged)"
|
||||
)
|
||||
|
||||
# Rebuild vector index
|
||||
# Rebuild vector index from the full saved set, not just this owner's
|
||||
# slice — otherwise the shared collection is wiped of every other
|
||||
# owner's entries until they happen to run their own audit.
|
||||
if memory_vector and memory_vector.healthy:
|
||||
memory_vector.rebuild(final_entries)
|
||||
memory_vector.rebuild(saved_entries)
|
||||
|
||||
# Persist the post-tidy fingerprint so the next call short-circuits
|
||||
# if nothing has changed in the meantime.
|
||||
|
||||
Reference in New Issue
Block a user