Files
mop-skills/skills/datastore/scripts/datastore.py
T

1800 lines
78 KiB
Python
Executable File

#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "python-ulid>=3.0.0",
# "pytz>=2024.1",
# ]
# ///
"""
OpenClaw Datastore — Structured Personal Data Layer (DuckDB + JSONL in Git)
Provides deterministic math, validation, interval handling, spatial queries,
and JSONL file persistence for personal nutrition, baby sleep, sports, recipes, and places.
Exposes both a CLI and a stdio JSON-RPC MCP server.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo
import duckdb
import urllib.request
import urllib.error
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
logger = logging.getLogger("datastore")
def generate_ulid() -> str:
try:
from ulid import ULID
return str(ULID())
except TypeError:
import ulid
return str(ulid.new())
except ImportError:
import uuid
return str(uuid.uuid4())
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def parse_duration(val: str) -> datetime.timedelta:
"""Parses duration strings like '15m', '15 min', '1h', '1h30m', '1.5h' into timedelta."""
s = val.strip().lower()
if not s:
raise ValueError("Empty duration string")
# Decimal hours like "1.5h" or "1.5 stunden"
m_dec_h = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:h|stunden?)$", s)
if m_dec_h:
return datetime.timedelta(hours=float(m_dec_h.group(1)))
# Decimal minutes like "2.5m" or "15 min"
m_dec_m = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:m|min|minuten?)$", s)
if m_dec_m:
return datetime.timedelta(minutes=float(m_dec_m.group(1)))
# Composite like "1h 30m" or "1h30min" or "45s"
m_comp = re.fullmatch(
r"^(?:(\d+)\s*(?:h|stunden?))?\s*(?:(\d+)\s*(?:m|min|minuten?))?\s*(?:(\d+)\s*(?:s|sek|sekunden?))?$",
s,
)
if m_comp and any(m_comp.groups()):
h = int(m_comp.group(1) or 0)
m = int(m_comp.group(2) or 0)
sec = int(m_comp.group(3) or 0)
return datetime.timedelta(hours=h, minutes=m, seconds=sec)
# Pure number without unit: default to minutes
if s.isdigit():
return datetime.timedelta(minutes=int(s))
raise ValueError(f"Unrecognized duration format: '{val}'. Expected formats: '15m', '15 min', '1h', '1h30m'.")
def find_data_dir() -> Path:
"""Discovers the knowledge-wiki data directory."""
for env_var in ["DATASTORE_DIR", "OPENCLAW_DATA_DIR"]:
if os.environ.get(env_var):
return Path(os.environ[env_var]).resolve()
candidates = [
Path.cwd() / "knowledge-wiki" / "data",
Path.cwd() / "data",
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki" / "data",
Path.home() / ".openclaw" / "wiki" / "main" / "data",
Path("/home/node/.openclaw/wiki/main/data"),
]
for c in candidates:
if c.is_dir() and (c / "_meta.json").exists():
return c.resolve()
# Fallback to creating a local data directory if needed
default_dir = Path.cwd() / "knowledge-wiki" / "data"
default_dir.mkdir(parents=True, exist_ok=True)
return default_dir.resolve()
class DatastoreEngine:
def __init__(self, data_dir: Optional[Path] = None, backend_url: Optional[str] = None):
self.data_dir = data_dir or find_data_dir()
self.backend_url = (backend_url or os.environ.get(
"DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1"
)).rstrip("/")
self.use_backend = False
if os.environ.get("DUCKDB_FORCE_LOCAL") != "1":
try:
base = self.backend_url.replace("/duckdb-api/v1", "")
test_url = f"{base}/healthz"
req = urllib.request.Request(test_url, method="GET")
with urllib.request.urlopen(req, timeout=1.0) as r:
if r.status == 200:
self.use_backend = True
logger.info(f"DatastoreEngine using single-writer backend at {self.backend_url}")
except Exception:
self.use_backend = False
if not self.use_backend:
self.con = duckdb.connect(":memory:")
self.has_spatial_extension = False
self._init_schemas()
self._load_all_data()
def _http_post(self, url: str, data: Dict[str, Any]) -> Any:
req = urllib.request.Request(
url,
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
try:
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = err_body
raise RuntimeError(f"Backend HTTP {e.code}: {detail}")
def _http_get(self, url: str) -> Any:
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
try:
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = err_body
raise RuntimeError(f"Backend HTTP {e.code}: {detail}")
def _http_delete(self, url: str) -> Any:
req = urllib.request.Request(url, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
try:
detail = json.loads(err_body).get("detail", err_body)
except Exception:
detail = err_body
raise RuntimeError(f"Backend HTTP {e.code}: {detail}")
def _init_schemas(self):
"""Initializes tables, views, and spatial support in DuckDB."""
# Timezone support via icu extension
try:
self.con.execute("INSTALL icu; LOAD icu;")
self.con.execute("SET TimeZone = 'Europe/Berlin';")
except Exception:
pass
# Spatial support: try extension, fallback to pure-SQL Haversine macro
try:
self.con.execute("INSTALL spatial; LOAD spatial;")
self.has_spatial_extension = True
except Exception:
self.has_spatial_extension = False
# Fallback pure-SQL distance macro (returns meters)
self.con.execute("""
CREATE MACRO IF NOT EXISTS distance_m(lat1, lon1, lat2, lon2) AS
6371000 * 2 * asin(sqrt(
sin(radians(lat2 - lat1) / 2) ^ 2 +
cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lon2 - lon1) / 2) ^ 2
));
""")
schema_dir = self.data_dir / "_schema"
if schema_dir.is_dir():
for f in ["envelope.sql", "entities.sql", "views.sql"]:
sql_file = schema_dir / f
if sql_file.is_file():
sql = sql_file.read_text(encoding="utf-8")
self.con.execute(sql)
else:
# Inline fallback schema if _schema is missing
self._create_inline_schema()
def _create_inline_schema(self):
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
if not schema_src.is_dir():
schema_src = Path("/home/node/.openclaw/wiki/main/data/_schema")
if schema_src.is_dir():
for f in ["envelope.sql", "entities.sql", "views.sql"]:
sql_file = schema_src / f
if sql_file.is_file():
self.con.execute(sql_file.read_text(encoding="utf-8"))
return
self.con.execute("""
CREATE TABLE IF NOT EXISTS sleep (
id VARCHAR PRIMARY KEY,
subject VARCHAR NOT NULL,
calendarDate DATE NOT NULL,
sleepType VARCHAR NOT NULL,
location VARCHAR,
isOpen BOOLEAN DEFAULT false,
startIso TIMESTAMPTZ NOT NULL,
endIso TIMESTAMPTZ,
sleepStartTimestampGMT BIGINT,
sleepEndTimestampGMT BIGINT,
sleepStartTimestampLocal BIGINT,
sleepEndTimestampLocal BIGINT,
sleepTimeSeconds INTEGER,
napTimeSeconds INTEGER,
deepSleepSeconds INTEGER,
lightSleepSeconds INTEGER,
remSleepSeconds INTEGER,
awakeSleepSeconds INTEGER,
awakeCount INTEGER,
avgSleepStress DOUBLE,
avgHeartRate DOUBLE,
averageSpO2Value DOUBLE,
lowestSpO2Value INTEGER,
highestSpO2Value INTEGER,
averageRespirationValue DOUBLE,
sleepScores JSON,
avgOvernightHrv DOUBLE,
hrvStatus VARCHAR,
bodyBatteryChange INTEGER,
restingHeartRate INTEGER,
restlessMomentsCount INTEGER,
note VARCHAR,
actor VARCHAR NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS nutrition (
id VARCHAR PRIMARY KEY,
kind VARCHAR NOT NULL,
subject VARCHAR NOT NULL,
actor VARCHAR NOT NULL,
start TIMESTAMPTZ NOT NULL,
"end" TIMESTAMPTZ,
estimated BOOLEAN DEFAULT false,
meal VARCHAR,
note VARCHAR,
payload JSON NOT NULL,
source_ref VARCHAR,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
CHECK ("end" IS NULL OR "end" >= start)
);
CREATE TABLE IF NOT EXISTS mila (
id VARCHAR PRIMARY KEY,
kind VARCHAR NOT NULL,
subject VARCHAR NOT NULL DEFAULT 'mila',
actor VARCHAR NOT NULL,
start TIMESTAMPTZ NOT NULL,
"end" TIMESTAMPTZ,
note VARCHAR,
payload JSON NOT NULL,
source_ref VARCHAR,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE OR REPLACE VIEW events AS
SELECT id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at FROM nutrition
UNION ALL
SELECT id, kind, subject, actor, start, "end", false AS estimated, NULL AS meal, note, payload, source_ref, created_at, updated_at, deleted_at FROM mila
UNION ALL
SELECT id, 'sleep.session' AS kind, subject, actor, startIso AS start, endIso AS "end", false AS estimated, NULL AS meal, note,
to_json(struct_pack(type := sleepType, location := location, duration_minutes := COALESCE(ROUND(sleepTimeSeconds / 60.0), 0))) AS payload,
NULL AS source_ref, created_at, updated_at, deleted_at FROM sleep;
CREATE TABLE IF NOT EXISTS people (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
role VARCHAR,
birthdate DATE,
notes VARCHAR
);
CREATE TABLE IF NOT EXISTS foods (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
brand VARCHAR,
category VARCHAR,
per_100 JSON,
density_g_per_ml DOUBLE,
ingredients_text VARCHAR,
barcode VARCHAR,
source VARCHAR,
description VARCHAR
);
CREATE TABLE IF NOT EXISTS recipes (
id VARCHAR PRIMARY KEY,
title VARCHAR NOT NULL,
servings INTEGER DEFAULT 1,
ingredients JSON,
estimated_delta JSON,
devices VARCHAR[],
tags VARCHAR[],
documents VARCHAR[],
description VARCHAR
);
CREATE TABLE IF NOT EXISTS places (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
category VARCHAR NOT NULL,
lat DOUBLE NOT NULL,
lon DOUBLE NOT NULL,
address VARCHAR,
google_maps_url VARCHAR,
tags VARCHAR[],
attributes JSON,
documents VARCHAR[],
notes VARCHAR
);
CREATE TABLE IF NOT EXISTS garmin_daily (
calendarDate DATE PRIMARY KEY,
totalKilocalories INTEGER,
activeKilocalories INTEGER,
bmrKilocalories INTEGER,
totalSteps INTEGER,
restingHeartRate INTEGER,
raw JSON
);
""")
def _get_target_db(self, kind: str) -> str:
"""Maps an event kind to one of the 3 target databases: 'sleep', 'nutrition', or 'mila'."""
if kind.startswith("sleep."):
return "sleep"
elif kind.startswith("diaper.") or kind.startswith("growth."):
return "mila"
else:
return "nutrition"
def _load_all_data(self):
"""Loads all existing JSONL files from entities/, sleep/, nutrition/, mila/, and garmin/ into DuckDB."""
# 1. Load entities
entities_dir = self.data_dir / "entities"
if entities_dir.is_dir():
for f in entities_dir.glob("*.jsonl"):
collection = f.stem
if collection in ["people", "foods", "recipes", "places"]:
self._load_jsonl_file(f, collection)
# 2. Load the 3 core databases: sleep, nutrition, mila
for db_name in ["sleep", "nutrition", "mila"]:
db_dir = self.data_dir / db_name
if db_dir.is_dir():
for f in db_dir.glob("*.jsonl"):
self._load_jsonl_file(f, db_name)
# 3. Fallback for legacy events/ if present
events_dir = self.data_dir / "events"
if events_dir.is_dir():
for f in events_dir.glob("**/*.jsonl"):
domain = f.parent.name
if domain == "sleep":
self._load_legacy_sleep_file(f)
elif domain in ("diaper", "growth"):
self._load_jsonl_file(f, "mila")
else:
self._load_jsonl_file(f, "nutrition")
# 4. Load garmin daily
garmin_daily_dir = self.data_dir / "garmin" / "daily"
if garmin_daily_dir.is_dir():
for f in garmin_daily_dir.glob("*.jsonl"):
self._load_garmin_daily_file(f)
def _load_legacy_sleep_file(self, file_path: Path):
"""Converts legacy events/sleep/*.jsonl records into the unified sleep table."""
if not file_path.is_file() or file_path.stat().st_size == 0:
return
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
start_str = r.get("start")
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00")).astimezone(BERLIN_TZ)
cal_date = start_dt.strftime("%Y-%m-%d")
end_str = r.get("end")
end_dt = datetime.datetime.fromisoformat(end_str.replace("Z", "+00:00")).astimezone(BERLIN_TZ) if end_str else None
dur_sec = int((end_dt - start_dt).total_seconds()) if end_dt else None
payload = r.get("payload") or {}
stype = payload.get("type") or "nap"
loc = payload.get("location") or "bett"
self.con.execute(
"""
INSERT OR REPLACE INTO sleep (
id, subject, calendarDate, sleepType, location, isOpen,
startIso, endIso, sleepTimeSeconds, napTimeSeconds,
note, actor, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
r["id"],
r.get("subject", "mila"),
cal_date,
stype,
loc,
end_str is None,
start_str,
end_str,
dur_sec,
dur_sec if stype == "nap" else 0,
r.get("note"),
r.get("actor", "agent:hebamme"),
r.get("created_at") or start_str,
r.get("updated_at"),
r.get("deleted_at"),
],
)
except Exception as e:
logger.warning(f"Error loading legacy sleep from {file_path}: {e}")
def _load_garmin_daily_file(self, file_path: Path):
"""Reads daily summaries from JSONL into garmin_daily table."""
if not file_path.is_file() or file_path.stat().st_size == 0:
return
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
cal_date = record.get("calendarDate")
if not cal_date:
continue
total_kcal = record.get("totalKilocalories")
active_kcal = record.get("activeKilocalories")
bmr_kcal = record.get("bmrKilocalories")
steps = record.get("totalSteps")
rhr = record.get("restingHeartRate")
raw = json.dumps(record, ensure_ascii=False)
self.con.execute(
"""
INSERT OR REPLACE INTO garmin_daily
(calendarDate, totalKilocalories, activeKilocalories, bmrKilocalories, totalSteps, restingHeartRate, raw)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[cal_date, total_kcal, active_kcal, bmr_kcal, steps, rhr, raw],
)
except Exception as e:
logger.warning(f"Error loading garmin record from {file_path}: {e}")
def _trigger_website_update(self, subject: str):
"""No-op: legacy HTML generator retired in favor of Vue 3 apps."""
pass
def _git_sync(self, commit_msg: str):
"""Safely commits and pushes data changes if data_dir is inside a git repository."""
try:
repo_dir = self.data_dir.parent if (self.data_dir.parent / ".git").is_dir() else self.data_dir
if not (repo_dir / ".git").is_dir():
return
subprocess.run(["git", "-C", str(repo_dir), "add", str(self.data_dir)], check=False, capture_output=True)
res = subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", f"{commit_msg} [skip ci]"], check=False, capture_output=True)
if res.returncode == 0:
subprocess.run(["git", "-C", str(repo_dir), "push", "origin", "main"], check=False, capture_output=True, timeout=10)
except Exception as e:
logger.debug(f"Git auto-sync skipped/failed: {e}")
def _load_jsonl_file(self, file_path: Path, table_name: str):
"""Reads lines from JSONL and inserts into target table."""
if not file_path.is_file() or file_path.stat().st_size == 0:
return
valid_cols_map = {
"nutrition": {"id", "kind", "subject", "actor", "start", "end", "estimated", "meal", "note", "payload", "source_ref", "created_at", "updated_at", "deleted_at"},
"mila": {"id", "kind", "subject", "actor", "start", "end", "note", "payload", "source_ref", "created_at", "updated_at", "deleted_at"},
"sleep": {
"id", "subject", "calendarDate", "sleepType", "location", "isOpen", "startIso", "endIso",
"sleepStartTimestampGMT", "sleepEndTimestampGMT", "sleepStartTimestampLocal", "sleepEndTimestampLocal",
"sleepTimeSeconds", "napTimeSeconds", "deepSleepSeconds", "lightSleepSeconds", "remSleepSeconds",
"awakeSleepSeconds", "awakeCount", "avgSleepStress", "avgHeartRate", "averageSpO2Value",
"lowestSpO2Value", "highestSpO2Value", "averageRespirationValue", "sleepScores", "avgOvernightHrv",
"hrvStatus", "bodyBatteryChange", "restingHeartRate", "restlessMomentsCount", "note", "actor",
"created_at", "updated_at", "deleted_at"
},
}
allowed_cols = valid_cols_map.get(table_name)
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
if table_name in ("nutrition", "mila"):
if "created_at" not in record or not record.get("created_at"):
record["created_at"] = record.get("start") or datetime.datetime.now(datetime.timezone.utc).isoformat()
if table_name == "nutrition" and "estimated" not in record:
record["estimated"] = False
if "deleted_at" not in record:
record["deleted_at"] = None
elif table_name == "sleep":
if "created_at" not in record or not record.get("created_at"):
record["created_at"] = record.get("startIso") or datetime.datetime.now(datetime.timezone.utc).isoformat()
if "deleted_at" not in record:
record["deleted_at"] = None
if allowed_cols:
columns = [c for c in record.keys() if c in allowed_cols]
else:
columns = list(record.keys())
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join([f'"{c}"' for c in columns])
values = [
json.dumps(record[c]) if isinstance(record[c], (dict, list)) else record[c]
for c in columns
]
self.con.execute(
f'INSERT OR REPLACE INTO "{table_name}" ({col_names}) VALUES ({placeholders})',
values,
)
except Exception as e:
logger.warning(f"Error loading {table_name} record from {file_path}: {e}")
def _matches_payload(self, kind: str, p1: Dict[str, Any], p2: Dict[str, Any]) -> bool:
"""Determines if two event payloads represent the same intake/session."""
if p1 == p2:
return True
if kind.startswith("nutrition.") or kind == "feeding.solid":
food1 = p1.get("food_id")
food2 = p2.get("food_id")
item1 = str(p1.get("item", "")).strip().lower()
item2 = str(p2.get("item", "")).strip().lower()
same_item = (
(food1 and food2 and food1 == food2)
or (item1 and item2 and item1 == item2)
or (food1 and item2 and food1 == item2)
or (food2 and item1 and food2 == item1)
)
if same_item:
qty1 = p1.get("qty") or p1.get("amount_g")
qty2 = p2.get("qty") or p2.get("amount_g")
if qty1 is not None and qty2 is not None:
try:
return abs(float(qty1) - float(qty2)) < 0.01
except (ValueError, TypeError):
return qty1 == qty2
return True
elif kind.startswith("feeding."):
amt1 = p1.get("amount_ml") or p1.get("consumed_ml")
amt2 = p2.get("amount_ml") or p2.get("consumed_ml")
if amt1 is not None and amt2 is not None:
return amt1 == amt2
elif kind.startswith("diaper."):
if p1.get("pee") == p2.get("pee") and p1.get("poo") == p2.get("poo"):
return True
elif kind.startswith("sleep."):
if (p1.get("type") or "nap") == (p2.get("type") or "nap"):
return True
elif kind.startswith("growth."):
w1, w2 = p1.get("weight_kg"), p2.get("weight_kg")
h1, h2 = p1.get("height_cm"), p2.get("height_cm")
match_w = (w1 is not None and w2 is not None and abs(float(w1) - float(w2)) < 0.001)
match_h = (h1 is not None and h2 is not None and abs(float(h1) - float(h2)) < 0.001)
if match_w or match_h:
return True
return False
def _is_duplicate_event(
self,
kind: str,
subject: str,
payload: Dict[str, Any],
start_iso: str,
tolerance_seconds: int = 900,
) -> Optional[Dict[str, Any]]:
"""Checks if a matching event already exists within tolerance_seconds (default 15m)."""
rows = self.con.execute(
"""
SELECT id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at
FROM events
WHERE kind = ?
AND subject = ?
AND deleted_at IS NULL
AND abs(epoch(start) - epoch(?::TIMESTAMPTZ)) <= ?
ORDER BY start DESC
""",
[kind, subject, start_iso, tolerance_seconds],
).fetchall()
for r in rows:
existing_payload = json.loads(r[9]) if isinstance(r[9], str) else (r[9] or {})
if self._matches_payload(kind, payload, existing_payload):
return {
"id": r[0],
"kind": r[1],
"subject": r[2],
"actor": r[3],
"start": r[4].isoformat() if hasattr(r[4], "isoformat") else str(r[4]),
"end": r[5].isoformat() if hasattr(r[5], "isoformat") and r[5] else (str(r[5]) if r[5] else None),
"estimated": r[6],
"meal": r[7],
"note": r[8],
"payload": existing_payload,
"source_ref": r[10],
"created_at": r[11].isoformat() if hasattr(r[11], "isoformat") else str(r[11]),
"updated_at": r[12].isoformat() if hasattr(r[12], "isoformat") and r[12] else (str(r[12]) if r[12] else None),
"deleted_at": None,
}
return None
def _get_open_sleep_sessions(self, subject: str, exclude_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Finds open sleep sessions (isOpen=true or endIso IS NULL) for subject, sorted chronologically."""
rows = self.con.execute(
"""
SELECT id, startIso, sleepType, location, note
FROM sleep
WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL
ORDER BY startIso ASC
""",
[subject],
).fetchall()
results = []
for r in rows:
if exclude_id and r[0] == exclude_id:
continue
results.append({
"id": r[0],
"start": str(r[1]),
"payload": {"type": r[2], "location": r[3]},
"note": r[4],
})
return results
# --------------------------------------------------------------------------
# Event Operations
# --------------------------------------------------------------------------
def add_event(
self,
kind: str,
subject: str,
actor: str,
payload: Dict[str, Any],
start: Optional[str] = None,
end: Optional[str] = None,
since: Optional[str] = None,
estimated: bool = False,
meal: Optional[str] = None,
note: Optional[str] = None,
source_ref: Optional[str] = None,
allow_duplicate: bool = False,
) -> Dict[str, Any]:
"""Adds a time-series event into the appropriate database (sleep, nutrition, or mila)."""
if self.use_backend:
body = {
"kind": kind,
"subject": subject,
"actor": actor,
"payload": payload,
"start": start,
"end": end,
"since": since,
"estimated": estimated,
"meal": meal,
"note": note,
"source_ref": source_ref,
"allow_duplicate": allow_duplicate,
}
if isinstance(payload, dict) and "date" in payload and payload["date"]:
body["date"] = payload["date"]
return self._http_post(f"{self.backend_url}/events", body)
event_id = generate_ulid()
now_dt = datetime.datetime.now(datetime.timezone.utc)
now_iso = now_dt.isoformat()
if since and start:
raise ValueError("Cannot specify both 'since' and 'start'. Use 'since' for relative times or 'start' for absolute times.")
# Parse or default start time
if since:
delta = parse_duration(since)
start_dt = (now_dt - delta).astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
elif not start:
payload_date = payload.get("date") if isinstance(payload, dict) else None
payload_time = payload.get("time") if isinstance(payload, dict) else None
if payload_date:
if payload_time:
try:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T{payload_time}").replace(tzinfo=BERLIN_TZ)
except Exception:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T12:00:00").replace(tzinfo=BERLIN_TZ)
else:
item_str = str(payload.get("item", "")).lower() if isinstance(payload, dict) else ""
if meal == "abendessen" or "abend" in item_str or "bier" in item_str:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T20:00:00").replace(tzinfo=BERLIN_TZ)
elif meal == "mittagessen" or "mittag" in item_str:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T12:30:00").replace(tzinfo=BERLIN_TZ)
elif meal == "fruehstueck" or "morgen" in item_str:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T08:00:00").replace(tzinfo=BERLIN_TZ)
else:
start_dt = datetime.datetime.fromisoformat(f"{payload_date}T12:00:00").replace(tzinfo=BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
start_dt = now_dt.astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
start_dt = datetime.datetime.fromisoformat(start)
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
start_iso = start_dt.isoformat()
end_iso = None
end_dt = None
if end:
end_dt = datetime.datetime.fromisoformat(end)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
if end_dt < start_dt:
raise ValueError(f"End time {end} cannot be earlier than start time {start}")
end_iso = end_dt.isoformat()
# Check for duplicate event within 15 minutes unless explicitly allowed
if not allow_duplicate:
existing_event = self._is_duplicate_event(kind, subject, payload, start_iso)
if existing_event:
feedback = self._get_event_feedback(kind, subject, start_dt.date())
return {
"status": "success",
"duplicate": True,
"message": f"Duplicate event detected within 15 minutes (existing event ID: {existing_event['id']}). Event retained without duplicate insertion.",
"event": existing_event,
"feedback": feedback,
}
# Check open sleep sessions (notice on every log, never silently deckeln!)
open_warning = None
if kind == "sleep.session":
open_sessions = self._get_open_sleep_sessions(subject)
if open_sessions:
first_open = open_sessions[0]
o_start_dt = datetime.datetime.fromisoformat(str(first_open["start"]).replace("Z", "+00:00")).astimezone(BERLIN_TZ)
open_warning = f"Vorheriges Schlaf-Event ({first_open['id']} ab {o_start_dt.strftime('%d.%m. %H:%M Uhr')}) ist noch offen! Bitte Aufwachzeit nachreichen."
if end_iso is None:
diff_hours = (start_dt - o_start_dt).total_seconds() / 3600.0
if diff_hours < 8.0 and start_dt.date() == o_start_dt.date() and not allow_duplicate:
raise ValueError(
f"Subject '{subject}' hat bereits ein aktives Schlaf-Event ({first_open['id']} gestartet um {o_start_dt.strftime('%H:%M Uhr')}). Bitte beende es zuerst."
)
target_db = self._get_target_db(kind)
month_str = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m")
db_dir = self.data_dir / target_db
db_dir.mkdir(parents=True, exist_ok=True)
partition_file = db_dir / f"{month_str}.jsonl"
if target_db == "sleep":
cal_date = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d")
sleep_type = payload.get("type") or "nap"
location = payload.get("location") or "bett"
is_open = (end_iso is None)
start_gmt = int(start_dt.timestamp() * 1000)
end_gmt = int(end_dt.timestamp() * 1000) if end_dt else None
start_local = int((start_dt.timestamp() + start_dt.utcoffset().total_seconds()) * 1000)
end_local = int((end_dt.timestamp() + end_dt.utcoffset().total_seconds()) * 1000) if end_dt else None
dur_sec = int((end_dt - start_dt).total_seconds()) if end_dt else None
nap_sec = dur_sec if (sleep_type == "nap" and dur_sec is not None) else 0
disk_record = {
"id": event_id,
"subject": subject,
"calendarDate": cal_date,
"sleepType": sleep_type,
"location": location,
"isOpen": is_open,
"startIso": start_iso,
"endIso": end_iso,
"sleepStartTimestampGMT": start_gmt,
"sleepEndTimestampGMT": end_gmt,
"sleepStartTimestampLocal": start_local,
"sleepEndTimestampLocal": end_local,
"sleepTimeSeconds": dur_sec,
"napTimeSeconds": nap_sec,
"deepSleepSeconds": None,
"lightSleepSeconds": None,
"remSleepSeconds": None,
"awakeSleepSeconds": None,
"awakeCount": None,
"avgSleepStress": None,
"avgHeartRate": None,
"averageSpO2Value": None,
"lowestSpO2Value": None,
"highestSpO2Value": None,
"averageRespirationValue": None,
"sleepScores": None,
"avgOvernightHrv": None,
"hrvStatus": None,
"bodyBatteryChange": None,
"restingHeartRate": None,
"restlessMomentsCount": None,
"note": note,
"actor": actor,
"created_at": now_iso,
"updated_at": None,
"deleted_at": None,
}
self.con.execute(
"""
INSERT INTO sleep (
id, subject, calendarDate, sleepType, location, isOpen,
startIso, endIso, sleepStartTimestampGMT, sleepEndTimestampGMT,
sleepStartTimestampLocal, sleepEndTimestampLocal,
sleepTimeSeconds, napTimeSeconds, note, actor, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
event_id, subject, cal_date, sleep_type, location, is_open,
start_iso, end_iso, start_gmt, end_gmt, start_local, end_local,
dur_sec, nap_sec, note, actor, now_iso, None, None,
],
)
sort_key = lambda x: (x.get("calendarDate", ""), x.get("startIso", ""), x.get("id", ""))
elif target_db == "mila":
disk_record = {
"id": event_id,
"kind": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"note": note,
"payload": payload,
"source_ref": source_ref,
"created_at": now_iso,
"updated_at": None,
"deleted_at": None,
}
self.con.execute(
"""
INSERT INTO mila (id, kind, subject, actor, start, "end", note, payload, source_ref, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[event_id, kind, subject, actor, start_iso, end_iso, note, json.dumps(payload), source_ref, now_iso, None, None],
)
sort_key = lambda x: (x.get("start", ""), x.get("id", ""))
else:
disk_record = {
"id": event_id,
"kind": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"estimated": estimated,
"meal": meal,
"note": note,
"payload": payload,
"source_ref": source_ref,
"created_at": now_iso,
"updated_at": None,
"deleted_at": None,
}
self.con.execute(
"""
INSERT INTO nutrition (id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[event_id, kind, subject, actor, start_iso, end_iso, estimated, meal, note, json.dumps(payload), source_ref, now_iso, None, None],
)
sort_key = lambda x: (x.get("start", ""), x.get("id", ""))
with open(partition_file, "a", encoding="utf-8") as f:
f.write(json.dumps(disk_record, ensure_ascii=False) + "\n")
self._sort_jsonl_file(partition_file, key_fn=sort_key)
record_response = {
"id": event_id,
"kind": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"estimated": estimated,
"meal": meal,
"note": note,
"payload": payload,
"source_ref": source_ref,
"created_at": now_iso,
"updated_at": None,
"deleted_at": None,
}
# Fetch computed feedback
feedback = self._get_event_feedback(kind, subject, start_dt.date())
if open_warning:
feedback["open_session_warning"] = open_warning
if "day_total_formatted" in feedback and open_warning not in feedback["day_total_formatted"]:
feedback["day_total_formatted"] += f"\n{open_warning}"
elif "day_total_formatted" not in feedback:
feedback["day_total_formatted"] = open_warning
self._trigger_website_update(subject)
self._git_sync(f"chore(data): log {kind} for {subject}")
return {
"status": "success",
"event": record_response,
"feedback": feedback,
}
def stop_event(
self,
kind: str,
subject: str,
end_time: Optional[str] = None,
since: Optional[str] = None,
event_id: Optional[str] = None,
actor: Optional[str] = None,
) -> Dict[str, Any]:
"""Finds the open event (end IS NULL) for kind and subject, and closes it."""
if self.use_backend:
body = {
"kind": kind,
"subject": subject,
"end_time": end_time,
"since": since,
"event_id": event_id,
}
if actor:
body["actor"] = actor
return self._http_post(f"{self.backend_url}/events/stop", body)
if since and end_time:
raise ValueError("Cannot specify both 'since' and 'end_time'. Use 'since' for relative times or 'end_time' for absolute times.")
target_db = self._get_target_db(kind)
if target_db == "sleep":
if event_id:
row = self.con.execute(
"""
SELECT id, startIso, sleepType FROM sleep
WHERE id = ? AND subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL
""",
[event_id, subject],
).fetchone()
if not row:
raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'")
else:
row = self.con.execute(
"""
SELECT id, startIso, sleepType FROM sleep
WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL
ORDER BY startIso DESC LIMIT 1
""",
[subject],
).fetchone()
if not row:
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
resolved_event_id, start_str, sleep_type = row[0], str(row[1]), row[2]
else:
if event_id:
row = self.con.execute(
f"""
SELECT id, start FROM "{target_db}"
WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
""",
[event_id, kind, subject],
).fetchone()
if not row:
raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'")
else:
row = self.con.execute(
f"""
SELECT id, start FROM "{target_db}"
WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
ORDER BY start DESC LIMIT 1
""",
[kind, subject],
).fetchone()
if not row:
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
resolved_event_id, start_str, sleep_type = row[0], str(row[1]), None
now_dt = datetime.datetime.now(datetime.timezone.utc)
if since:
delta = parse_duration(since)
end_dt = (now_dt - delta).astimezone(BERLIN_TZ)
elif end_time:
end_dt = datetime.datetime.fromisoformat(end_time)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
else:
end_dt = now_dt.astimezone(BERLIN_TZ)
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
if end_dt < start_dt:
raise ValueError(f"End time {end_dt.isoformat()} cannot be earlier than start time {start_dt.isoformat()}")
end_iso = end_dt.isoformat()
now_iso = now_dt.isoformat()
duration_seconds = int((end_dt.astimezone(BERLIN_TZ) - start_dt.astimezone(BERLIN_TZ)).total_seconds())
duration_minutes = int(duration_seconds / 60)
if target_db == "sleep":
end_gmt = int(end_dt.timestamp() * 1000)
end_local = int((end_dt.timestamp() + end_dt.utcoffset().total_seconds()) * 1000)
nap_sec = duration_seconds if sleep_type == "nap" else 0
self.con.execute(
"""
UPDATE sleep
SET endIso = ?, isOpen = false, sleepEndTimestampGMT = ?, sleepEndTimestampLocal = ?,
sleepTimeSeconds = ?, napTimeSeconds = ?, updated_at = ?
WHERE id = ?
""",
[end_iso, end_gmt, end_local, duration_seconds, nap_sec, now_iso, resolved_event_id],
)
self._update_jsonl_record("sleep", resolved_event_id, {
"endIso": end_iso,
"isOpen": False,
"sleepEndTimestampGMT": end_gmt,
"sleepEndTimestampLocal": end_local,
"sleepTimeSeconds": duration_seconds,
"napTimeSeconds": nap_sec,
"updated_at": now_iso,
})
else:
self.con.execute(
f"""
UPDATE "{target_db}"
SET "end" = ?, updated_at = ?
WHERE id = ?
""",
[end_iso, now_iso, resolved_event_id],
)
self._update_jsonl_record(target_db, resolved_event_id, {"end": end_iso, "updated_at": now_iso})
self._trigger_website_update(subject)
self._git_sync(f"chore(data): stop {kind} for {subject}")
return {
"status": "success",
"event_id": resolved_event_id,
"kind": kind,
"subject": subject,
"start": start_str,
"end": end_iso,
"duration_minutes": duration_minutes,
"duration_formatted": f"{duration_minutes // 60} h {duration_minutes % 60} min"
if duration_minutes >= 60
else f"{duration_minutes} min",
}
def delete_event(self, event_id: str) -> Dict[str, Any]:
"""Soft-deletes an event across sleep, nutrition, or mila."""
if self.use_backend:
return self._http_delete(f"{self.backend_url}/events/{event_id}")
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone()
if not row:
raise ValueError(f"Event with id '{event_id}' not found.")
kind = row[0]
subject = row[1]
target_db = self._get_target_db(kind)
self.con.execute(f'UPDATE "{target_db}" SET deleted_at = ? WHERE id = ?', [now_iso, event_id])
self._update_jsonl_record(target_db, event_id, {"deleted_at": now_iso})
self._trigger_website_update(subject)
self._git_sync(f"chore(data): delete event {event_id} ({kind}) for {subject}")
return {"status": "success", "deleted_event_id": event_id}
# --------------------------------------------------------------------------
# Entity Operations
# --------------------------------------------------------------------------
def put_entity(self, collection: str, record: Dict[str, Any]) -> Dict[str, Any]:
"""Upserts an entity into entities/<collection>.jsonl."""
if self.use_backend:
return self._http_post(f"{self.backend_url}/entities", {"collection": collection, "record": record})
if collection not in ["foods", "recipes", "places", "people"]:
raise ValueError(f"Unknown entity collection: {collection}")
entity_id = record.get("id")
if not entity_id:
raise ValueError("Entity record must have an 'id' field.")
entities_dir = self.data_dir / "entities"
entities_dir.mkdir(parents=True, exist_ok=True)
target_file = entities_dir / f"{collection}.jsonl"
# Read existing, update or append
existing_records = []
replaced = False
if target_file.is_file():
with open(target_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
if r.get("id") == entity_id:
existing_records.append(record)
replaced = True
else:
existing_records.append(r)
except Exception:
pass
if not replaced:
existing_records.append(record)
# Sort by id
existing_records.sort(key=lambda x: str(x.get("id", "")))
with open(target_file, "w", encoding="utf-8") as f:
for r in existing_records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# Reload into DuckDB
columns = list(record.keys())
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join([f'"{c}"' for c in columns])
values = [
json.dumps(record[c]) if isinstance(record[c], (dict, list)) else record[c]
for c in columns
]
self.con.execute(
f'INSERT OR REPLACE INTO "{collection}" ({col_names}) VALUES ({placeholders})',
values,
)
self._git_sync(f"chore(data): update entity {collection}/{entity_id}")
return {"status": "success", "collection": collection, "id": entity_id}
# --------------------------------------------------------------------------
# Queries & Feedback
# --------------------------------------------------------------------------
def query(self, sql: str) -> List[Dict[str, Any]]:
"""Executes read-only SQL query and returns list of dictionaries."""
if self.use_backend:
res = self._http_post(f"{self.backend_url}/query", {"query": sql})
return res.get("rows", [])
clean_sql = sql.strip()
# Basic safeguard against destructive statements in read query
disallowed = ["DROP ", "ALTER ", "TRUNCATE ", "DELETE FROM", "UPDATE "]
for d in disallowed:
if clean_sql.upper().startswith(d):
raise ValueError(f"Direct {d.strip()} queries not permitted via query(). Use dedicated methods.")
cursor = self.con.execute(clean_sql)
cols = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
results = []
for row in rows:
record = {}
for col, val in zip(cols, row):
if isinstance(val, (datetime.datetime, datetime.date)):
record[col] = val.isoformat()
else:
record[col] = val
results.append(record)
return results
def get_summary(self, subject: str, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Returns daily summary for subject (nutrition, sleep, deficit)."""
if self.use_backend:
url = f"{self.backend_url}/summary/{subject}"
if date_str:
url += f"?date={date_str}"
return self._http_get(url)
target_date = date_str or datetime.datetime.now(BERLIN_TZ).strftime("%Y-%m-%d")
res: Dict[str, Any] = {"subject": subject, "date": target_date}
# 1. Nutrition summary
nut = self.query(f"SELECT * FROM v_nutrition_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if nut:
res["nutrition"] = nut[0]
else:
res["nutrition"] = {
"total_protein_g": 0.0,
"total_carbs_g": 0.0,
"total_sugar_g": 0.0,
"total_fat_g": 0.0,
"total_fiber_g": 0.0,
"total_salt_g": 0.0,
"total_kcal": 0,
"item_count": 0,
}
# 2. Sleep summary
slp = self.query(f"SELECT * FROM v_sleep_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if slp:
res["sleep"] = slp[0]
# 3. Active open sleeps
open_sleep = self.query(
f"SELECT id, start, local_start, sleep_type, location FROM v_sleep WHERE subject = '{subject}' AND is_open = true"
)
if open_sleep:
res["open_sleep"] = open_sleep[0]
# 4. Diaper summary (if subject has diaper data)
dia = self.query(f"SELECT * FROM v_diaper_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if dia:
res["diaper"] = dia[0]
# 5. Feeding summary (if subject has feeding data)
fee = self.query(f"SELECT * FROM v_feeding_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if fee:
res["feeding"] = fee[0]
# 6. Growth latest measurement
grw = self.query(f"SELECT * FROM v_growth WHERE subject = '{subject}' ORDER BY start DESC LIMIT 1")
if grw:
res["growth_latest"] = grw[0]
return res
def find_places_near(
self,
lat: float,
lon: float,
radius_m: float = 1000.0,
category: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Radius search for places using spatial extension or Haversine fallback."""
if self.use_backend:
url = f"{self.backend_url}/places/near?lat={lat}&lon={lon}&radius_m={radius_m}"
if category:
url += f"&category={category}"
return self._http_get(url)
cat_filter = f"AND category = '{category}'" if category else ""
if self.has_spatial_extension:
sql = f"""
SELECT id, name, category, lat, lon, address, google_maps_url, tags, notes, documents,
ROUND(ST_Distance_Spheroid(ST_Point(lon, lat), ST_Point({lon}, {lat})), 1) AS distance_meters
FROM places
WHERE ST_Distance_Spheroid(ST_Point(lon, lat), ST_Point({lon}, {lat})) <= {radius_m}
{cat_filter}
ORDER BY distance_meters ASC
"""
else:
sql = f"""
SELECT id, name, category, lat, lon, address, google_maps_url, tags, notes, documents,
ROUND(distance_m({lat}, {lon}, lat, lon), 1) AS distance_meters
FROM places
WHERE distance_m({lat}, {lon}, lat, lon) <= {radius_m}
{cat_filter}
ORDER BY distance_meters ASC
"""
return self.query(sql)
def _get_event_feedback(self, kind: str, subject: str, target_date: datetime.date) -> Dict[str, Any]:
"""Generates compact running status feedback after an insert."""
date_str = target_date.strftime("%Y-%m-%d")
if kind.startswith("nutrition") and subject != "mila":
daily = self.con.execute(
"SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_kcal FROM v_nutrition_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
return {
"day_total_formatted": f"Tag bisher ({date_str}): ~{daily[0]} g Eiweiß · ~{daily[1]} g KH (davon ~{daily[2]} g Zucker) · ~{daily[3]} g Fett · ~{int(daily[4])} kcal",
"protein_g": daily[0],
"carbs_g": daily[1],
"kcal": daily[4],
}
elif kind.startswith("sleep"):
daily = self.con.execute(
"SELECT total_sleep_hours, nap_count, night_sleep_minutes FROM v_sleep_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
return {
"day_total_formatted": f"Schlaf ({date_str}): {daily[0]} h gesamt ({daily[1]} Schläfchen, {daily[2]} min Nacht)",
"total_hours": daily[0],
"nap_count": daily[1],
}
elif kind.startswith("diaper"):
daily = self.con.execute(
"SELECT total_changes, wet_count, dirty_count FROM v_diaper_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
return {
"day_total_formatted": f"Windeln ({date_str}): {daily[0]} gewechselt ({daily[1]} Pipi, {daily[2]} Stuhl)",
"total_changes": daily[0],
"wet_count": daily[1],
"dirty_count": daily[2],
}
elif kind.startswith("feeding") or (kind.startswith("nutrition") and subject == "mila"):
daily = self.con.execute(
"SELECT bottle_count, total_consumed_ml, solid_count, total_solid_g, total_kcal FROM v_feeding_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
b_cnt, b_ml, s_cnt, s_g, tot_kcal = daily[0], daily[1], daily[2], daily[3], daily[4]
parts = []
if b_cnt > 0 or b_ml > 0:
parts.append(f"{b_cnt} Fläschchen ({int(b_ml)} ml getrunken)")
if s_cnt > 0 or s_g > 0:
parts.append(f"{s_cnt}x Brei/Beikost ({int(s_g)} g)")
summary_str = " · ".join(parts) if parts else f"{b_cnt} Fläschchen ({int(b_ml)} ml getrunken)"
return {
"day_total_formatted": f"Ernährung Mila ({date_str}): {summary_str} · ~{int(tot_kcal)} kcal",
"bottle_count": b_cnt,
"total_consumed_ml": b_ml,
"solid_count": s_cnt,
"total_solid_g": s_g,
"total_kcal": tot_kcal,
}
elif kind.startswith("growth"):
row = self.con.execute(
"SELECT weight_kg, height_cm, head_circ_cm FROM v_growth WHERE subject = ? AND log_date = ? ORDER BY start DESC LIMIT 1",
[subject, date_str],
).fetchone()
if row:
w_kg, h_cm, hc_cm = row[0], row[1], row[2]
p_row = self.con.execute("SELECT birthdate FROM people WHERE id = ?", [subject]).fetchone()
bdate = p_row[0] if p_row and p_row[0] else datetime.date(2026, 3, 14)
if isinstance(bdate, str):
bdate = datetime.date.fromisoformat(bdate)
age_days = (target_date - bdate).days
age_months = max(0.0, age_days / 30.4375)
try:
from who_growth import format_growth_feedback, calculate_percentile
fb_text = format_growth_feedback(w_kg, h_cm, age_months, date_str)
fb_res: Dict[str, Any] = {
"day_total_formatted": fb_text,
"age_months": round(age_months, 2),
}
if w_kg is not None:
pw = calculate_percentile("weight", w_kg, age_months)
fb_res["weight_kg"] = w_kg
fb_res["weight_percentile"] = pw["percentile"]
fb_res["weight_percentile_str"] = pw["percentile_str"]
if h_cm is not None:
ph = calculate_percentile("height", h_cm, age_months)
fb_res["height_cm"] = h_cm
fb_res["height_percentile"] = ph["percentile"]
fb_res["height_percentile_str"] = ph["percentile_str"]
return fb_res
except Exception as e:
logger.warning(f"Error computing growth feedback: {e}")
return {
"day_total_formatted": f"Wachstum ({date_str}): Gewicht {w_kg} kg, Größe {h_cm} cm",
"weight_kg": w_kg,
"height_cm": h_cm,
}
return {}
# --------------------------------------------------------------------------
# Internal Helpers
# --------------------------------------------------------------------------
def _sort_jsonl_file(self, file_path: Path, key_fn):
if not file_path.is_file():
return
records = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except Exception:
pass
records.sort(key=key_fn)
with open(file_path, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def _update_jsonl_record(self, target_db: str, event_id: str, updates: Dict[str, Any]):
"""Finds and updates a record in <target_db>/*.jsonl."""
db_dir = self.data_dir / target_db
if not db_dir.is_dir():
return
for p_file in db_dir.glob("*.jsonl"):
records = []
found = False
with open(p_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
if r.get("id") == event_id:
r.update(updates)
found = True
records.append(r)
except Exception:
pass
if found:
with open(p_file, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
return
# ==============================================================================
# JSON-RPC MCP Server Protocol
# ==============================================================================
def run_mcp_server():
"""Runs standard JSON-RPC stdio MCP server for OpenClaw."""
engine = DatastoreEngine()
TOOLS = [
{
"name": "datastore_add_event",
"description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle, growth.measurement). Computes all macros, totals, and WHO percentiles deterministically.",
"inputSchema": {
"type": "object",
"properties": {
"kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle', 'growth.measurement'"},
"subject": {"type": "string", "description": "'moritz', 'amelie', or 'mila'"},
"actor": {"type": "string", "description": "'agent:koch', 'agent:hebamme', 'moritz', 'amelie'"},
"payload": {"type": "object", "description": "Detailed payload matching the kind schema"},
"start": {"type": "string", "description": "ISO 8601 timestamp (optional, defaults to now)"},
"end": {"type": "string", "description": "ISO 8601 timestamp (optional for intervals)"},
"since": {"type": "string", "description": "Relative start offset backwards from now (e.g. '5m', '15m', '1h'). Use when logging events that started X minutes ago without computing absolute timestamps."},
"estimated": {"type": "boolean", "description": "True if portions or macros are estimated"},
"meal": {"type": "string", "description": "Optional meal grouping (e.g. 'Frühstück', 'Mittagessen')"},
"note": {"type": "string", "description": "Free text notes or context"},
"allow_duplicate": {"type": "boolean", "description": "Set to true to force adding duplicate event within 15m"},
},
"required": ["kind", "subject", "actor", "payload"],
},
},
{
"name": "datastore_stop_event",
"description": "Closes an open event interval (e.g. Mila wakes up -> closes open sleep.session).",
"inputSchema": {
"type": "object",
"properties": {
"kind": {"type": "string", "description": "e.g. 'sleep.session'"},
"subject": {"type": "string", "description": "'mila', 'moritz', 'amelie'"},
"end_time": {"type": "string", "description": "ISO timestamp (optional, defaults to now)"},
"since": {"type": "string", "description": "Relative end offset backwards from now (e.g. '5m', '15m')"},
"event_id": {"type": "string", "description": "Specific open event ID to close (optional)"},
},
"required": ["kind", "subject"],
},
},
{
"name": "datastore_get_summary",
"description": "Fetch daily nutrition, sleep, and status totals computed deterministically by DuckDB.",
"inputSchema": {
"type": "object",
"properties": {
"subject": {"type": "string", "description": "'moritz', 'mila', or 'amelie'"},
"date": {"type": "string", "description": "YYYY-MM-DD (defaults to today)"},
},
"required": ["subject"],
},
},
{
"name": "datastore_query",
"description": "Execute a read-only DuckDB SQL query against the datastore (events, v_nutrition, v_sleep, places, foods, etc.).",
"inputSchema": {
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SQL SELECT query to execute"},
},
"required": ["sql"],
},
},
{
"name": "datastore_find_places_near",
"description": "Search for points of interest (beer gardens, playgrounds, stores) within a given radius in meters.",
"inputSchema": {
"type": "object",
"properties": {
"lat": {"type": "number", "description": "Latitude (e.g. 49.0205 for Grabengasse)"},
"lon": {"type": "number", "description": "Longitude (e.g. 12.0975 for Grabengasse)"},
"radius_m": {"type": "number", "description": "Search radius in meters (default 1000)"},
"category": {"type": "string", "description": "Optional category filter (e.g. 'biergarten')"},
},
"required": ["lat", "lon"],
},
},
{
"name": "datastore_put_entity",
"description": "Upsert an entity record into foods, recipes, places, or people.",
"inputSchema": {
"type": "object",
"properties": {
"collection": {"type": "string", "enum": ["foods", "recipes", "places", "people"]},
"record": {"type": "object", "description": "Entity record matching collection schema"},
},
"required": ["collection", "record"],
},
},
]
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception:
continue
method = req.get("method")
msg_id = req.get("id")
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "openclaw-datastore", "version": "1.0.0"},
"capabilities": {"tools": {}},
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/list":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {"tools": TOOLS},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/call":
params = req.get("params", {})
name = params.get("name")
args = params.get("arguments", {})
try:
if name == "datastore_add_event":
data = engine.add_event(
kind=args["kind"],
subject=args["subject"],
actor=args.get("actor", "agent"),
payload=args["payload"],
start=args.get("start"),
end=args.get("end"),
since=args.get("since"),
estimated=args.get("estimated", False),
meal=args.get("meal"),
note=args.get("note"),
source_ref=args.get("source_ref"),
allow_duplicate=args.get("allow_duplicate", False),
)
elif name == "datastore_stop_event":
data = engine.stop_event(
kind=args["kind"],
subject=args["subject"],
end_time=args.get("end_time"),
since=args.get("since"),
event_id=args.get("event_id"),
)
elif name == "datastore_get_summary":
data = engine.get_summary(subject=args["subject"], date_str=args.get("date"))
elif name == "datastore_query":
data = engine.query(sql=args["sql"])
elif name == "datastore_find_places_near":
data = engine.find_places_near(
lat=args["lat"],
lon=args["lon"],
radius_m=args.get("radius_m", 1000.0),
category=args.get("category"),
)
elif name == "datastore_put_entity":
data = engine.put_entity(
collection=args["collection"],
record=args["record"],
)
else:
raise ValueError(f"Unknown tool: {name}")
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [{"type": "text", "text": json.dumps(data, indent=2, ensure_ascii=False)}]
},
}
except Exception as e:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"isError": True,
"content": [{"type": "text", "text": f"Error executing {name}: {str(e)}"}],
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif msg_id is not None:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
# ==============================================================================
# CLI Entrypoint
# ==============================================================================
def run_cli():
parser = argparse.ArgumentParser(description="OpenClaw Datastore CLI")
subparsers = parser.add_subparsers(dest="command")
# Add Event
p_add = subparsers.add_parser("add", help="Add an event")
p_add.add_argument("kind", nargs="?", default=None, help="e.g. nutrition.intake, sleep.session")
p_add.add_argument("--kind", dest="kind_flag", help="e.g. nutrition.intake, sleep.session")
p_add.add_argument("--subject", required=True, help="moritz, mila, amelie")
p_add.add_argument("--actor", default="cli", help="Reporter identifier")
p_add.add_argument("--payload", required=True, help="JSON payload string")
p_add.add_argument("--start", help="ISO start time")
p_add.add_argument("--date", help="Target date YYYY-MM-DD for retroactive logging")
p_add.add_argument("--end", help="ISO end time")
p_add.add_argument("--since", help="Relative start offset backwards from now (e.g. '5m', '15m', '1h')")
p_add.add_argument("--meal", help="Meal grouping")
p_add.add_argument("--note", help="Free-form note")
p_add.add_argument("--estimated", action="store_true", help="Flag as estimated")
p_add.add_argument("--allow-duplicate", action="store_true", help="Allow duplicate event within 15 minutes")
# Stop Event
p_stop = subparsers.add_parser("stop", help="Stop an open interval event")
p_stop.add_argument("kind", nargs="?", default=None, help="e.g. sleep.session")
p_stop.add_argument("--kind", dest="kind_flag", help="e.g. sleep.session")
p_stop.add_argument("--subject", required=True, help="mila, moritz")
p_stop.add_argument("--id", dest="event_id", help="Specific event ID to close")
p_stop.add_argument("--end", dest="end_time", help="ISO end time (defaults to now)")
p_stop.add_argument("--since", help="Relative end offset backwards from now (e.g. '5m', '15m')")
# Query
p_query = subparsers.add_parser("query", help="Execute read-only SQL query")
p_query.add_argument("sql", help="SQL string")
# Summary
p_sum = subparsers.add_parser("summary", help="Get daily summary")
p_sum.add_argument("--subject", required=True, help="moritz, mila")
p_sum.add_argument("--date", help="YYYY-MM-DD")
# Places
p_places = subparsers.add_parser("places", help="Radius search for places")
p_places.add_argument("--lat", type=float, required=True)
p_places.add_argument("--lon", type=float, required=True)
p_places.add_argument("--radius", type=float, default=1000.0)
# Delete Event
p_delete = subparsers.add_parser("delete", help="Soft-delete an event by ID")
p_delete.add_argument("event_id", help="Event ID to delete")
args = parser.parse_args()
engine = DatastoreEngine()
if args.command == "add":
kind = args.kind or args.kind_flag
if not kind:
parser.error("the 'add' command requires 'kind' (positional or via --kind)")
if kind.lower() in ("true", "false", "yes", "no"):
parser.error(f"Invalid kind '{kind}'. Did you mean '--estimated' without an argument?")
if "." not in kind:
parser.error(f"Invalid kind '{kind}'. Expected domain.event (e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle').")
payload = json.loads(args.payload)
start_val = args.start
if getattr(args, "date", None) and not start_val:
meal_val = args.meal or ""
item_str = str(payload.get("item", "")).lower() if isinstance(payload, dict) else ""
if meal_val == "abendessen" or "abend" in item_str or "bier" in item_str:
start_val = f"{args.date}T20:00:00+02:00"
elif meal_val == "mittagessen" or "mittag" in item_str:
start_val = f"{args.date}T12:30:00+02:00"
elif meal_val == "fruehstueck" or "morgen" in item_str:
start_val = f"{args.date}T08:00:00+02:00"
else:
start_val = f"{args.date}T12:00:00+02:00"
res = engine.add_event(
kind=kind,
subject=args.subject,
actor=args.actor,
payload=payload,
start=start_val,
end=args.end,
since=args.since,
meal=args.meal,
note=args.note,
estimated=args.estimated,
allow_duplicate=args.allow_duplicate,
)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "stop":
kind = args.kind or args.kind_flag
if not kind:
parser.error("the 'stop' command requires 'kind' (positional or via --kind)")
res = engine.stop_event(
kind=kind,
subject=args.subject,
end_time=getattr(args, "end_time", None),
since=getattr(args, "since", None),
event_id=getattr(args, "event_id", None),
)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "query":
res = engine.query(args.sql)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "summary":
res = engine.get_summary(subject=args.subject, date_str=args.date)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "places":
res = engine.find_places_near(
lat=args.lat,
lon=args.lon,
radius_m=args.radius,
category=args.category,
)
elif args.command == "delete":
res = engine.delete_event(args.event_id)
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
parser.print_help()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()