2026-03-02 04:30:10 +03:00
|
|
|
import json
|
2026-04-19 15:22:49 +03:00
|
|
|
from datetime import datetime
|
2026-03-02 04:30:10 +03:00
|
|
|
from threading import Lock
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_base_dir() -> Path:
|
|
|
|
|
if getattr(sys, "frozen", False):
|
|
|
|
|
return Path(sys.executable).parent
|
|
|
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
BASE_DIR = get_base_dir()
|
|
|
|
|
MEMORY_PATH = BASE_DIR / "memory" / "long_term.json"
|
|
|
|
|
_lock = Lock()
|
|
|
|
|
MAX_VALUE_LENGTH = 380
|
|
|
|
|
MEMORY_MAX_CHARS = 2200
|
|
|
|
|
|
2026-03-02 04:30:10 +03:00
|
|
|
def _empty_memory() -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"identity": {},
|
|
|
|
|
"preferences": {},
|
2026-04-19 15:22:49 +03:00
|
|
|
"projects": {},
|
2026-03-02 04:30:10 +03:00
|
|
|
"relationships": {},
|
2026-04-19 15:22:49 +03:00
|
|
|
"wishes": {},
|
|
|
|
|
"notes": {},
|
2026-03-02 04:30:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def load_memory() -> dict:
|
|
|
|
|
if not MEMORY_PATH.exists():
|
|
|
|
|
return _empty_memory()
|
|
|
|
|
with _lock:
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(MEMORY_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
if isinstance(data, dict):
|
2026-04-19 15:22:49 +03:00
|
|
|
base = _empty_memory()
|
|
|
|
|
for key in base:
|
|
|
|
|
if key not in data:
|
|
|
|
|
data[key] = {}
|
2026-03-02 04:30:10 +03:00
|
|
|
return data
|
|
|
|
|
return _empty_memory()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[Memory] ⚠️ Load error: {e}")
|
|
|
|
|
return _empty_memory()
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
def _all_entries(memory: dict) -> list[tuple]:
|
|
|
|
|
entries = []
|
|
|
|
|
for cat, items in memory.items():
|
|
|
|
|
if not isinstance(items, dict):
|
|
|
|
|
continue
|
|
|
|
|
for key, entry in items.items():
|
|
|
|
|
if isinstance(entry, dict) and "value" in entry:
|
|
|
|
|
entries.append((cat, key, entry))
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trim_to_limit(memory: dict) -> dict:
|
|
|
|
|
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
|
|
|
|
return memory
|
|
|
|
|
entries = _all_entries(memory)
|
|
|
|
|
entries.sort(key=lambda t: t[2].get("updated", "0000-00-00"))
|
|
|
|
|
for cat, key, _ in entries:
|
|
|
|
|
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
|
|
|
|
break
|
|
|
|
|
del memory[cat][key]
|
|
|
|
|
print(f"[Memory] 🗑️ Trimmed {cat}/{key}")
|
|
|
|
|
return memory
|
|
|
|
|
|
2026-03-02 04:30:10 +03:00
|
|
|
def save_memory(memory: dict) -> None:
|
|
|
|
|
if not isinstance(memory, dict):
|
|
|
|
|
return
|
2026-04-19 15:22:49 +03:00
|
|
|
memory = _trim_to_limit(memory)
|
2026-03-02 04:30:10 +03:00
|
|
|
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with _lock:
|
|
|
|
|
MEMORY_PATH.write_text(
|
|
|
|
|
json.dumps(memory, indent=2, ensure_ascii=False),
|
2026-04-19 15:22:49 +03:00
|
|
|
encoding="utf-8",
|
2026-03-02 04:30:10 +03:00
|
|
|
)
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
|
2026-03-02 04:30:10 +03:00
|
|
|
def _truncate_value(val: str) -> str:
|
|
|
|
|
if isinstance(val, str) and len(val) > MAX_VALUE_LENGTH:
|
|
|
|
|
return val[:MAX_VALUE_LENGTH].rstrip() + "…"
|
|
|
|
|
return val
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recursive_update(target: dict, updates: dict) -> bool:
|
|
|
|
|
changed = False
|
|
|
|
|
for key, value in updates.items():
|
|
|
|
|
if value is None:
|
|
|
|
|
continue
|
|
|
|
|
if isinstance(value, str) and not value.strip():
|
|
|
|
|
continue
|
|
|
|
|
if isinstance(value, dict) and "value" not in value:
|
|
|
|
|
if key not in target or not isinstance(target[key], dict):
|
|
|
|
|
target[key] = {}
|
|
|
|
|
changed = True
|
|
|
|
|
if _recursive_update(target[key], value):
|
|
|
|
|
changed = True
|
|
|
|
|
else:
|
2026-04-19 15:22:49 +03:00
|
|
|
new_val = _truncate_value(str(value["value"] if isinstance(value, dict) else value))
|
|
|
|
|
entry = {"value": new_val, "updated": datetime.now().strftime("%Y-%m-%d")}
|
|
|
|
|
existing = target.get(key, {})
|
|
|
|
|
if not isinstance(existing, dict) or existing.get("value") != new_val:
|
2026-03-02 04:30:10 +03:00
|
|
|
target[key] = entry
|
|
|
|
|
changed = True
|
|
|
|
|
return changed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_memory(memory_update: dict) -> dict:
|
|
|
|
|
if not isinstance(memory_update, dict) or not memory_update:
|
|
|
|
|
return load_memory()
|
|
|
|
|
memory = load_memory()
|
|
|
|
|
if _recursive_update(memory, memory_update):
|
|
|
|
|
save_memory(memory)
|
|
|
|
|
print(f"[Memory] 💾 Saved: {list(memory_update.keys())}")
|
|
|
|
|
return memory
|
|
|
|
|
|
|
|
|
|
def format_memory_for_prompt(memory: dict | None) -> str:
|
|
|
|
|
if not memory:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
identity = memory.get("identity", {})
|
|
|
|
|
id_fields = ["name", "age", "birthday", "city", "job", "language", "school", "nationality"]
|
|
|
|
|
for field in id_fields:
|
|
|
|
|
entry = identity.get(field)
|
|
|
|
|
if entry:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f"{field.title()}: {val}")
|
|
|
|
|
for key, entry in identity.items():
|
|
|
|
|
if key in id_fields:
|
|
|
|
|
continue
|
2026-03-02 04:30:10 +03:00
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f"{key.replace('_', ' ').title()}: {val}")
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
prefs = memory.get("preferences", {})
|
|
|
|
|
if prefs:
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("Preferences:")
|
|
|
|
|
for key, entry in list(prefs.items())[:15]:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f" - {key.replace('_', ' ').title()}: {val}")
|
|
|
|
|
|
|
|
|
|
projects = memory.get("projects", {})
|
|
|
|
|
if projects:
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("Active Projects / Goals:")
|
|
|
|
|
for key, entry in list(projects.items())[:8]:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f" - {key.replace('_', ' ').title()}: {val}")
|
|
|
|
|
|
2026-03-02 04:30:10 +03:00
|
|
|
rels = memory.get("relationships", {})
|
2026-04-19 15:22:49 +03:00
|
|
|
if rels:
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("People in their life:")
|
|
|
|
|
for key, entry in list(rels.items())[:10]:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f" - {key.replace('_', ' ').title()}: {val}")
|
|
|
|
|
|
|
|
|
|
wishes = memory.get("wishes", {})
|
|
|
|
|
if wishes:
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("Wishes / Plans / Wants:")
|
|
|
|
|
for key, entry in list(wishes.items())[:8]:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f" - {key.replace('_', ' ').title()}: {val}")
|
2026-03-02 04:30:10 +03:00
|
|
|
|
|
|
|
|
notes = memory.get("notes", {})
|
2026-04-19 15:22:49 +03:00
|
|
|
if notes:
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("Other notes:")
|
|
|
|
|
for key, entry in list(notes.items())[:8]:
|
|
|
|
|
val = entry.get("value") if isinstance(entry, dict) else entry
|
|
|
|
|
if val:
|
|
|
|
|
lines.append(f" - {key}: {val}")
|
2026-03-02 04:30:10 +03:00
|
|
|
|
|
|
|
|
if not lines:
|
|
|
|
|
return ""
|
|
|
|
|
|
2026-04-19 15:22:49 +03:00
|
|
|
header = "[WHAT YOU KNOW ABOUT THIS PERSON — use naturally, never recite like a list]\n"
|
|
|
|
|
result = header + "\n".join(lines)
|
|
|
|
|
if len(result) > 2000:
|
|
|
|
|
result = result[:1997] + "…"
|
|
|
|
|
|
|
|
|
|
return result + "\n"
|
|
|
|
|
|
|
|
|
|
def remember(key: str, value: str, category: str = "notes") -> str:
|
|
|
|
|
valid = {"identity", "preferences", "projects", "relationships", "wishes", "notes"}
|
|
|
|
|
if category not in valid:
|
|
|
|
|
category = "notes"
|
|
|
|
|
update_memory({category: {key: {"value": value}}})
|
|
|
|
|
return f"Remembered: {category}/{key} = {value}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def forget(key: str, category: str = "notes") -> str:
|
|
|
|
|
memory = load_memory()
|
|
|
|
|
cat = memory.get(category, {})
|
|
|
|
|
if key in cat:
|
|
|
|
|
del cat[key]
|
|
|
|
|
memory[category] = cat
|
|
|
|
|
save_memory(memory)
|
|
|
|
|
return f"Forgotten: {category}/{key}"
|
|
|
|
|
return f"Not found: {category}/{key}"
|
|
|
|
|
|
2026-03-02 04:30:10 +03:00
|
|
|
|
2026-07-23 16:57:15 +03:00
|
|
|
forget_memory = forget
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Session memory ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
_SESSION_MAX = 3 # safety cap — in practice 0-1 entries after pop
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_session_summary(summary: str, language: str = "") -> None:
|
|
|
|
|
"""Append a 1-2 sentence session summary to long_term.json['sessions']."""
|
|
|
|
|
summary = (summary or "").strip()
|
|
|
|
|
if not summary:
|
|
|
|
|
return
|
|
|
|
|
memory = load_memory()
|
|
|
|
|
sessions = memory.get("sessions", [])
|
|
|
|
|
if not isinstance(sessions, list):
|
|
|
|
|
sessions = []
|
|
|
|
|
entry: dict = {
|
|
|
|
|
"date": datetime.now().strftime("%Y-%m-%d"),
|
|
|
|
|
"summary": summary[:280],
|
|
|
|
|
}
|
|
|
|
|
if language:
|
|
|
|
|
entry["language"] = language
|
|
|
|
|
sessions.append(entry)
|
|
|
|
|
memory["sessions"] = sessions[-_SESSION_MAX:]
|
|
|
|
|
with _lock:
|
|
|
|
|
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
MEMORY_PATH.write_text(
|
|
|
|
|
json.dumps(memory, indent=2, ensure_ascii=False),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
print(f"[Memory] 📝 Session saved ({entry['date']}): {summary[:60]}…")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pop_last_session() -> dict | None:
|
|
|
|
|
"""
|
|
|
|
|
Return AND remove the most recent session entry.
|
|
|
|
|
Calling this consumes the entry so it is never repeated in future briefings.
|
|
|
|
|
"""
|
|
|
|
|
with _lock:
|
|
|
|
|
if not MEMORY_PATH.exists():
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
memory = json.loads(MEMORY_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
sessions = memory.get("sessions", [])
|
|
|
|
|
if not isinstance(sessions, list) or not sessions:
|
|
|
|
|
return None
|
|
|
|
|
entry = sessions.pop() # remove the last entry
|
|
|
|
|
memory["sessions"] = sessions
|
|
|
|
|
MEMORY_PATH.write_text(
|
|
|
|
|
json.dumps(memory, indent=2, ensure_ascii=False),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
return entry
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[Memory] ⚠️ pop_last_session error: {e}")
|
|
|
|
|
return None
|