feat: auto-split large memories at store time (>500 chars)

When content exceeds 500 chars, it's automatically split into multiple
memories on paragraph boundaries. Each chunk gets the same category,
tags (with part-N-of-M suffix), keywords, and importance. Removes the
old 800 char hard limit from the Pydantic model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-08 18:19:52 +00:00
parent c88dd03cce
commit 73aefda82e
3 changed files with 56 additions and 27 deletions

View file

@ -834,29 +834,64 @@ def _resolve_user_from_token(token: str) -> str | None:
mcp_server = FastMCP("claude-memory")
MAX_MEMORY_CHARS = 500
def _split_content(text: str, max_chars: int = MAX_MEMORY_CHARS) -> list[str]:
"""Split text into chunks on paragraph boundaries, each <= max_chars."""
if len(text) <= max_chars:
return [text]
paragraphs = text.split("\n\n")
chunks: list[str] = []
current = ""
for para in paragraphs:
candidate = f"{current}\n\n{para}".strip() if current else para
if len(candidate) <= max_chars:
current = candidate
else:
if current:
chunks.append(current)
# If a single paragraph exceeds max_chars, hard-split it
while len(para) > max_chars:
chunks.append(para[:max_chars])
para = para[max_chars:]
current = para
if current:
chunks.append(current)
return chunks
@mcp_server.tool()
async def memory_store(content: str, category: str = "facts", tags: str = "",
expanded_keywords: str = "", importance: float = 0.5) -> str:
"""Store a new memory."""
"""Store a new memory. Content over 500 chars is auto-split into multiple memories."""
pool = await get_pool()
user_id = _current_user.get()
is_sensitive = _detect_sensitive(content)
stored_content = content if not is_sensitive else _redact_content(content)
chunks = _split_content(content)
created_ids = []
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id""",
user_id, stored_content, category, tags, expanded_keywords, importance, is_sensitive,
)
memory_id = row["id"]
for i, chunk in enumerate(chunks):
is_sensitive = _detect_sensitive(chunk)
stored = chunk if not is_sensitive else _redact_content(chunk)
chunk_tags = f"{tags},part-{i + 1}-of-{len(chunks)}" if len(chunks) > 1 else tags
if is_sensitive and is_vault_configured():
vault_path = await store_secret(user_id, memory_id, content)
await conn.execute("UPDATE memories SET vault_path = $1 WHERE id = $2", vault_path, memory_id)
row = await conn.fetchrow(
"""INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id""",
user_id, stored, category, chunk_tags, expanded_keywords, importance, is_sensitive,
)
memory_id = row["id"]
created_ids.append(memory_id)
return json.dumps({"id": memory_id, "category": category, "importance": importance})
if is_sensitive and is_vault_configured():
vault_path = await store_secret(user_id, memory_id, chunk)
await conn.execute("UPDATE memories SET vault_path = $1 WHERE id = $2", vault_path, memory_id)
if len(created_ids) == 1:
return json.dumps({"id": created_ids[0], "category": category, "importance": importance})
return json.dumps({"ids": created_ids, "parts": len(created_ids), "category": category, "importance": importance})
@mcp_server.tool()

View file

@ -3,11 +3,8 @@ from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
MAX_MEMORY_CHARS = 800
class MemoryStore(BaseModel):
content: str = Field(..., max_length=MAX_MEMORY_CHARS)
content: str
category: str = "facts"
tags: str = Field(default="", max_length=500)
expanded_keywords: str = Field(default="", max_length=500)
@ -57,7 +54,7 @@ class UnshareTag(BaseModel):
class MemoryUpdate(BaseModel):
content: Optional[str] = Field(None, max_length=MAX_MEMORY_CHARS)
content: Optional[str] = None
tags: Optional[str] = None
importance: Optional[float] = Field(None, ge=0.0, le=1.0)
expanded_keywords: Optional[str] = None

View file

@ -29,15 +29,12 @@ def test_roundtrip_memory_store(mem):
assert restored.tags == mem.tags
@given(content=st.text(min_size=801, max_size=1000))
@given(content=st.text(min_size=801, max_size=2000))
@settings(max_examples=20)
def test_content_over_max_rejected(content):
"""Content exceeding 800 chars is rejected."""
try:
MemoryStore(content=content)
assert False, "Should have raised ValidationError"
except ValidationError:
pass
def test_content_over_500_accepted(content):
"""Content over 500 chars is accepted by the model (auto-split happens server-side)."""
mem = MemoryStore(content=content)
assert len(mem.content) > 500
@given(importance=st.floats().filter(lambda x: x < 0.0 or x > 1.0).filter(lambda x: x == x)) # exclude NaN