diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-06-22 02:49:02 +0200 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-06-26 13:40:00 +0200 |
| commit | 506713247b0099142ff873f5750933d49b182c37 (patch) | |
| tree | dfe444c057a9addfeaa57951673cf2251802f049 | |
| download | senia-506713247b0099142ff873f5750933d49b182c37.tar.xz senia-506713247b0099142ff873f5750933d49b182c37.zip | |
first commit
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | claude/CLAUDE.md | 0 | ||||
| -rw-r--r-- | docker/Dockerfile | 67 | ||||
| -rw-r--r-- | docker/compose.yml | 35 | ||||
| -rw-r--r-- | mcp/senia/db.py | 46 | ||||
| -rw-r--r-- | mcp/senia/diary.py | 51 | ||||
| -rw-r--r-- | mcp/senia/migrations/0001_initial.sql | 12 | ||||
| -rw-r--r-- | mcp/senia/migrations/0002_tasks.sql | 16 | ||||
| -rw-r--r-- | mcp/senia/server.py | 144 | ||||
| -rw-r--r-- | mcp/senia/tasks.py | 93 | ||||
| -rw-r--r-- | scripts/heartbeat.py | 160 |
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()) |
