feat: scratchpad tool + fix Claude auth token expiry warning
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
]
|
||||
|
||||
|
||||
79
cortex/tools/scratch.py
Normal file
79
cortex/tools/scratch.py
Normal file
@@ -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)
|
||||
7
inara/SCRATCH.md
Normal file
7
inara/SCRATCH.md
Normal file
@@ -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.
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user