Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions docs/REDIS_READ_CACHE_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,25 +226,25 @@ Random tokens prevent an evicted generation key from returning to an old integer
reviving stale data. A read that fills after concurrent invalidation also remains safe because its
old token no longer matches.

## Initial Cache Surface
## Cache Surface And Production TTL

Phase one:

| Operation | Initial TTL | Constraints |
| --------------------------- | ----------: | --------------------------------------------------- |
| Entity by external ID | 60 seconds | Cache validated `EntityResponseV2` JSON |
| Identifier resolution | 60 seconds | Include body and workspace context |
| Markdown note resource | 60 seconds | Cache only below an explicit size limit |
| Directory structure | 60 seconds | Folder-only tree; two MiB payload cap |
| Directory tree | 60 seconds | Full hierarchy; two MiB measured payload cap |
| Paginated directory listing | 60 seconds | Include path, depth, glob, page, and page-size keys |
| Operation | Production TTL | Constraints |
| --------------------------- | -------------: | --------------------------------------------------- |
| Entity by external ID | 300 seconds | Cache validated `EntityResponseV2` JSON |
| Identifier resolution | 300 seconds | Include body and workspace context |
| Markdown note resource | 300 seconds | Cache only below an explicit size limit |
| Directory structure | 300 seconds | Folder-only tree; two MiB payload cap |
| Directory tree | 300 seconds | Full hierarchy; two MiB measured payload cap |
| Paginated directory listing | 300 seconds | Include path, depth, glob, page, and page-size keys |

Additional measured surfaces:

| Operation | Initial TTL | Constraints |
| --------------------------- | ------------: | ---------------------------------------------- |
| Search | 30 seconds | Implemented; complete query plus pagination key |
| Context and recent activity | 15-30 seconds | Future; normalize or bound time-relative inputs |
| Operation | Production TTL | Constraints |
| --------------------------- | -------------: | ----------------------------------------------- |
| Search | 30 seconds | Implemented; complete query plus pagination key |
| Context and recent activity | 15-30 seconds | Future; normalize or bound time-relative inputs |

Do not initially cache failures, missing entities, graph/orphan responses, large or arbitrary
binary resources, schema inference, writes, or Cloud control-plane data.
Expand Down Expand Up @@ -437,7 +437,8 @@ response-cache layers as the final design.
- Reads bypass Redis and use the authoritative path when the cache is unavailable.
- Cache-store failures do not fail an otherwise successful read.
- Cache-invalidation failures do not fail committed writes, but they emit prominent telemetry.
- Short initial TTLs bound stale-data exposure after an invalidation failure and Redis recovery.
- Bounded operation TTLs limit stale-data exposure after an invalidation failure and Redis
recovery.
- Redis client-input errors plus local serialization, decoding, and programming errors fail fast
rather than masquerading as cache misses.

Expand All @@ -447,12 +448,14 @@ Rate-limit failure behavior remains entirely Cloud-owned.

Record:

- hit, miss, bypass, store, invalidation, unavailable, and oversize outcomes;
- operation name without tenant or project metric labels;
- distinct lookup and store outcomes, including hit, miss, bypass, store, invalidation,
unavailable, corrupt, and oversize;
- operation name and configured TTL without tenant or project metric labels;
- remaining TTL on cache hits;
- Redis operation latency;
- cached payload size;
- authoritative read latency on misses;
- hashed scope and request identifiers on diagnostic spans only.
- hashed scope, request, and generation identifiers on diagnostic spans only.

Do not add public cache headers in the first version.

Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/mcp/clients/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
DirectoryDeleteResult,
)
from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse
from basic_memory.schemas.v2.entity import EntityResolveResponse
from basic_memory.schemas.v2.entity import EntityResolveResponse, EntityResponseV2


class KnowledgeClient:
Expand Down Expand Up @@ -112,14 +112,14 @@ async def update_entity(
)
return EntityResponse.model_validate(response.json())

async def get_entity(self, entity_id: str) -> EntityResponse:
async def get_entity(self, entity_id: str) -> EntityResponseV2:
"""Get an entity by ID.

Args:
entity_id: Entity external_id (UUID)

Returns:
EntityResponse with entity details
EntityResponseV2 with accepted note content and entity metadata

Raises:
ToolError: If the entity is not found or request fails
Expand All @@ -138,7 +138,7 @@ async def get_entity(self, entity_id: str) -> EntityResponse:
operation="get_entity",
path_template="/v2/projects/{project_id}/knowledge/entities/{entity_id}",
)
return EntityResponse.model_validate(response.json())
return EntityResponseV2.model_validate(response.json())

async def patch_entity(
self,
Expand Down
106 changes: 106 additions & 0 deletions src/basic_memory/mcp/note_reads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Reusable typed note-read shaping for MCP adapters."""

from typing import Any, Protocol, TypedDict

import logfire
import yaml
from httpx import Response

from basic_memory.schemas.v2 import EntityResponseV2


class ReadNoteJsonPayload(TypedDict):
"""Existing successful ``read_note(output_format="json")`` payload."""

title: str
permalink: str | None
file_path: str
content: str
frontmatter: dict[str, Any] | None


class KnowledgeEntityReader(Protocol):
"""Entity-read capability required by exact-ID note reads."""

async def get_entity(self, entity_id: str) -> EntityResponseV2:
"""Return the entity response for one exact external ID."""


class NoteResourceReader(Protocol):
"""Resource-read capability used only for legacy entities without content."""

async def read(self, entity_id: str) -> Response:
"""Return raw resource content for one exact external ID."""


def parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]:
"""Parse opening YAML frontmatter and return ``(body, frontmatter)``.

Mirrors CLI behavior: only parses a frontmatter block at the very top.
If parsing fails or frontmatter is not a mapping, returns body unchanged and ``None``.
"""
original_content = content
lines = content.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
return original_content, None

closing_index = None
for index in range(1, len(lines)):
if lines[index].strip() == "---":
closing_index = index
break

if closing_index is None:
return original_content, None

frontmatter_text = "".join(lines[1:closing_index])
try:
parsed = yaml.safe_load(frontmatter_text)
except yaml.YAMLError:
return original_content, None

if parsed is None:
parsed = {}
if not isinstance(parsed, dict):
return original_content, None

body_content = "".join(lines[closing_index + 1 :])
return body_content, parsed


async def read_note_json_by_external_id(
*,
knowledge_client: KnowledgeEntityReader,
resource_client: NoteResourceReader,
entity_external_id: str,
include_frontmatter: bool = False,
) -> ReadNoteJsonPayload:
"""Read and shape one note by exact external ID without identifier resolution.

The entity response carries the accepted Markdown and its routing metadata. Legacy or
non-note entities may not carry content, so only that explicit ``None`` state falls back to
the raw resource route. Empty accepted Markdown remains a valid response and never triggers
a speculative resource read.
"""
with logfire.span(
"mcp.read_note.shape_response",
domain="mcp",
action="read_note",
phase="shape_response",
) as span:
entity = await knowledge_client.get_entity(entity_external_id)
content_text = entity.content
resource_fallback = content_text is None
if resource_fallback:
response = await resource_client.read(entity_external_id)
content_text = response.text

span.set_attribute("read_note.resource_fallback", resource_fallback)
body_content, parsed_frontmatter = parse_opening_frontmatter(content_text)
return {
"title": entity.title,
"permalink": entity.permalink,
"file_path": entity.file_path,
"content": content_text if include_frontmatter else body_content,
"frontmatter": parsed_frontmatter,
}
Loading
Loading