lifeos / memory.py
awaisaziz's picture
Add config, model status, and VLM support
0c4cd3b
Raw
History Blame Contribute Delete
13.7 kB
"""Short-term structured memory for LifeOS.
One shared JSON store at data/memory.json:
meals: [{date, dish, ingredients[]}]
workouts: [{date, type, duration_min}]
finances: {income_monthly, subscriptions: [{name, cost, last_used}]}
user_profile: {name, dietary_prefs, fitness_goal, budget_weekly}
A real install starts blank (empty_data). Set LIFEOS_DEMO=1 to instead seed a
sample persona whose dates are generated relative to "today" so the demo never
goes stale. memory.json is gitignored and created on first import / app start.
"""
import json
import os
import threading
import uuid
from datetime import date, timedelta
import config
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
MEMORY_PATH = os.path.join(DATA_DIR, "memory.json")
_lock = threading.Lock()
def _d(days_ago: int) -> str:
return (date.today() - timedelta(days=days_ago)).isoformat()
def _new_id() -> str:
return uuid.uuid4().hex[:8]
def _week_day(weekday: int) -> str:
"""Date of the given weekday (0=Mon) in the current week."""
today = date.today()
return (today + timedelta(days=weekday - today.weekday())).isoformat()
def _months_out(months: int) -> str:
"""YYYY-MM roughly `months` months from today."""
y, m = date.today().year, date.today().month + months
y += (m - 1) // 12
m = (m - 1) % 12 + 1
return f"{y:04d}-{m:02d}"
def empty_data() -> dict:
"""A blank store for a brand-new user. Same schema as seed_data() but with
no personal history — the user fills it in via the Profile/Memory UI."""
return {
"meals": [],
"workouts": [],
"calendar": [],
"workout_schedule": {"days": [], "time": "07:00"},
"goals": [],
"finances": {
"income_monthly": 0,
"monthly_payments": [],
"subscriptions": [],
},
"user_profile": {
"first_name": "",
"last_name": "",
"name": "",
"dietary_prefs": [],
"fitness_goal": "",
"budget_weekly": 0,
"address": "",
"city": "",
"postal_code": "",
"country": "",
},
}
def first_run_data() -> dict:
"""What a fresh install starts with: demo persona if LIFEOS_DEMO, else blank."""
return seed_data() if config.DEMO else empty_data()
def seed_data() -> dict:
"""Demo persona: Awais, a busy student on a tight budget."""
return {
"meals": [
{"date": _d(7), "dish": "Chicken karahi", "ingredients": ["chicken thighs", "tomatoes", "ginger", "green chili", "yogurt"]},
{"date": _d(6), "dish": "Spaghetti aglio e olio", "ingredients": ["spaghetti", "garlic", "olive oil", "chili flakes"]},
{"date": _d(5), "dish": "Egg fried rice", "ingredients": ["rice", "eggs", "frozen peas", "soy sauce", "green onion"]},
{"date": _d(4), "dish": "Spaghetti bolognese", "ingredients": ["spaghetti", "ground beef", "tomato sauce", "onion"]},
{"date": _d(3), "dish": "Chickpea curry", "ingredients": ["chickpeas", "coconut milk", "tomatoes", "curry powder", "rice"]},
{"date": _d(2), "dish": "Grilled cheese and tomato soup", "ingredients": ["bread", "cheddar", "canned tomato soup"]},
{"date": _d(1), "dish": "Chicken caesar wrap", "ingredients": ["chicken breast", "tortilla", "romaine", "caesar dressing", "parmesan"]},
],
"workouts": [
{"date": _d(6), "type": "run", "duration_min": 30},
{"date": _d(5), "type": "push (chest/triceps)", "duration_min": 50},
{"date": _d(3), "type": "pull (back/biceps)", "duration_min": 45},
{"date": _d(2), "type": "run", "duration_min": 25},
{"date": _d(1), "type": "legs", "duration_min": 55},
],
"calendar": [
{"id": _new_id(), "title": "CS lecture: Distributed Systems", "date": _week_day(0), "start": "10:00", "end": "12:00", "kind": "class"},
{"id": _new_id(), "title": "Cafe shift", "date": _week_day(1), "start": "16:00", "end": "21:00", "kind": "work"},
{"id": _new_id(), "title": "Stats tutorial", "date": _week_day(2), "start": "09:00", "end": "10:30", "kind": "class"},
{"id": _new_id(), "title": "Cafe shift", "date": _week_day(5), "start": "08:00", "end": "14:00", "kind": "work"},
],
"workout_schedule": {"days": ["mon", "wed", "sat"], "time": "07:00"},
"goals": [
{
"id": _new_id(),
"title": "Used car",
"target_amount": 8000,
"saved": 1200,
"deadline": _months_out(14),
"notes": "",
}
],
"finances": {
"income_monthly": 1400,
"monthly_payments": [
{"name": "Rent (shared)", "amount": 650, "due_day": 1},
{"name": "Phone plan", "amount": 45, "due_day": 15},
{"name": "Transit pass", "amount": 128, "due_day": 1},
],
"subscriptions": [
{"name": "Netflix", "cost": 16.99, "last_used": _d(2)},
{"name": "Spotify Premium", "cost": 11.99, "last_used": _d(0)},
{"name": "Crunchyroll", "cost": 11.99, "last_used": _d(54)},
{"name": "FitnessPal Pro", "cost": 19.99, "last_used": _d(88)},
{"name": "Disney+", "cost": 13.99, "last_used": _d(41)},
{"name": "iCloud 200GB", "cost": 3.99, "last_used": _d(0)},
{"name": "Adobe CC Student", "cost": 22.99, "last_used": _d(9)},
],
},
"user_profile": {
"first_name": "Awais",
"last_name": "Aziz",
"name": "Awais Aziz",
"dietary_prefs": ["halal", "high-protein", "no pork"],
"fitness_goal": "build muscle, run a 10K in September",
"budget_weekly": 80,
"address": "",
"city": "Toronto",
"postal_code": "",
"country": "Canada",
},
}
def load() -> dict:
with _lock:
first_run = not os.path.exists(MEMORY_PATH)
if first_run:
mem = first_run_data()
os.makedirs(DATA_DIR, exist_ok=True)
with open(MEMORY_PATH, "w", encoding="utf-8") as f:
json.dump(mem, f, indent=2, ensure_ascii=False)
return mem
with open(MEMORY_PATH, "r", encoding="utf-8") as f:
mem = json.load(f)
# Backfill keys added by schema upgrades so old memory.json files keep
# working. Defaults come from empty_data() — never inject demo persona data
# into a real user's store.
blank = empty_data()
changed = False
for key in ("calendar", "workout_schedule", "goals"):
if key not in mem:
mem[key] = blank[key]
changed = True
if "monthly_payments" not in mem.get("finances", {}):
mem.setdefault("finances", {})["monthly_payments"] = blank["finances"]["monthly_payments"]
changed = True
profile = mem.setdefault("user_profile", {})
if "first_name" not in profile:
# Upgrade legacy single-`name` profiles: first word -> first_name.
parts = (profile.get("name") or "").split(" ", 1)
profile["first_name"] = parts[0]
profile.setdefault("last_name", parts[1] if len(parts) > 1 else "")
changed = True
profile.setdefault("last_name", "")
for key in ("address", "city", "postal_code", "country"):
if key not in profile:
profile[key] = blank["user_profile"][key]
changed = True
full_name = f"{profile['first_name']} {profile['last_name']}".strip()
if profile.get("name") != full_name:
profile["name"] = full_name
changed = True
for g in mem.get("goals", []):
if not g.get("id"):
g["id"] = _new_id()
changed = True
if changed:
save(mem)
return mem
def save(mem: dict) -> None:
with _lock:
os.makedirs(DATA_DIR, exist_ok=True)
with open(MEMORY_PATH, "w", encoding="utf-8") as f:
json.dump(mem, f, indent=2, ensure_ascii=False)
def _write(mem: dict) -> dict:
"""Persist a full store to disk and return it (used by the reset helpers).
Caller must not hold _lock — save() takes it."""
save(mem)
return mem
def reset_to_empty() -> dict:
"""Wipe the store back to a blank slate (real-user reset)."""
return _write(empty_data())
def reset_to_seed() -> dict:
"""Reset the store to the demo persona (used by demo mode / reset button)."""
return _write(seed_data())
def log_meal(dish: str, ingredients: list[str], when: str | None = None) -> dict:
mem = load()
mem["meals"].append({"date": when or date.today().isoformat(), "dish": dish, "ingredients": ingredients})
save(mem)
return mem
def log_workout(workout_type: str, duration_min: int, when: str | None = None) -> dict:
mem = load()
mem["workouts"].append({"date": when or date.today().isoformat(), "type": workout_type, "duration_min": int(duration_min)})
save(mem)
return mem
def set_subscriptions(subs: list[dict]) -> dict:
"""Replace detected subscriptions (from the Money feature)."""
mem = load()
mem["finances"]["subscriptions"] = subs
save(mem)
return mem
def add_event(ev: dict) -> dict:
"""Add a calendar event. Fills in an id if missing. Returns full mem."""
mem = load()
event = {
"id": ev.get("id") or _new_id(),
"title": ev.get("title", ""),
"date": ev.get("date", date.today().isoformat()),
"start": ev.get("start", "09:00"),
"end": ev.get("end", "10:00"),
"kind": ev.get("kind", "other"),
}
mem.setdefault("calendar", []).append(event)
save(mem)
return mem
def delete_event(event_id: str) -> dict:
mem = load()
mem["calendar"] = [e for e in mem.get("calendar", []) if e.get("id") != event_id]
save(mem)
return mem
def set_workout_schedule(days: list[str], time: str) -> dict:
mem = load()
mem["workout_schedule"] = {"days": [d.lower()[:3] for d in days], "time": time}
save(mem)
return mem
def set_monthly_payments(payments: list[dict]) -> dict:
mem = load()
mem.setdefault("finances", {})["monthly_payments"] = [
{"name": p.get("name", ""), "amount": p.get("amount", 0), "due_day": int(p.get("due_day", 1))}
for p in payments
]
save(mem)
return mem
def upsert_goal(goal: dict) -> dict:
"""Insert or update a goal by id (new id assigned when missing/unknown)."""
mem = load()
goals = mem.setdefault("goals", [])
gid = goal.get("id")
merged = {
"id": gid or _new_id(),
"title": goal.get("title", ""),
"target_amount": goal.get("target_amount", 0),
"saved": goal.get("saved", 0),
"deadline": goal.get("deadline", _months_out(12)),
"notes": goal.get("notes", ""),
}
for i, g in enumerate(goals):
if gid and g.get("id") == gid:
goals[i] = {**g, **{k: v for k, v in goal.items() if v is not None}}
break
else:
goals.append(merged)
save(mem)
return mem
def set_profile(fields: dict) -> dict:
"""Merge fields into user_profile; income_monthly routes to finances."""
mem = load()
fields = dict(fields)
if "income_monthly" in fields:
mem.setdefault("finances", {})["income_monthly"] = fields.pop("income_monthly")
profile = mem.setdefault("user_profile", {})
profile.update(fields)
profile["name"] = f"{profile.get('first_name', '')} {profile.get('last_name', '')}".strip() or profile.get("name", "")
save(mem)
return mem
def delete_goal(goal_id: str) -> dict:
mem = load()
mem["goals"] = [g for g in mem.get("goals", []) if g.get("id") != goal_id]
save(mem)
return mem
# Sections the frontend may replace wholesale; value = path into mem.
_EDITABLE_SECTIONS = {
"meals": ("meals",),
"workouts": ("workouts",),
"calendar": ("calendar",),
"subscriptions": ("finances", "subscriptions"),
"monthly_payments": ("finances", "monthly_payments"),
}
def set_section(section: str, items: list) -> dict:
"""Replace a whitelisted list section wholesale (manual memory editing)."""
path = _EDITABLE_SECTIONS.get(section)
if path is None:
raise ValueError(f"unknown section: {section!r}")
if not isinstance(items, list):
raise ValueError("items must be a list")
mem = load()
target = mem
for key in path[:-1]:
target = target.setdefault(key, {})
target[path[-1]] = items
save(mem)
return mem
def events_in_window(days: int = 7, mem: dict | None = None) -> list[dict]:
"""Calendar events from today through today+days, sorted."""
mem = mem or load()
start = date.today().isoformat()
end = (date.today() + timedelta(days=days)).isoformat()
return sorted(
(e for e in mem.get("calendar", []) if start <= e.get("date", "") <= end),
key=lambda e: (e.get("date", ""), e.get("start", "")),
)
def recent_meals(days: int = 7, mem: dict | None = None) -> list[dict]:
mem = mem or load()
cutoff = (date.today() - timedelta(days=days)).isoformat()
return [m for m in mem["meals"] if m["date"] >= cutoff]
def workouts_in_window(days: int = 14, mem: dict | None = None) -> list[dict]:
mem = mem or load()
cutoff = (date.today() - timedelta(days=days)).isoformat()
return sorted((w for w in mem["workouts"] if w["date"] >= cutoff), key=lambda w: w["date"])