feat: reminders due-date support + context filtering
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from persona import persona_path
|
from persona import persona_path
|
||||||
|
from tools.reminders import load_due_reminders
|
||||||
|
|
||||||
_STATIC_DIR = Path(__file__).parent / "static"
|
_STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -80,12 +81,11 @@ def load_context(
|
|||||||
parts.append(f"--- HELP.md ---\n{help_path.read_text()}")
|
parts.append(f"--- HELP.md ---\n{help_path.read_text()}")
|
||||||
|
|
||||||
# ── 4. Pending reminders (tier 2+) ────────────────────────────
|
# ── 4. Pending reminders (tier 2+) ────────────────────────────
|
||||||
# Written by cron jobs; cleared by Inara after acting on them.
|
# Only due and undated reminders are surfaced — future-dated ones
|
||||||
reminders_path = inara_dir / "REMINDERS.md"
|
# are stored in REMINDERS.md but suppressed until their date arrives.
|
||||||
if reminders_path.exists() and reminders_path.stat().st_size > 10:
|
content = load_due_reminders()
|
||||||
content = reminders_path.read_text().strip()
|
if content:
|
||||||
if content:
|
parts.append(f"--- REMINDERS.md ---\n{content}")
|
||||||
parts.append(f"--- REMINDERS.md ---\n{content}")
|
|
||||||
|
|
||||||
# ── 5. Tiered memory — long → mid → short ─────────────────────
|
# ── 5. Tiered memory — long → mid → short ─────────────────────
|
||||||
# Short is last so it sits closest to the conversation turn.
|
# Short is last so it sits closest to the conversation turn.
|
||||||
|
|||||||
@@ -2,18 +2,20 @@
|
|||||||
Reminders tools.
|
Reminders tools.
|
||||||
|
|
||||||
Reminders are stored in persona/REMINDERS.md and automatically surfaced
|
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
|
in the system prompt at Tier 2+. Each reminder can have an optional due date —
|
||||||
pending reminders.
|
only due or undated reminders surface in context; future-dated ones are stored
|
||||||
|
but invisible until their date arrives.
|
||||||
|
|
||||||
Operations:
|
Operations:
|
||||||
reminders_add — append a new reminder entry
|
reminders_add — append a new reminder, optional due date (YYYY-MM-DD)
|
||||||
reminders_list — return all current reminders (or a message if empty)
|
reminders_list — return all reminders with due status (including future)
|
||||||
reminders_clear — erase all reminders (moved here from cron.py for consistency;
|
reminders_remove — remove a single reminder by number
|
||||||
cron.py still calls the same underlying file)
|
reminders_clear — erase all reminders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
import re
|
||||||
|
from datetime import datetime, timezone, date as _date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from google.genai import types
|
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)
|
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
|
# Sync implementations
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -63,22 +105,29 @@ def _reminders_list() -> str:
|
|||||||
return "No pending reminders."
|
return "No pending reminders."
|
||||||
lines = []
|
lines = []
|
||||||
for i, (heading, body) in enumerate(sections, 1):
|
for i, (heading, body) in enumerate(sections, 1):
|
||||||
lines.append(f"{i}. {heading}")
|
status = _due_label(body)
|
||||||
if body:
|
lines.append(f"{i}. {heading}{status}")
|
||||||
# Indent body so it reads as belonging to the numbered item
|
display_body = _body_without_due(body)
|
||||||
for bline in body.splitlines()[:4]: # cap at 4 lines for brevity
|
if display_body:
|
||||||
|
for bline in display_body.splitlines()[:4]:
|
||||||
lines.append(f" {bline}")
|
lines.append(f" {bline}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines).rstrip()
|
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()
|
p = _reminders_path()
|
||||||
existing = p.read_text() if p.exists() else ""
|
existing = p.read_text() if p.exists() else ""
|
||||||
heading = label or _now_label()
|
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)
|
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:
|
def _reminders_remove(index: int) -> str:
|
||||||
@@ -107,6 +156,31 @@ def _reminders_clear() -> str:
|
|||||||
return "All reminders cleared."
|
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
|
# Async wrappers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -115,8 +189,8 @@ async def reminders_list() -> str:
|
|||||||
return await asyncio.to_thread(_reminders_list)
|
return await asyncio.to_thread(_reminders_list)
|
||||||
|
|
||||||
|
|
||||||
async def reminders_add(text: str, label: str | None = None) -> str:
|
async def reminders_add(text: str, label: str | None = None, due: str | None = None) -> str:
|
||||||
return await asyncio.to_thread(_reminders_add, text, label)
|
return await asyncio.to_thread(_reminders_add, text, label, due)
|
||||||
|
|
||||||
|
|
||||||
async def reminders_remove(index: int) -> str:
|
async def reminders_remove(index: int) -> str:
|
||||||
@@ -132,15 +206,17 @@ DECLARATIONS = [
|
|||||||
name="reminders_add",
|
name="reminders_add",
|
||||||
description=(
|
description=(
|
||||||
"Add a new reminder to REMINDERS.md. Reminders are automatically surfaced "
|
"Add a new reminder to REMINDERS.md. Reminders are automatically surfaced "
|
||||||
"in your context at the start of each session (Tier 2+). "
|
"in context at the start of each session (Tier 2+). "
|
||||||
"Use this when the user asks you to remember something, follow up on something, "
|
"Use this when the user asks you to remember something or follow up on something. "
|
||||||
"or surface a note at the next session."
|
"Set a due date to suppress the reminder until that date — useful for future tasks "
|
||||||
|
"that would be noise today."
|
||||||
),
|
),
|
||||||
parameters=types.Schema(
|
parameters=types.Schema(
|
||||||
type=types.Type.OBJECT,
|
type=types.Type.OBJECT,
|
||||||
properties={
|
properties={
|
||||||
"text": types.Schema(type=types.Type.STRING, description="The reminder text to add"),
|
"text": types.Schema(type=types.Type.STRING, description="The reminder text"),
|
||||||
"label": types.Schema(type=types.Type.STRING, description="Optional heading for this reminder (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."),
|
"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"],
|
required=["text"],
|
||||||
),
|
),
|
||||||
@@ -148,9 +224,9 @@ DECLARATIONS = [
|
|||||||
types.FunctionDeclaration(
|
types.FunctionDeclaration(
|
||||||
name="reminders_list",
|
name="reminders_list",
|
||||||
description=(
|
description=(
|
||||||
"Read all current pending reminders from REMINDERS.md. "
|
"Read all pending reminders, including future-dated ones not yet in context. "
|
||||||
"Use this to check what reminders are queued before adding duplicates, "
|
"Shows due status for each (due today, overdue, or future date). "
|
||||||
"or to show the user what's pending."
|
"Use this before adding to avoid duplicates, or to show the user what's queued."
|
||||||
),
|
),
|
||||||
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
|
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
|
||||||
),
|
),
|
||||||
@@ -158,12 +234,12 @@ DECLARATIONS = [
|
|||||||
name="reminders_remove",
|
name="reminders_remove",
|
||||||
description=(
|
description=(
|
||||||
"Remove a single reminder by its number. "
|
"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(
|
parameters=types.Schema(
|
||||||
type=types.Type.OBJECT,
|
type=types.Type.OBJECT,
|
||||||
properties={
|
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"],
|
required=["index"],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ system prompt by `context_loader.py` at all tiers.
|
|||||||
- Params: `query: str`, `limit: int = 5` (max 20)
|
- Params: `query: str`, `limit: int = 5` (max 20)
|
||||||
- Returns: excerpts with session date, newest first; own sessions only via ContextVars
|
- Returns: excerpts with session date, newest first; own sessions only via ContextVars
|
||||||
- User-level (no TOOL_ROLES entry needed)
|
- User-level (no TOOL_ROLES entry needed)
|
||||||
- [ ] **`reminders` due-date support** — make reminders time-aware
|
- [x] **`reminders` due-date support** — make reminders time-aware — 2026-05-08
|
||||||
- Add optional `due: str` (ISO date or natural language) to `reminders_add`
|
- Optional `due: YYYY-MM-DD` on `reminders_add`; stored as `due: date` first line of body
|
||||||
- Filter context surfacing: only show reminders where `due` is today or past (or no due date)
|
- `context_loader.py` calls `load_due_reminders()` — future-dated sections suppressed until due
|
||||||
- `reminders_list` should show due date and overdue status
|
- `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
|
- [ ] **`http_post`** — POST to external URLs
|
||||||
- Params: `url: str`, `body: dict | str`, `headers: dict | None`
|
- Params: `url: str`, `body: dict | str`, `headers: dict | None`
|
||||||
- Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email)
|
- Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email)
|
||||||
|
|||||||
Reference in New Issue
Block a user