From 36fdda6728033de25daffdd4fb0372236d60f0e0 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Thu, 30 Apr 2026 19:27:53 -0400 Subject: [PATCH] feat: add reminders_remove tool for single-reminder removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reminders_remove(index) removes one reminder by 1-based index - reminders_list now returns numbered output (1. heading / body) so any model can easily identify which index to pass - _parse_sections() / _sections_to_text() helpers for clean round-trip - Not in CONFIRM_REQUIRED — targeted removal is safe without a gate Co-Authored-By: Claude Sonnet 4.6 --- cortex/tools/__init__.py | 21 ++++++++++++++ cortex/tools/reminders.py | 59 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index d612d37..0e0a1d4 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -37,6 +37,7 @@ from tools.cron import ( from tools.reminders import ( reminders_add as _reminders_add, reminders_list as _reminders_list, + reminders_remove as _reminders_remove, reminders_clear as _reminders_clear, ) from tools.scratch import ( @@ -308,6 +309,7 @@ _CALLABLES: dict[str, callable] = { "cron_toggle": _cron_toggle, "reminders_add": _reminders_add, "reminders_list": _reminders_list, + "reminders_remove": _reminders_remove, "reminders_clear": _reminders_clear, "scratch_read": _scratch_read, "scratch_write": _scratch_write, @@ -584,6 +586,24 @@ _reminders_list_declaration = types.FunctionDeclaration( parameters=types.Schema(type=types.Type.OBJECT, properties={}), ) +_reminders_remove_declaration = types.FunctionDeclaration( + 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." + ), + 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).", + ), + }, + required=["index"], + ), +) + _reminders_clear_declaration = types.FunctionDeclaration( name="reminders_clear", description=( @@ -864,6 +884,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = [ _cron_toggle_declaration, _reminders_add_declaration, _reminders_list_declaration, + _reminders_remove_declaration, _reminders_clear_declaration, _scratch_read_declaration, _scratch_write_declaration, diff --git a/cortex/tools/reminders.py b/cortex/tools/reminders.py index 9914b5f..f94e52a 100644 --- a/cortex/tools/reminders.py +++ b/cortex/tools/reminders.py @@ -27,6 +27,28 @@ def _now_label() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") +def _parse_sections(text: str) -> list[tuple[str, str]]: + """Split REMINDERS.md into (heading, body) tuples, one per ## section.""" + sections: list[tuple[str, str]] = [] + heading: str | None = None + body_lines: list[str] = [] + for line in text.splitlines(): + if line.startswith("## "): + if heading is not None: + sections.append((heading, "\n".join(body_lines).strip())) + heading = line[3:].strip() + body_lines = [] + elif heading is not None: + body_lines.append(line) + if heading is not None: + sections.append((heading, "\n".join(body_lines).strip())) + return sections + + +def _sections_to_text(sections: list[tuple[str, str]]) -> str: + return "".join(f"\n## {h}\n\n{b}\n" for h, b in sections) + + # --------------------------------------------------------------------------- # Sync implementations # --------------------------------------------------------------------------- @@ -35,7 +57,18 @@ def _reminders_list() -> str: p = _reminders_path() if not p.exists() or not p.read_text().strip(): return "No pending reminders." - return p.read_text() + sections = _parse_sections(p.read_text()) + if not sections: + 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 + lines.append(f" {bline}") + lines.append("") + return "\n".join(lines).rstrip() def _reminders_add(text: str, label: str | None = None) -> str: @@ -47,6 +80,26 @@ def _reminders_add(text: str, label: str | None = None) -> str: return f"Reminder added: {heading}" +def _reminders_remove(index: int) -> str: + p = _reminders_path() + if not p.exists() or not p.read_text().strip(): + return "No reminders to remove." + sections = _parse_sections(p.read_text()) + if not sections: + return "No reminders to remove." + if index < 1 or index > len(sections): + return ( + f"Index {index} is out of range. " + f"There {'is' if len(sections) == 1 else 'are'} {len(sections)} " + f"reminder{'s' if len(sections) != 1 else ''} (1–{len(sections)}). " + "Call reminders_list to see them." + ) + removed_heading = sections[index - 1][0] + sections.pop(index - 1) + p.write_text(_sections_to_text(sections)) + return f"Removed reminder {index}: {removed_heading}" + + def _reminders_clear() -> str: p = _reminders_path() p.write_text("") @@ -65,5 +118,9 @@ async def reminders_add(text: str, label: str | None = None) -> str: return await asyncio.to_thread(_reminders_add, text, label) +async def reminders_remove(index: int) -> str: + return await asyncio.to_thread(_reminders_remove, index) + + async def reminders_clear() -> str: return await asyncio.to_thread(_reminders_clear)