1801 lines
78 KiB
Python
Executable File
1801 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=3.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), "pull", "--rebase", "--autostash", "origin", "main"], check=False, capture_output=True, timeout=20)
|
|
subprocess.run(["git", "-C", str(repo_dir), "push", "origin", "main"], check=False, capture_output=True, timeout=20)
|
|
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()
|