From 8e512d4e118e0cd77261b9e10dc72109a276ea68 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 8 May 2026 21:46:45 -0400 Subject: [PATCH] feat: reminders due-date support + context filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reminders_add now accepts optional due: YYYY-MM-DD parameter. Due date stored as first line of section body in REMINDERS.md. context_loader.py calls load_due_reminders() instead of loading REMINDERS.md wholesale — future-dated reminders are suppressed in the system prompt until their date arrives. Undated reminders always surface (backward compatible). reminders_list shows due status per entry: [OVERDUE by N days], [due TODAY], or [due: YYYY-MM-DD] for future items. All reminders visible via the tool regardless of date; only context surfacing is filtered. Co-Authored-By: Claude Sonnet 4.6 --- cortex/context_loader.py | 12 ++-- cortex/tools/reminders.py | 128 +++++++++++++++++++++++++++------- documentation/TODO__Agents.md | 9 +-- 3 files changed, 113 insertions(+), 36 deletions(-) diff --git a/cortex/context_loader.py b/cortex/context_loader.py index 4c90bb1..df3c67d 100644 --- a/cortex/context_loader.py +++ b/cortex/context_loader.py @@ -2,6 +2,7 @@ from datetime import datetime from pathlib import Path from persona import persona_path +from tools.reminders import load_due_reminders _STATIC_DIR = Path(__file__).parent / "static" @@ -80,12 +81,11 @@ def load_context( parts.append(f"--- HELP.md ---\n{help_path.read_text()}") # ── 4. Pending reminders (tier 2+) ──────────────────────────── - # Written by cron jobs; cleared by Inara after acting on them. - reminders_path = inara_dir / "REMINDERS.md" - if reminders_path.exists() and reminders_path.stat().st_size > 10: - content = reminders_path.read_text().strip() - if content: - parts.append(f"--- REMINDERS.md ---\n{content}") + # Only due and undated reminders are surfaced — future-dated ones + # are stored in REMINDERS.md but suppressed until their date arrives. + content = load_due_reminders() + if content: + parts.append(f"--- REMINDERS.md ---\n{content}") # ── 5. Tiered memory — long → mid → short ───────────────────── # Short is last so it sits closest to the conversation turn. diff --git a/cortex/tools/reminders.py b/cortex/tools/reminders.py index e8f6f6e..f9708d6 100644 --- a/cortex/tools/reminders.py +++ b/cortex/tools/reminders.py @@ -2,18 +2,20 @@ Reminders tools. Reminders are stored in persona/REMINDERS.md and automatically surfaced -in the system prompt at Tier 2+. Use these tools to add, list, and clear -pending reminders. +in the system prompt at Tier 2+. Each reminder can have an optional due date — +only due or undated reminders surface in context; future-dated ones are stored +but invisible until their date arrives. Operations: - reminders_add — append a new reminder entry - reminders_list — return all current reminders (or a message if empty) - reminders_clear — erase all reminders (moved here from cron.py for consistency; - cron.py still calls the same underlying file) + reminders_add — append a new reminder, optional due date (YYYY-MM-DD) + reminders_list — return all reminders with due status (including future) + reminders_remove — remove a single reminder by number + reminders_clear — erase all reminders """ import asyncio -from datetime import datetime, timezone +import re +from datetime import datetime, timezone, date as _date from pathlib import Path from google.genai import types @@ -50,6 +52,46 @@ def _sections_to_text(sections: list[tuple[str, str]]) -> str: return "".join(f"\n## {h}\n\n{b}\n" for h, b in sections) +def _parse_due(body: str) -> _date | None: + """Extract due date from a 'due: YYYY-MM-DD' line in the body, if present.""" + m = re.search(r'^due:\s*(\d{4}-\d{2}-\d{2})', body, re.MULTILINE | re.IGNORECASE) + if not m: + return None + try: + return _date.fromisoformat(m.group(1)) + except ValueError: + return None + + +def _today() -> _date: + return datetime.now().astimezone().date() + + +def _is_due_or_undated(body: str) -> bool: + """Return True if this reminder has no due date or its due date is today or past.""" + due = _parse_due(body) + return due is None or due <= _today() + + +def _due_label(body: str) -> str: + """Return a human-readable due status string for reminders_list output.""" + due = _parse_due(body) + if due is None: + return "" + today = _today() + if due < today: + days = (today - due).days + return f" [OVERDUE by {days} day{'s' if days != 1 else ''} — due {due}]" + if due == today: + return " [due TODAY]" + return f" [due: {due}]" + + +def _body_without_due(body: str) -> str: + """Strip the due: line from body for display (due status shown in heading line).""" + return re.sub(r'^due:\s*\S+\s*\n?', '', body, count=1, flags=re.MULTILINE | re.IGNORECASE).strip() + + # --------------------------------------------------------------------------- # Sync implementations # --------------------------------------------------------------------------- @@ -63,22 +105,29 @@ def _reminders_list() -> str: return "No pending reminders." lines = [] for i, (heading, body) in enumerate(sections, 1): - lines.append(f"{i}. {heading}") - if body: - # Indent body so it reads as belonging to the numbered item - for bline in body.splitlines()[:4]: # cap at 4 lines for brevity + status = _due_label(body) + lines.append(f"{i}. {heading}{status}") + display_body = _body_without_due(body) + if display_body: + for bline in display_body.splitlines()[:4]: lines.append(f" {bline}") lines.append("") return "\n".join(lines).rstrip() -def _reminders_add(text: str, label: str | None = None) -> str: +def _reminders_add(text: str, label: str | None = None, due: str | None = None) -> str: p = _reminders_path() existing = p.read_text() if p.exists() else "" heading = label or _now_label() - section = f"\n## {heading}\n\n{text.strip()}\n" + body = text.strip() + if due: + body = f"due: {due}\n{body}" + section = f"\n## {heading}\n\n{body}\n" p.write_text(existing.rstrip() + "\n" + section) - return f"Reminder added: {heading}" + msg = f"Reminder added: {heading}" + if due: + msg += f" (due: {due})" + return msg def _reminders_remove(index: int) -> str: @@ -107,6 +156,31 @@ def _reminders_clear() -> str: return "All reminders cleared." +# --------------------------------------------------------------------------- +# Public helper for context_loader +# --------------------------------------------------------------------------- + +def load_due_reminders() -> str: + """Return REMINDERS.md content filtered to only due and undated sections. + + Called by context_loader at Tier 2+. Future-dated reminders are excluded + from the system prompt until their due date arrives. + """ + p = _reminders_path() + if not p.exists(): + return "" + text = p.read_text() + if not text.strip(): + return "" + sections = _parse_sections(text) + due_sections = [(h, b) for h, b in sections if _is_due_or_undated(b)] + if not due_sections: + return "" + # Strip the raw due: line from body — the date is already part of the heading context + cleaned = [(h, _body_without_due(b)) for h, b in due_sections] + return _sections_to_text(cleaned).strip() + + # --------------------------------------------------------------------------- # Async wrappers # --------------------------------------------------------------------------- @@ -115,8 +189,8 @@ async def reminders_list() -> str: return await asyncio.to_thread(_reminders_list) -async def reminders_add(text: str, label: str | None = None) -> str: - return await asyncio.to_thread(_reminders_add, text, label) +async def reminders_add(text: str, label: str | None = None, due: str | None = None) -> str: + return await asyncio.to_thread(_reminders_add, text, label, due) async def reminders_remove(index: int) -> str: @@ -132,15 +206,17 @@ DECLARATIONS = [ name="reminders_add", description=( "Add a new reminder to REMINDERS.md. Reminders are automatically surfaced " - "in your context at the start of each session (Tier 2+). " - "Use this when the user asks you to remember something, follow up on something, " - "or surface a note at the next session." + "in context at the start of each session (Tier 2+). " + "Use this when the user asks you to remember something or follow up on something. " + "Set a due date to suppress the reminder until that date — useful for future tasks " + "that would be noise today." ), parameters=types.Schema( type=types.Type.OBJECT, properties={ - "text": types.Schema(type=types.Type.STRING, description="The reminder text to add"), - "label": types.Schema(type=types.Type.STRING, description="Optional heading for this reminder (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."), + "text": types.Schema(type=types.Type.STRING, description="The reminder text"), + "label": types.Schema(type=types.Type.STRING, description="Optional heading (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."), + "due": types.Schema(type=types.Type.STRING, description="Optional due date in YYYY-MM-DD format. Reminder is hidden from context until this date arrives. Omit for an always-visible reminder."), }, required=["text"], ), @@ -148,9 +224,9 @@ DECLARATIONS = [ types.FunctionDeclaration( name="reminders_list", description=( - "Read all current pending reminders from REMINDERS.md. " - "Use this to check what reminders are queued before adding duplicates, " - "or to show the user what's pending." + "Read all pending reminders, including future-dated ones not yet in context. " + "Shows due status for each (due today, overdue, or future date). " + "Use this before adding to avoid duplicates, or to show the user what's queued." ), parameters=types.Schema(type=types.Type.OBJECT, properties={}), ), @@ -158,12 +234,12 @@ DECLARATIONS = [ name="reminders_remove", description=( "Remove a single reminder by its number. " - "Call reminders_list first to get the numbered list, then pass the number of the reminder to remove." + "Call reminders_list first to get the numbered list, then pass the number to remove." ), parameters=types.Schema( type=types.Type.OBJECT, properties={ - "index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first item in reminders_list output)."), + "index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first in reminders_list output)."), }, required=["index"], ), diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 98738f4..3735b8c 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -76,10 +76,11 @@ system prompt by `context_loader.py` at all tiers. - Params: `query: str`, `limit: int = 5` (max 20) - Returns: excerpts with session date, newest first; own sessions only via ContextVars - User-level (no TOOL_ROLES entry needed) -- [ ] **`reminders` due-date support** — make reminders time-aware - - Add optional `due: str` (ISO date or natural language) to `reminders_add` - - Filter context surfacing: only show reminders where `due` is today or past (or no due date) - - `reminders_list` should show due date and overdue status +- [x] **`reminders` due-date support** — make reminders time-aware — 2026-05-08 + - Optional `due: YYYY-MM-DD` on `reminders_add`; stored as `due: date` first line of body + - `context_loader.py` calls `load_due_reminders()` — future-dated sections suppressed until due + - `reminders_list` shows `[OVERDUE]`, `[due TODAY]`, or `[due: YYYY-MM-DD]` per entry + - Backward compatible — existing undated reminders always surface as before - [ ] **`http_post`** — POST to external URLs - Params: `url: str`, `body: dict | str`, `headers: dict | None` - Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email)