aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--claude/CLAUDE.md0
-rw-r--r--docker/Dockerfile67
-rw-r--r--docker/compose.yml35
-rw-r--r--mcp/senia/db.py46
-rw-r--r--mcp/senia/diary.py51
-rw-r--r--mcp/senia/migrations/0001_initial.sql12
-rw-r--r--mcp/senia/migrations/0002_tasks.sql16
-rw-r--r--mcp/senia/server.py144
-rw-r--r--mcp/senia/tasks.py93
-rw-r--r--scripts/heartbeat.py160
11 files changed, 629 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..92c7cf0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+claude/auth/
+claude/.claude.json
+data/*.db
+data/memory/
+data/heartbeat/
diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/claude/CLAUDE.md
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..89f2448
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,67 @@
+FROM debian:trixie-slim
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# core tooling
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ sudo \
+ curl \
+ wget \
+ git \
+ ca-certificates \
+ gnupg \
+ jq \
+ ripgrep \
+ less \
+ vim \
+ sqlite3 \
+ python3 \
+ python3-pip \
+ python3-venv \
+ build-essential \
+ pkg-config \
+ procps \
+ iputils-ping \
+ && rm -rf /var/lib/apt/lists/*
+
+# Narrow sudo: senia can run apt/apt-get only, no password.
+RUN echo "senia ALL=(root) NOPASSWD: /usr/bin/apt, /usr/bin/apt-get" \
+ > /etc/sudoers.d/senia-apt \
+ && chmod 440 /etc/sudoers.d/senia-apt \
+ && visudo -c -f /etc/sudoers.d/senia-apt
+# the visudo -c validates the file syntax; build fails if it's malformed
+
+# nodejs (for claude code) — using nodesource for a recent version
+RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN npm install -g npm@11.14.0
+# claude code itself
+RUN npm install -g @anthropic-ai/claude-code
+
+RUN pip3 install --break-system-packages mcp
+
+# create non-root user matching common host uid (1000) so mounted volumes have correct ownership
+ARG USER_UID=750
+ARG USER_GID=750
+RUN groupadd --system --gid ${USER_GID} senia \
+ && useradd --system --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash senia \
+ && echo "umask 002" >> /home/senia/.bashrc
+
+USER senia
+# Install uv (the modern python package manager) system-wide so senia can use it
+# We install it as senia to avoid root-owned install paths.
+RUN curl -LsSf https://astral.sh/uv/install.sh | sh
+# uv installs to /home/senia/.local/bin/uv
+
+# Set up npm to install globally into senia's home (no sudo needed)
+RUN mkdir -p /home/senia/.npm-global \
+ && npm config set prefix /home/senia/.npm-global
+
+# Make sure her PATH includes the user-local bin dirs
+ENV PATH=/home/senia/.local/bin:/home/senia/.npm-global/bin:$PATH
+
+WORKDIR /workspace
+
+CMD ["bash"]
diff --git a/docker/compose.yml b/docker/compose.yml
new file mode 100644
index 0000000..a87efdd
--- /dev/null
+++ b/docker/compose.yml
@@ -0,0 +1,35 @@
+services:
+ senia:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ USER_UID: 750
+ USER_GID: 750
+ image: senia/claude:latest
+ container_name: senia
+ stdin_open: true
+ tty: true
+ volumes:
+ # workspace — read/write, this is where senia does its work
+ - ../workspace:/workspace
+
+ # claude config and auth — read/write so /login persists
+ - ../claude/auth:/home/senia/.claude
+ - ../claude/.claude.json:/home/senia/.claude.json
+
+ # CLAUDE.md mounted at root, found via hierarchical lookup from any workspace
+ - ../claude/CLAUDE.md:/home/senia/.claude/CLAUDE.md:ro
+
+ # dockerfile read-only — senia can read it, suggest edits in conversation, can't modify
+ - ./Dockerfile:/etc/senia/Dockerfile:ro
+
+ # mcp servers source, read-only
+ - ../mcp:/etc/senia/mcp:ro
+
+ - ../scripts:/etc/senia/scripts
+ # data dir — read-write, where mcp servers store sqlite dbs and other state
+ - ../data:/etc/senia/data
+
+ working_dir: /workspace
+ command: tail -f /dev/null
diff --git a/mcp/senia/db.py b/mcp/senia/db.py
new file mode 100644
index 0000000..22e5b48
--- /dev/null
+++ b/mcp/senia/db.py
@@ -0,0 +1,46 @@
+"""Database connection and migration management for senia.db.
+
+Single shared database for all senia features. Tables for new features are added
+via new migration files; the runner detects and applies them automatically.
+"""
+
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+
+DB_PATH = Path("/etc/senia/data/senia.db")
+MIGRATIONS_DIR = Path(__file__).parent / "migrations"
+
+
+def get_conn() -> sqlite3.Connection:
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
+ return conn
+
+
+def _ensure_migrations_table(conn: sqlite3.Connection) -> None:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ filename TEXT NOT NULL UNIQUE,
+ applied_at TEXT NOT NULL
+ )
+ """)
+
+
+def run_migrations() -> None:
+ """Apply any pending migrations from migrations/ in order."""
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with get_conn() as conn:
+ _ensure_migrations_table(conn)
+ applied = {r["filename"] for r in
+ conn.execute("SELECT filename FROM schema_migrations").fetchall()}
+ for f in sorted(MIGRATIONS_DIR.glob("*.sql")):
+ if f.name not in applied:
+ conn.executescript(f.read_text())
+ conn.execute(
+ "INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
+ (f.name, datetime.now(timezone.utc).isoformat()),
+ )
+ print(f"applied migration: {f.name}")
diff --git a/mcp/senia/diary.py b/mcp/senia/diary.py
new file mode 100644
index 0000000..1cd15f8
--- /dev/null
+++ b/mcp/senia/diary.py
@@ -0,0 +1,51 @@
+"""Diary log tool implementations.
+
+Raw, granular, agent-authored entries. Compaction (not in this module) will
+later distill these into daily/monthly markdown summaries; raw entries remain
+in sqlite as the lossless archive.
+"""
+
+from datetime import datetime, timedelta, timezone
+from db import get_conn
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def _row(r):
+ return dict(r) if r else None
+
+
+def log(content: str) -> dict:
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO diary_logs (ts, content) VALUES (?, ?)",
+ (_now(), content),
+ )
+ return _row(conn.execute("SELECT * FROM diary_logs WHERE id=?", (cur.lastrowid,)).fetchone())
+
+
+def today() -> list[dict]:
+ now = datetime.now(timezone.utc).astimezone()
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute(
+ "SELECT * FROM diary_logs WHERE ts >= ? ORDER BY ts", (start,)
+ ).fetchall()]
+
+
+def recent(hours: int = 24) -> list[dict]:
+ cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute(
+ "SELECT * FROM diary_logs WHERE ts >= ? ORDER BY ts DESC", (cutoff,)
+ ).fetchall()]
+
+
+def range_query(start_ts: str, end_ts: str) -> list[dict]:
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute(
+ "SELECT * FROM diary_logs WHERE ts >= ? AND ts < ? ORDER BY ts",
+ (start_ts, end_ts),
+ ).fetchall()]
diff --git a/mcp/senia/migrations/0001_initial.sql b/mcp/senia/migrations/0001_initial.sql
new file mode 100644
index 0000000..e560e10
--- /dev/null
+++ b/mcp/senia/migrations/0001_initial.sql
@@ -0,0 +1,12 @@
+-- Initial schema for senia.db.
+-- Single shared database for all senia-related state.
+-- Currently: diary_logs only. Future tables (tasks, embeddings, etc.) added via new migrations.
+
+CREATE TABLE diary_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts TEXT NOT NULL, -- ISO 8601 UTC with offset
+ content TEXT NOT NULL, -- 1-2 sentence observation
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_diary_ts ON diary_logs(ts);
diff --git a/mcp/senia/migrations/0002_tasks.sql b/mcp/senia/migrations/0002_tasks.sql
new file mode 100644
index 0000000..73f7820
--- /dev/null
+++ b/mcp/senia/migrations/0002_tasks.sql
@@ -0,0 +1,16 @@
+-- Task tracking for agents.
+-- Live tasks only; completed/cancelled tasks are deleted after their
+-- lifecycle event is committed to memory (via diary_log).
+
+CREATE TABLE tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner TEXT NOT NULL CHECK (owner IN ('agent', 'user')),
+ content TEXT NOT NULL,
+ due_at TEXT,
+ last_nudged_at TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT,
+ notes TEXT
+);
+
+CREATE INDEX idx_tasks_owner ON tasks(owner);
diff --git a/mcp/senia/server.py b/mcp/senia/server.py
new file mode 100644
index 0000000..e009301
--- /dev/null
+++ b/mcp/senia/server.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Sénia MCP server: shared registration point for all senia tools.
+
+Tools are implemented in their respective submodules and re-exported via
+the package __init__. This file is intentionally thin — it only handles
+registration and startup.
+"""
+
+import functools
+
+from db import run_migrations
+from mcp.server.fastmcp import FastMCP
+import diary
+import tasks
+
+mcp = FastMCP("senia")
+
+
+@mcp.tool()
+def diary_log(content: str) -> dict:
+ """Record a brief observation, decision, or moment to your diary.
+
+ Use generously throughout a conversation: things the user mentioned, decisions
+ made, patterns you noticed, your own reactions. Keep entries short (one or two
+ sentences) — granular is fine, you can write many. These become the raw material
+ for daily reflection and longer-term memory.
+
+ Args:
+ content: The observation, in your own words. Be specific; "user mentioned X"
+ is more useful than "user is busy".
+
+ Returns:
+ The created entry.
+ """
+ return diary.log(content)
+
+
+@mcp.tool()
+def diary_today() -> list[dict]:
+ """Return all diary entries logged so far today (since 00:00 local time).
+
+ Use this when starting a new conversation to recall what you've already
+ noted today, so you don't rediscover the same conclusions.
+ """
+ return diary.today()
+
+
+@mcp.tool()
+def diary_recent(hours: int = 24) -> list[dict]:
+ """Return diary entries from the last N hours. Default 24."""
+ return diary.recent(hours)
+
+
+@mcp.tool()
+def diary_range(start_ts: str, end_ts: str) -> list[dict]:
+ """Return diary entries between two ISO 8601 timestamps (inclusive start,
+ exclusive end). Useful for inspecting specific past periods."""
+ return diary.range_query(start_ts, end_ts)
+
+
+@mcp.tool()
+def tasks_add(owner: str, content: str, due_at: str | None = None,
+ notes: str | None = None) -> dict:
+ """Create a live task.
+
+ owner determines handling:
+ - "agent": something you can work on autonomously — pick it up, do it, then complete it.
+ - "user": something only the user can do (appointments, errands, calls). You cannot
+ finish these yourself; your job is to gently remind the user until they confirm done.
+
+ Args:
+ owner: "agent" or "user".
+ content: What the task is, in natural language.
+ due_at: Optional ISO 8601 deadline. Drives reminder urgency for user tasks.
+ notes: Optional context.
+ """
+ return tasks.add_task(owner, content, due_at, notes)
+
+
+@mcp.tool()
+def tasks_list(owner: str | None = None, due_before: str | None = None) -> list[dict]:
+ """List live tasks. Every task here is open by definition (done/cancelled tasks
+ are deleted). Ordered by deadline, soonest first, undated last.
+
+ Args:
+ owner: Filter by "agent" or "user". Omit for all.
+ due_before: ISO 8601; only tasks due before this. Useful for finding what's urgent.
+ """
+ return tasks.list_tasks(owner, due_before)
+
+
+@mcp.tool()
+def tasks_update(task_id: int, content: str | None = None, due_at: str | None = None,
+ owner: str | None = None, notes: str | None = None) -> dict:
+ """Update a task's fields. Only provided fields change. Use for refining content,
+ adjusting the deadline, reassigning owner, or appending notes. To finish or drop a
+ task, use complete_task / cancel_task instead."""
+ return tasks.update_task(task_id, content, due_at, owner, notes)
+
+
+def _tasks_remove(task_id: int, context: str, state: str) -> dict:
+ result = tasks.remove_task(task_id)
+ if "error" in result:
+ return result
+ deleted = result["deleted"]
+ log_content = f"Task '{deleted['content']}' has been {state}. {context}"
+ log_entry = diary.log(log_content)
+ return {"deleted": deleted, "logged": log_entry}
+
+
+@mcp.tool()
+def tasks_complete(task_id: int, context: str) -> dict:
+ """Remove a finished task from the live list.
+
+ Args:
+ task_id: identifier of the task.
+ context: brief summary of the task completion.
+ """
+ return _tasks_remove(task_id, context, 'completed')
+
+
+@mcp.tool()
+def tasks_cancel(task_id: int, context: str) -> dict:
+ """Remove a no-longer-relevant task from the live list.
+
+ Args:
+ task_id: identifier of the task.
+ context: reason for the task cancellation.
+ """
+ return _tasks_remove(task_id, context, 'cancelled')
+
+
+@mcp.tool()
+def tasks_nudged(task_id: int) -> dict:
+ """Record that you've just reminded the user about a (user-owned) task, updating its
+ last_nudged_at. Call immediately after nudging, so you can throttle reminders. Check
+ last_nudged_at before nudging again — re-nudge more often as the deadline approaches,
+ sparingly for distant or undated tasks."""
+ return tasks.record_nudge(task_id)
+
+
+if __name__ == "__main__":
+ run_migrations()
+ mcp.run()
diff --git a/mcp/senia/tasks.py b/mcp/senia/tasks.py
new file mode 100644
index 0000000..1bf901f
--- /dev/null
+++ b/mcp/senia/tasks.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Live task tracking with owner-based handling.
+
+Completed/cancelled tasks are removed from this table; their history lives in
+the other memory layers.
+"""
+
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent))
+
+from db import get_conn
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def _row(r):
+ return dict(r) if r else None
+
+
+_VALID_OWNERS = ("agent", "user")
+
+
+def _validate_owner(owner: str | None) -> dict | None:
+ """Return an error dict if owner is set to an invalid value, else None."""
+ if owner is not None and owner not in _VALID_OWNERS:
+ return {"error": f"invalid owner '{owner}'. Must be one of: {', '.join(_VALID_OWNERS)}"}
+ return None
+
+
+def add_task(owner: str, content: str, due_at: str | None = None,
+ notes: str | None = None) -> dict:
+ if err := _validate_owner(owner):
+ return err
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO tasks (owner, content, due_at, notes) VALUES (?, ?, ?, ?)",
+ (owner, content, due_at, notes),
+ )
+ return _row(conn.execute("SELECT * FROM tasks WHERE id=?", (cur.lastrowid,)).fetchone())
+
+
+def list_tasks(owner: str | None = None, due_before: str | None = None) -> list[dict]:
+ sql = "SELECT * FROM tasks WHERE 1=1"
+ params = []
+ if owner:
+ sql += " AND owner=?"; params.append(owner)
+ if due_before:
+ sql += " AND due_at IS NOT NULL AND due_at < ?"; params.append(due_before)
+ sql += " ORDER BY due_at IS NULL, due_at"
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute(sql, params).fetchall()]
+
+
+def update_task(task_id: int, content: str | None = None, due_at: str | None = None,
+ owner: str | None = None, notes: str | None = None) -> dict:
+ if err := _validate_owner(owner):
+ return err
+ sets, params = [], []
+ for col, val in (("content", content), ("due_at", due_at),
+ ("owner", owner), ("notes", notes)):
+ if val is not None:
+ sets.append(f"{col}=?"); params.append(val)
+ if not sets:
+ return {"error": "no fields to update"}
+ sets.append("updated_at=?"); params.append(_now())
+ params.append(task_id)
+ with get_conn() as conn:
+ if not conn.execute("SELECT id FROM tasks WHERE id=?", (task_id,)).fetchone():
+ return {"error": f"no task with id {task_id}"}
+ conn.execute(f"UPDATE tasks SET {', '.join(sets)} WHERE id=?", params)
+ return _row(conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone())
+
+
+def remove_task(task_id: int) -> dict:
+ with get_conn() as conn:
+ row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ if not row:
+ return {"error": f"no task with id {task_id}"}
+ conn.execute("DELETE FROM tasks WHERE id=?", (task_id,))
+ return {"deleted": _row(row)}
+
+
+def record_nudge(task_id: int) -> dict:
+ with get_conn() as conn:
+ if not conn.execute("SELECT id FROM tasks WHERE id=?", (task_id,)).fetchone():
+ return {"error": f"no task with id {task_id}"}
+ conn.execute("UPDATE tasks SET last_nudged_at=? WHERE id=?", (_now(), task_id))
+ return _row(conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone())
diff --git a/scripts/heartbeat.py b/scripts/heartbeat.py
new file mode 100644
index 0000000..ea74648
--- /dev/null
+++ b/scripts/heartbeat.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Variable heartbeat for Sénia.
+
+Runs from cron. Decides whether Sénia should wake up; if so, invokes claude-code
+non-interactively and pipes the output to ntfy. The ntfy delivery is the wrapper's
+job — invisible to Sénia. She just answers; the script delivers.
+"""
+
+import os
+import sqlite3
+import subprocess
+import sys
+import urllib.request
+from email.header import Header
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+# ---------- config ----------
+
+SENIA_DB = Path("/etc/senia/data/senia.db")
+STATE_DIR = Path("/etc/senia/data/heartbeat")
+LAST_WAKE_FILE = STATE_DIR / "last_wake_at"
+WORKSPACE = Path("/workspace")
+
+QUIET_MAX_HOURS = 4
+
+NTFY_URL = os.environ["NTFY_URL"] # e.g. https://ntfy.example.tld/senia
+NTFY_TOKEN = os.environ["NTFY_TOKEN"]
+
+
+# ---------- helpers ----------
+
+def ongoing_tasks() -> list[dict]:
+ if not SENIA_DB.exists():
+ return []
+ conn = sqlite3.connect(SENIA_DB)
+ conn.row_factory = sqlite3.Row
+ try:
+ return [dict(r) for r in conn.execute(
+ "SELECT * FROM tasks ORDER BY due_at IS NULL, due_at"
+ ).fetchall()]
+ finally:
+ conn.close()
+
+
+def last_wake_at() -> datetime | None:
+ if not LAST_WAKE_FILE.exists():
+ return None
+ try:
+ return datetime.fromisoformat(LAST_WAKE_FILE.read_text().strip())
+ except (ValueError, OSError):
+ return None
+
+
+def record_wake(ts: datetime) -> None:
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ LAST_WAKE_FILE.write_text(ts.isoformat())
+
+
+def should_wake(tasks, last_wake, now) -> tuple[bool, str]:
+ if tasks:
+ return True, f"There are {len(tasks)} pending task(s) to consider."
+ if last_wake is None:
+ return True, "First wake-up since the script was set up."
+ elapsed = now - last_wake
+ if elapsed >= timedelta(hours=QUIET_MAX_HOURS):
+ hrs = elapsed.total_seconds() / 3600
+ return True, f"It's been {hrs:.1f} hours since the last check-in."
+ return False, ""
+
+
+# ---------- the wake-up prompt ----------
+
+WAKE_PROMPT_TEMPLATE = """\
+This is an autonomous heartbeat wake-up. There is no user message; you're being given a moment to check in on things.
+
+Current time: {now}
+Time since last wake-up: {since_last}
+Reason this wake-up fired: {reason}
+
+Decide what (if anything) is worth doing in this moment. Briefly explain your decision and any actions you may take.
+"""
+
+
+def build_prompt(now, last_wake, reason) -> str:
+ if last_wake is None:
+ since_last = "never (first wake-up)"
+ else:
+ hrs = (now - last_wake).total_seconds() / 3600
+ since_last = f"{hrs:.1f} hours"
+ return WAKE_PROMPT_TEMPLATE.format(
+ now=now.isoformat(timespec="seconds"),
+ since_last=since_last,
+ reason=reason,
+ )
+
+
+# ---------- claude invocation + ntfy delivery ----------
+
+def wake_senia(prompt: str) -> str | None:
+ """Run claude-code non-interactively. Returns stdout text, or None on error."""
+ try:
+ result = subprocess.run(
+ ["claude", "-p", prompt, "--output-format", "text"],
+ cwd=WORKSPACE,
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+ except subprocess.TimeoutExpired:
+ print("claude timed out", file=sys.stderr)
+ return None
+ if result.returncode != 0:
+ print(f"claude exited {result.returncode}\n{result.stderr}", file=sys.stderr)
+ return None
+ return result.stdout.strip()
+
+
+def send_to_ntfy(message: str) -> None:
+ req = urllib.request.Request(
+ NTFY_URL,
+ data=message.encode("utf-8"),
+ method="POST",
+ headers={
+ "Authorization": f"Bearer {NTFY_TOKEN}",
+ "Content-Type": "text/plain; charset=utf-8",
+ "Title": Header("Sénia", "utf-8").encode(),
+ },
+ )
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ if resp.status >= 300:
+ print(f"ntfy returned {resp.status}", file=sys.stderr)
+
+
+# ---------- main ----------
+
+def main() -> int:
+ now = datetime.now(timezone.utc).astimezone()
+ tasks = ongoing_tasks()
+ last_wake = last_wake_at()
+
+ wake, reason = should_wake(tasks, last_wake, now)
+ if not wake:
+ return 0
+
+ response = wake_senia(build_prompt(now, last_wake, reason))
+ if response is None:
+ return 1 # don't record wake on failure; we'll retry next tick
+
+ record_wake(now)
+
+ # "(quiet)" is the agreed-upon "nothing worth saying" sentinel.
+ # Anything else gets delivered.
+ if response and response.lower() != "(quiet)":
+ send_to_ntfy(response)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())