From 73aefda82e6ddb2b3c3ae63769f2c3f5e48d2b62 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 8 Apr 2026 18:19:52 +0000 Subject: [PATCH] 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) --- src/claude_memory/api/app.py | 63 +++++++++++++++++++++++++-------- src/claude_memory/api/models.py | 7 ++-- tests/test_properties.py | 13 +++---- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/claude_memory/api/app.py b/src/claude_memory/api/app.py index b3fff97..b119c1d 100644 --- a/src/claude_memory/api/app.py +++ b/src/claude_memory/api/app.py @@ -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() diff --git a/src/claude_memory/api/models.py b/src/claude_memory/api/models.py index beddff9..d9678d0 100644 --- a/src/claude_memory/api/models.py +++ b/src/claude_memory/api/models.py @@ -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 diff --git a/tests/test_properties.py b/tests/test_properties.py index b4fb49e..cf3f00d 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -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