From 1b326678724a87305013994c05e7ac35cf9066c4 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 20 Mar 2026 21:10:03 -0400 Subject: [PATCH] feat: scratchpad tool + fix Claude auth token expiry warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cortex/tools/scratch.py with scratch_read/write/append/clear tools - Register all four scratch tools in the orchestrator tool registry - Create inara/SCRATCH.md as the backing file (never distilled/archived) - Fix auth.py: expiresAt reflects short-lived access token (~8h) not the 1-year refresh token — suppress expiry warning when refreshToken is present Co-Authored-By: Claude Sonnet 4.6 --- cortex/routers/auth.py | 15 ++++++-- cortex/tools/__init__.py | 72 ++++++++++++++++++++++++++++++++++++ cortex/tools/scratch.py | 79 ++++++++++++++++++++++++++++++++++++++++ inara/SCRATCH.md | 7 ++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 cortex/tools/scratch.py create mode 100644 inara/SCRATCH.md diff --git a/cortex/routers/auth.py b/cortex/routers/auth.py index 9f51380..b234453 100644 --- a/cortex/routers/auth.py +++ b/cortex/routers/auth.py @@ -27,15 +27,22 @@ def _claude_status() -> dict: try: data = json.loads(CLAUDE_CREDS.read_text()) oauth = data["claudeAiOauth"] + has_refresh = bool(oauth.get("refreshToken")) expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc) now = datetime.now(tz=timezone.utc) hours_remaining = (expires_dt - now).total_seconds() / 3600 + # If a refresh token is present the session is long-lived (~1 year). + # expiresAt only reflects the current access token window (~8 h) and + # rotates automatically — do not warn based on it when a refresh token exists. + warning = not has_refresh and hours_remaining < WARN_HOURS + expired = hours_remaining <= 0 and not has_refresh return { "ok": True, - "expires_at": expires_dt.isoformat(), - "hours_remaining": round(hours_remaining, 1), - "warning": hours_remaining < WARN_HOURS, - "expired": hours_remaining <= 0, + "has_refresh_token": has_refresh, + "access_token_expires_at": expires_dt.isoformat(), + "access_token_hours_remaining": round(hours_remaining, 1), + "warning": warning, + "expired": expired, } except Exception as e: logger.warning("claude auth check failed: %s", e) diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index e8a4840..83ee252 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -23,6 +23,12 @@ from tools.files import file_read as _file_read from tools.system import claude_allow_dir as _claude_allow_dir from tools.tasks import task_list as _task_list, task_create as _task_create from tools.tasks import task_update as _task_update, task_complete as _task_complete +from tools.scratch import ( + scratch_read as _scratch_read, + scratch_write as _scratch_write, + scratch_append as _scratch_append, + scratch_clear as _scratch_clear, +) # --------------------------------------------------------------------------- @@ -179,6 +185,10 @@ _CALLABLES: dict[str, callable] = { "task_create": _task_create, "task_update": _task_update, "task_complete": _task_complete, + "scratch_read": _scratch_read, + "scratch_write": _scratch_write, + "scratch_append": _scratch_append, + "scratch_clear": _scratch_clear, } _claude_allow_dir_declaration = types.FunctionDeclaration( @@ -305,6 +315,64 @@ _task_complete_declaration = types.FunctionDeclaration( ), ) +_scratch_read_declaration = types.FunctionDeclaration( + name="scratch_read", + description=( + "Read the full contents of the scratchpad. " + "Use this to recall working notes, mid-task context, or anything previously jotted down. " + "The scratchpad is transient — nothing here is distilled or archived." + ), + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + +_scratch_write_declaration = types.FunctionDeclaration( + name="scratch_write", + description=( + "Replace the entire scratchpad with new content. " + "Use this to set a clean working note, replacing whatever was there before. " + "For adding without replacing, use scratch_append instead." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "content": types.Schema( + type=types.Type.STRING, + description="The new scratchpad content (markdown supported)", + ), + }, + required=["content"], + ), +) + +_scratch_append_declaration = types.FunctionDeclaration( + name="scratch_append", + description=( + "Add a new section to the bottom of the scratchpad without replacing existing content. " + "Each section gets a timestamp heading unless you supply one." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "content": types.Schema( + type=types.Type.STRING, + description="The content to append (markdown supported)", + ), + "heading": types.Schema( + type=types.Type.STRING, + description="Optional section heading. Defaults to current UTC timestamp.", + ), + }, + required=["content"], + ), +) + +_scratch_clear_declaration = types.FunctionDeclaration( + name="scratch_clear", + description="Erase everything in the scratchpad. Use when the working notes are no longer needed.", + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + + # Gemini Tool object — pass this to GenerateContentConfig TOOL_DECLARATIONS = [ types.Tool(function_declarations=[ @@ -318,6 +386,10 @@ TOOL_DECLARATIONS = [ _task_create_declaration, _task_update_declaration, _task_complete_declaration, + _scratch_read_declaration, + _scratch_write_declaration, + _scratch_append_declaration, + _scratch_clear_declaration, ]) ] diff --git a/cortex/tools/scratch.py b/cortex/tools/scratch.py new file mode 100644 index 0000000..279607b --- /dev/null +++ b/cortex/tools/scratch.py @@ -0,0 +1,79 @@ +""" +Scratchpad tools for Inara. + +A lightweight, persistent notepad stored at inara/SCRATCH.md. +Nothing here is ever distilled or archived — it is intentionally transient. +Good for: working notes mid-task, half-formed ideas, things too long for +a chat response but not worth saving to memory or a journal entry. + +Operations: + scratch_read — return the full contents (or a message if empty) + scratch_write — replace the entire scratchpad + scratch_append — add a new timestamped section at the bottom + scratch_clear — erase everything +""" + +import asyncio +from datetime import datetime, timezone +from pathlib import Path + +from config import settings + + +def _scratch_path() -> Path: + return settings.inara_path() / "SCRATCH.md" + + +def _now_label() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +# --------------------------------------------------------------------------- +# Sync implementations +# --------------------------------------------------------------------------- + +def _scratch_read() -> str: + p = _scratch_path() + if not p.exists() or not p.read_text().strip(): + return "Scratchpad is empty." + return p.read_text() + + +def _scratch_write(content: str) -> str: + _scratch_path().write_text(content.rstrip() + "\n") + return "Scratchpad updated." + + +def _scratch_append(content: str, heading: str | None = None) -> str: + p = _scratch_path() + existing = p.read_text() if p.exists() else "" + label = heading or _now_label() + section = f"\n## {label}\n\n{content.strip()}\n" + p.write_text(existing.rstrip() + "\n" + section) + return f"Appended section: {label}" + + +def _scratch_clear() -> str: + p = _scratch_path() + p.write_text("") + return "Scratchpad cleared." + + +# --------------------------------------------------------------------------- +# Async wrappers +# --------------------------------------------------------------------------- + +async def scratch_read() -> str: + return await asyncio.to_thread(_scratch_read) + + +async def scratch_write(content: str) -> str: + return await asyncio.to_thread(_scratch_write, content) + + +async def scratch_append(content: str, heading: str | None = None) -> str: + return await asyncio.to_thread(_scratch_append, content, heading) + + +async def scratch_clear() -> str: + return await asyncio.to_thread(_scratch_clear) diff --git a/inara/SCRATCH.md b/inara/SCRATCH.md new file mode 100644 index 0000000..979e1d0 --- /dev/null +++ b/inara/SCRATCH.md @@ -0,0 +1,7 @@ +# Inara — Scratchpad + +Transient working notes. Nothing here is distilled or archived. +Use this for mid-task context, half-formed ideas, or anything too long +for a chat response but not worth saving to memory or a journal. + +---