From ed191cf0b4e53b9e94a1531c93d4fefbed63ad3a Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 28 Apr 2026 22:02:22 -0400 Subject: [PATCH] feat: add journal entry update, disable, append, prepend tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new tools for full journal entry lifecycle management: - ae_journal_entry_update — PATCH any combination of fields (title, content, summary, tags, enable); only provided fields are changed - ae_journal_entry_disable — soft-delete via enable=false - ae_journal_entry_append — fetch entry, append timestamped section to the bottom (ideal for running logs / data logs) - ae_journal_entry_prepend — fetch entry, prepend timestamped section to the top (most-recent-first pattern) Shared _get_entry / _patch_entry helpers keep the read-modify-write logic DRY. Also fixed journal_entry_create to prefer the canonical journal_entry_id field over the legacy id_random alias. Co-Authored-By: Claude Sonnet 4.6 --- cortex/tools/__init__.py | 87 +++++++++++++++++- cortex/tools/ae_knowledge.py | 167 ++++++++++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index a1b700f..d8026c8 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -17,8 +17,12 @@ IMPORTANT: These tools are separate from the ae_* MCP tools used by the fleet ag from google.genai import types from tools.web import search as _web_search from tools.ae_knowledge import journal_search as _ae_journal_search -from tools.ae_knowledge import journal_entry_create as _ae_journal_entry_create from tools.ae_knowledge import journal_list as _ae_journal_list +from tools.ae_knowledge import journal_entry_create as _ae_journal_entry_create +from tools.ae_knowledge import journal_entry_update as _ae_journal_entry_update +from tools.ae_knowledge import journal_entry_disable as _ae_journal_entry_disable +from tools.ae_knowledge import journal_entry_append as _ae_journal_entry_append +from tools.ae_knowledge import journal_entry_prepend as _ae_journal_entry_prepend from tools.ae_tasks import task_list as _ae_task_list from tools.files import file_read as _file_read from tools.system import claude_allow_dir as _claude_allow_dir, shell_exec as _shell_exec @@ -148,6 +152,79 @@ _ae_journal_entry_create_declaration = types.FunctionDeclaration( ), ) +_ae_journal_entry_update_declaration = types.FunctionDeclaration( + name="ae_journal_entry_update", + description=( + "Update fields on an existing journal entry. Only the fields you provide are changed — " + "omitted fields are left as-is. Use ae_journal_search to find the entry_id first. " + "To soft-delete, use ae_journal_entry_disable instead." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "entry_id": types.Schema(type=types.Type.STRING, description="Journal entry id_random"), + "title": types.Schema(type=types.Type.STRING, description="New title"), + "content": types.Schema(type=types.Type.STRING, description="Replacement content (full, markdown supported)"), + "summary": types.Schema(type=types.Type.STRING, description="New summary"), + "tags": types.Schema(type=types.Type.STRING, description="Replacement comma-separated tags"), + "enable": types.Schema(type=types.Type.BOOLEAN, description="Set false to hide/disable the entry"), + }, + required=["entry_id"], + ), +) + +_ae_journal_entry_disable_declaration = types.FunctionDeclaration( + name="ae_journal_entry_disable", + description=( + "Soft-delete a journal entry by setting enable=false. " + "The entry is hidden but not permanently removed. " + "Use ae_journal_search to find the entry_id first." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "entry_id": types.Schema(type=types.Type.STRING, description="Journal entry id_random"), + }, + required=["entry_id"], + ), +) + +_ae_journal_entry_append_declaration = types.FunctionDeclaration( + name="ae_journal_entry_append", + description=( + "Append a new section to the bottom of a journal entry's content. " + "Each section gets a UTC timestamp heading unless you provide one. " + "Ideal for timestamped logs, running notes, or data logs." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "entry_id": types.Schema(type=types.Type.STRING, description="Journal entry id_random"), + "content": types.Schema(type=types.Type.STRING, description="The text to append (markdown supported)"), + "heading": types.Schema(type=types.Type.STRING, description="Optional section heading (defaults to current UTC timestamp)"), + }, + required=["entry_id", "content"], + ), +) + +_ae_journal_entry_prepend_declaration = types.FunctionDeclaration( + name="ae_journal_entry_prepend", + description=( + "Prepend a new section to the top of a journal entry's content. " + "Each section gets a UTC timestamp heading unless you provide one. " + "Useful for most-recent-first logs." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "entry_id": types.Schema(type=types.Type.STRING, description="Journal entry id_random"), + "content": types.Schema(type=types.Type.STRING, description="The text to prepend (markdown supported)"), + "heading": types.Schema(type=types.Type.STRING, description="Optional section heading (defaults to current UTC timestamp)"), + }, + required=["entry_id", "content"], + ), +) + _ae_task_list_declaration = types.FunctionDeclaration( name="ae_task_list", description=( @@ -202,6 +279,10 @@ _CALLABLES: dict[str, callable] = { "ae_journal_list": _ae_journal_list, "ae_journal_search": _ae_journal_search, "ae_journal_entry_create": _ae_journal_entry_create, + "ae_journal_entry_update": _ae_journal_entry_update, + "ae_journal_entry_disable": _ae_journal_entry_disable, + "ae_journal_entry_append": _ae_journal_entry_append, + "ae_journal_entry_prepend": _ae_journal_entry_prepend, "ae_task_list": _ae_task_list, "file_read": _file_read, "claude_allow_dir": _claude_allow_dir, @@ -567,6 +648,10 @@ TOOL_DECLARATIONS = [ _ae_journal_list_declaration, _ae_journal_search_declaration, _ae_journal_entry_create_declaration, + _ae_journal_entry_update_declaration, + _ae_journal_entry_disable_declaration, + _ae_journal_entry_append_declaration, + _ae_journal_entry_prepend_declaration, _ae_task_list_declaration, _file_read_declaration, _claude_allow_dir_declaration, diff --git a/cortex/tools/ae_knowledge.py b/cortex/tools/ae_knowledge.py index 64a3387..ad66e9c 100644 --- a/cortex/tools/ae_knowledge.py +++ b/cortex/tools/ae_knowledge.py @@ -1,11 +1,12 @@ """ -Aether Platform knowledge tools — journal search and entry creation. +Aether Platform knowledge tools — journal search, listing, and entry management. These tools give the orchestrator read/write access to the AE Journals module, which serves as the primary long-term knowledge base. Auth: x-aether-api-key + x-account-id headers (same pattern as agents_sync scripts). API: V3 CRUD — POST /v3/crud/journal_entry/search, POST /v3/crud/journal/{id}/journal_entry/ + PATCH /v3/crud/journal_entry/{entry_id}, GET /v3/crud/journal_entry/{entry_id} """ import asyncio @@ -216,8 +217,170 @@ def _sync_journal_entry_create( return f"Journal entry creation error: {e}" entry_id = ( - result.get("data", {}).get("id_random") + result.get("data", {}).get("journal_entry_id") + or result.get("data", {}).get("id_random") or result.get("id_random") or "unknown" ) return f"Journal entry created. id: `{entry_id}`, title: \"{title}\", journal: `{journal_id}`" + + +# --------------------------------------------------------------------------- +# Shared helper: fetch a single journal entry by id +# --------------------------------------------------------------------------- + +def _get_entry(entry_id: str) -> dict | str: + """Return the entry dict, or an error string on failure.""" + import requests + url = f"{settings.ae_api_url}/v3/crud/journal_entry/{entry_id}" + try: + resp = requests.get(url, headers=_headers(), timeout=settings.ae_api_timeout) + resp.raise_for_status() + data = resp.json() + entry = data.get("data") or data + if not isinstance(entry, dict): + return f"Unexpected response shape for entry {entry_id}" + return entry + except Exception as e: + logger.warning("_get_entry %s failed: %s", entry_id, e) + return f"Error fetching entry {entry_id}: {e}" + + +def _patch_entry(entry_id: str, payload: dict) -> str: + """PATCH a journal entry. Returns a success/error string.""" + import requests + url = f"{settings.ae_api_url}/v3/crud/journal_entry/{entry_id}" + try: + resp = requests.patch( + url, + headers=_headers(), + json=payload, + timeout=settings.ae_api_timeout, + ) + resp.raise_for_status() + return "ok" + except Exception as e: + logger.warning("_patch_entry %s failed: %s", entry_id, e) + return f"Error updating entry {entry_id}: {e}" + + +# --------------------------------------------------------------------------- +# Tool: ae_journal_entry_update +# --------------------------------------------------------------------------- + +async def journal_entry_update( + entry_id: str, + title: str = "", + content: str = "", + summary: str = "", + tags: str = "", + enable: bool | None = None, +) -> str: + """Update fields on an existing journal entry. Only provided fields are changed.""" + err = _check_config() + if err: + return err + return await asyncio.to_thread(_sync_journal_entry_update, entry_id, title, content, summary, tags, enable) + + +def _sync_journal_entry_update( + entry_id: str, + title: str, + content: str, + summary: str, + tags: str, + enable: bool | None, +) -> str: + payload: dict = {} + if title: + payload["name"] = title + if content: + payload["content"] = content + if summary: + payload["summary"] = summary + if tags: + payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + if enable is not None: + payload["enable"] = enable + + if not payload: + return "Nothing to update — no fields provided." + + result = _patch_entry(entry_id, payload) + if result != "ok": + return result + + updated = ", ".join(payload.keys()) + return f"Journal entry `{entry_id}` updated. Fields changed: {updated}" + + +# --------------------------------------------------------------------------- +# Tool: ae_journal_entry_disable +# --------------------------------------------------------------------------- + +async def journal_entry_disable(entry_id: str) -> str: + """Soft-delete a journal entry by setting enable=false.""" + err = _check_config() + if err: + return err + return await asyncio.to_thread(_patch_entry, entry_id, {"enable": False}) + + +# --------------------------------------------------------------------------- +# Tool: ae_journal_entry_append +# --------------------------------------------------------------------------- + +async def journal_entry_append(entry_id: str, content: str, heading: str = "") -> str: + """Append a timestamped section to the bottom of a journal entry's content.""" + err = _check_config() + if err: + return err + return await asyncio.to_thread(_sync_journal_entry_append, entry_id, content, heading) + + +def _sync_journal_entry_append(entry_id: str, content: str, heading: str) -> str: + from datetime import datetime, timezone + + entry = _get_entry(entry_id) + if isinstance(entry, str): + return entry + + existing = (entry.get("content") or "").rstrip() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + section_heading = heading or ts + new_content = f"{existing}\n\n### {section_heading}\n{content.strip()}" + + result = _patch_entry(entry_id, {"content": new_content}) + if result != "ok": + return result + return f"Appended to journal entry `{entry_id}` under heading \"{section_heading}\"." + + +# --------------------------------------------------------------------------- +# Tool: ae_journal_entry_prepend +# --------------------------------------------------------------------------- + +async def journal_entry_prepend(entry_id: str, content: str, heading: str = "") -> str: + """Prepend a timestamped section to the top of a journal entry's content.""" + err = _check_config() + if err: + return err + return await asyncio.to_thread(_sync_journal_entry_prepend, entry_id, content, heading) + + +def _sync_journal_entry_prepend(entry_id: str, content: str, heading: str) -> str: + from datetime import datetime, timezone + + entry = _get_entry(entry_id) + if isinstance(entry, str): + return entry + + existing = (entry.get("content") or "").lstrip() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + section_heading = heading or ts + new_content = f"### {section_heading}\n{content.strip()}\n\n{existing}" + + result = _patch_entry(entry_id, {"content": new_content}) + if result != "ok": + return result + return f"Prepended to journal entry `{entry_id}` under heading \"{section_heading}\"."