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:
parent
c88dd03cce
commit
73aefda82e
3 changed files with 56 additions and 27 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue