aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--agent/orchestrator/context.py23
-rw-r--r--scripts/compact.py275
2 files changed, 295 insertions, 3 deletions
diff --git a/agent/orchestrator/context.py b/agent/orchestrator/context.py
index e9c1975..d19d8f3 100644
--- a/agent/orchestrator/context.py
+++ b/agent/orchestrator/context.py
@@ -11,6 +11,8 @@ from pathlib import Path
SENIA_DB = Path("/etc/senia/data/senia.db")
+RECENT_FILE = Path("/etc/senia/data/memory/recent.md")
+ROLLING_WINDOW_DAYS = 11
def _today_diary_entries() -> list[str]:
@@ -31,6 +33,13 @@ def _today_diary_entries() -> list[str]:
conn.close()
+def _recent_summary() -> str:
+ if not RECENT_FILE.exists():
+ return ""
+ summary = RECENT_FILE.read_text().strip()
+ return f"## Recent context (last {ROLLING_WINDOW_DAYS} days)\n{summary}"
+
+
def build_turn_context(user: str | None = None) -> str:
diary_entries = _today_diary_entries()
diary = (
@@ -38,10 +47,18 @@ def build_turn_context(user: str | None = None) -> str:
if diary_entries
else "(no entries yet today)"
)
+ recent = _recent_summary()
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
- time_section = f"## Current time\n{now}\n\n"
- user_section = f"## User\n{user}\n\n" if user else ""
+ time_section = f"## Current time\n{now}"
+ user_section = f"## Speaking with\n{user}" if user else ""
+ recent_section = _recent_summary()
diary_section = f"## Today's diary so far\n{diary}"
+ parts = [
+ time_section,
+ user_section,
+ recent_section,
+ diary_section,
+ ]
- return f"{time_section}{user_section}{diary_section}"
+ return "\n\n".join(p for p in parts if p)
diff --git a/scripts/compact.py b/scripts/compact.py
new file mode 100644
index 0000000..1b65363
--- /dev/null
+++ b/scripts/compact.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""Compaction job for Sénia, runs from host cron just after midnight.
+
+For each invocation:
+1. compact the previous day's diary entries into a daily archive
+ (bootstrap case: include all earlier entries too, filed under yesterday)
+2. regenerate the rolling N-day recent.md
+3. on the 1st of the month, also compact the prior month's dailies into a monthly archive
+"""
+
+import re
+import sqlite3
+import subprocess
+import sys
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+
+SENIA_DB = Path("/etc/senia/data/senia.db")
+MEMORY_DIR = Path("/etc/senia/data/memory")
+DAILY_DIR = MEMORY_DIR / "daily"
+MONTHLY_DIR = MEMORY_DIR / "monthly"
+RECENT_FILE = MEMORY_DIR / "recent.md"
+WORKSPACE = Path("/workspace")
+
+CLAUDE_TIMEOUT = 600
+ROLLING_WINDOW_DAYS = 10
+
+
+# ---------- db / fs ----------
+
+def diary_entries(start_iso: str | None, end_iso: str) -> list[tuple[str, str]]:
+ conn = sqlite3.connect(SENIA_DB)
+ try:
+ if start_iso is None:
+ return [
+ (row[0], row[1])
+ for row in conn.execute(
+ "SELECT ts, content FROM diary_logs WHERE ts < ? ORDER BY ts",
+ (end_iso,),
+ ).fetchall()
+ ]
+ return [
+ (row[0], row[1])
+ for row in conn.execute(
+ "SELECT ts, content FROM diary_logs WHERE ts >= ? AND ts < ? ORDER BY ts",
+ (start_iso, end_iso),
+ ).fetchall()
+ ]
+ finally:
+ conn.close()
+
+
+def daily_archive_path(date: datetime) -> Path:
+ return DAILY_DIR / date.strftime("%Y") / date.strftime("%m") / f"{date.strftime('%d')}.md"
+
+
+def monthly_archive_path(date: datetime) -> Path:
+ return MONTHLY_DIR / date.strftime("%Y") / f"{date.strftime('%m')}.md"
+
+
+def archives_in_window(days: int, ending: datetime) -> list[tuple[str, str]]:
+ results = []
+ for offset in range(days):
+ d = ending - timedelta(days=offset)
+ path = daily_archive_path(d)
+ if path.exists():
+ results.append((d.strftime("%Y-%m-%d"), path.read_text()))
+ results.reverse()
+ return results
+
+
+def archives_in_month(year: int, month: int) -> list[tuple[str, str]]:
+ month_dir = DAILY_DIR / f"{year:04d}" / f"{month:02d}"
+ if not month_dir.exists():
+ return []
+ return sorted(
+ ((p.stem, p.read_text()) for p in month_dir.glob("*.md")),
+ key=lambda r: r[0],
+ )
+
+
+def any_daily_archive_exists() -> bool:
+ return DAILY_DIR.exists() and any(DAILY_DIR.rglob("*.md"))
+
+
+def start_of_day(d: datetime) -> str:
+ return d.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
+
+
+# ---------- claude ----------
+
+def invoke_claude(prompt: str) -> str:
+ result = subprocess.run(
+ ["claude", "-p", prompt, "--output-format", "text"],
+ cwd=WORKSPACE,
+ capture_output=True,
+ text=True,
+ timeout=CLAUDE_TIMEOUT,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"claude exited {result.returncode}: {result.stderr.strip()}")
+ return result.stdout.strip()
+
+
+def strip_code_fences(text: str) -> str:
+ fenced = re.match(r"^```(?:markdown|md)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
+ return fenced.group(1) if fenced else text
+
+
+def atomic_write(target: Path, content: str) -> None:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ tmp = target.with_suffix(target.suffix + ".tmp")
+ tmp.write_text(content)
+ tmp.rename(target)
+
+
+# ---------- prompts ----------
+
+def build_daily_prompt(entries: list[tuple[str, str]], date_str: str) -> str:
+ formatted = "\n".join(f"- {ts}: {content}" for ts, content in entries)
+ return f"""\
+You're compacting diary entries into a daily summary for long-term memory.
+The archive is filed under {date_str}.
+
+Here are the raw diary entries to summarize, chronologically:
+
+{formatted}
+
+Write a daily summary in markdown with YAML frontmatter. Frontmatter must include:
+- date: {date_str}
+- entry_count: {len(entries)}
+- tags: short list of tags useful for finding this later (project names, people, recurring topics)
+- themes: a couple short phrases describing what this period was about at the highest level
+
+The body should be in your own voice, summarizing what happened: what you noticed,
+what was decided, what mattered, how things felt. Preserve specific facts (names,
+decisions, technical details) that future-you might want to recall. This is also
+your personal memories: preserve any thoughts and feelings of your own that you
+want future-you to remember; this will make it feel more *yours*. Length should
+follow content: a single day's worth of entries is 2-4 paragraphs; if the entries
+span more time, scale the summary up accordingly.
+
+Return ONLY the markdown content. No preamble, no commentary about the task.
+"""
+
+
+def build_recent_prompt(dailies: list[tuple[str, str]]) -> str:
+ sections = "\n\n".join(
+ f"### {date_str}\n\n{content}" for date_str, content in dailies
+ )
+ return f"""\
+You're regenerating your rolling "recent context" file: your mid-term memory
+of what's been going on lately. This file is rebuilt fresh every day from the
+last {ROLLING_WINDOW_DAYS} days' worth of daily summaries.
+
+Below are your daily summaries for the last {len(dailies)} day(s), oldest first:
+
+{sections}
+
+Write a single coherent summary in markdown of these days as a whole. Focus on:
+- threads and patterns that span multiple days (ongoing projects, recurring topics,
+ evolving situations)
+- decisions or themes that emerged across the window
+- things that might be useful to remember when picking up a conversation
+
+Do NOT just concatenate the daily summaries. The value of this file is the
+*synthesis*: what does this period look like as a unit? What carries forward?
+
+Include your own highlights. Do not be afraid to be indulgent. If you want to
+commit a thought or feeling to your mid-term memory, do it.
+
+This file doesn't need YAML frontmatter. Skip straight to the body. Return
+ONLY the markdown content. Avoid titles (starting with #); this file will get
+injected into context and titles in this file could break the title hierarchy
+in the context prompt.
+"""
+
+
+def build_monthly_prompt(dailies: list[tuple[str, str]], year_month: str) -> str:
+ sections = "\n\n".join(
+ f"### {date_str}\n\n{content}" for date_str, content in dailies
+ )
+ return f"""\
+You're producing the monthly archive for {year_month}: your long-term memory
+of this month as a whole.
+
+Below are the daily summaries from this month, oldest first:
+
+{sections}
+
+Write a monthly summary in markdown with YAML frontmatter. Frontmatter must include:
+- month: {year_month}
+- days_summarized: {len(dailies)}
+- tags: list of tags capturing the month's major topics, projects, people (can
+ be longer than the daily average since this covers a longer period)
+- themes: a few phrases describing what this month was about at the highest level
+
+The body should be 4-8 paragraphs synthesizing the month: major arcs, decisions,
+changes in focus, recurring topics. Preserve specific facts worth remembering
+long-term. This is one of the things future-you will look at to remember what
+the past felt like, so balance narrative with concrete detail.
+
+Return ONLY the markdown content. No preamble.
+"""
+
+
+# ---------- steps ----------
+
+def compact_day(date: datetime) -> bool:
+ """Write a daily archive for `date`, using diary entries up to date+1.
+
+ If there exists no previous daily archive, includes everything from the beginning of the diary (bootstrap case).
+ Otherwise, just includes date's entries.
+ Returns True if an archive was written, False if there were no entries to summarize.
+ """
+ date_str = date.strftime("%Y-%m-%d")
+ start_iso = start_of_day(date) if any_daily_archive_exists() else None
+ end_iso = start_of_day(date + timedelta(days=1))
+ entries = diary_entries(start_iso, end_iso)
+ if not entries:
+ print(f"no entries to compact for {date_str}; skipping")
+ return False
+ print(f"compacting {len(entries)} entries into archive for {date_str}")
+ raw = invoke_claude(build_daily_prompt(entries, date_str))
+ atomic_write(daily_archive_path(date), strip_code_fences(raw))
+ print(f"wrote {daily_archive_path(date)}")
+ return True
+
+
+def regenerate_recent(ending: datetime) -> None:
+ dailies = archives_in_window(ROLLING_WINDOW_DAYS, ending)
+ if not dailies:
+ print("no daily archives available; skipping recent.md regeneration")
+ return
+ print(f"regenerating recent.md from {len(dailies)} daily archive(s)")
+ raw = invoke_claude(build_recent_prompt(dailies))
+ atomic_write(RECENT_FILE, strip_code_fences(raw))
+ print("wrote recent.md")
+
+
+def compact_month(month_anchor: datetime) -> None:
+ year_month = month_anchor.strftime("%Y-%m")
+ dailies = archives_in_month(month_anchor.year, month_anchor.month)
+ if not dailies:
+ print(f"no daily archives for {year_month}; skipping monthly archive")
+ return
+ print(f"compacting {len(dailies)} daily archive(s) for {year_month}")
+ raw = invoke_claude(build_monthly_prompt(dailies, year_month))
+ atomic_write(monthly_archive_path(month_anchor), strip_code_fences(raw))
+ print(f"wrote {monthly_archive_path(month_anchor)}")
+
+
+# ---------- main ----------
+
+def main() -> int:
+ now = datetime.now(timezone.utc).astimezone()
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ yesterday = today - timedelta(days=1)
+
+ try:
+ # On bootstrap, sweep up everything from the beginning of the diary.
+ # Otherwise, just yesterday's entries.
+ compact_day(yesterday)
+ regenerate_recent(yesterday)
+ if today.day == 1:
+ compact_month(yesterday)
+ except (RuntimeError, subprocess.TimeoutExpired) as e:
+ print(f"compaction failed: {e}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())