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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Restart your agent. Synapto tools appear automatically, and any future release w
| `recall` | Search memories by meaning |
| `get_memory` | Fetch the complete content and metadata for one recalled memory |
| `get_memories` | Fetch complete content for multiple recalled memories |
| `update_memory` | Replace, append, or patch fields on an existing memory |
| `relate` | Link two entities ("Hermes" --[produces]--> "agent.messages") |
| `forget` | Soft-delete a memory |
| `trust_feedback` | Mark a memory as helpful or unhelpful |
Expand All @@ -144,6 +145,21 @@ Restart your agent. Synapto tools appear automatically, and any future release w
| `memory_stats` | View counts and distribution |
| `maintain` | Run decay and cleanup |

### Tool Field Limits

Synapto validates known hard limits before hitting the database, so MCP clients
get actionable errors instead of raw Postgres exceptions.

| Field | Limit |
|------|-------|
| `content` | Text; no Synapto length limit |
| `summary` | Max 255 characters |
| `memory_type` | Max 20 characters |
| `depth_layer` | Max 20 characters |
| `tenant` | Max 100 characters |
| `get_memories.memory_ids` | Max 20 IDs per call |
| `recall.preview_chars` | Clamped to 0-1000 characters |

## CLI

```bash
Expand Down
4 changes: 4 additions & 0 deletions docs/handoffs.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ and receiver are never online at the same time.
4. **Follow up** — progress is appended as a new memory with the same `task_id`;
older handoffs are not mutated.

Use `update_memory` for small corrections or appending clarifying text to a
single memory. For state transitions between agents, prefer a new follow-up
memory so the coordination trail stays auditable.

## Storage Model

The MVP stores handoffs in the existing `memories` table:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ dependencies = [
# pinned to fix CVE-2026-42561 in python-multipart, pulled transitively
# by the MCP/FastAPI stack.
"python-multipart>=0.0.27",
# pinned to fix CVE-2026-44431/CVE-2026-44432, pulled transitively.
"urllib3>=2.7.0",
"psycopg[binary,pool]>=3.1",
"pgvector>=0.3.0",
"redis>=5.0",
Expand Down
4 changes: 3 additions & 1 deletion src/synapto/prompts/server_instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Cross-agent handoffs:
`metadata.task_id` after fetching the full memory.
- Treat `files_scope` as an advisory claim. Do not edit outside it unless the
user expands the scope. Append follow-up memories instead of mutating old ones.
- Use `update_memory` for narrow corrections, summary fixes, metadata patches,
or appending clarifying text to an existing memory.

Depth layers control decay:
- core: forever (rules, identity)
Expand All @@ -44,4 +46,4 @@ Depth layers control decay:
- ephemeral: hours (short-lived state)

If `recall` returns a memory that conflicts with what you observe now, trust the
current state and update or `forget` the stale memory.
current state and call `update_memory` or `forget` for the stale memory.
7 changes: 7 additions & 0 deletions src/synapto/repositories/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
VALUES (%s, %s) ON CONFLICT DO NOTHING;
"""

_UNLINK_MEMORY_ENTITIES = "DELETE FROM memory_entities WHERE memory_id = %s;"

_GET_MEMORY_ENTITIES = """
SELECT e.id, e.name, e.entity_type
FROM entities e
Expand Down Expand Up @@ -118,6 +120,11 @@ async def delete(self, name: str, tenant: str = "default") -> bool:
async def link_memory(self, memory_id: UUID, entity_id: UUID) -> None:
await self._db.execute(_LINK_MEMORY, (memory_id, entity_id))

async def replace_memory_links(self, memory_id: UUID, entity_ids: list[UUID]) -> None:
await self._db.execute(_UNLINK_MEMORY_ENTITIES, (memory_id,))
if entity_ids:
await self._db.execute_many(_LINK_MEMORY, [(memory_id, entity_id) for entity_id in entity_ids])

async def get_memory_entities(self, memory_id: UUID) -> list[dict[str, Any]]:
return await self._db.execute(_GET_MEMORY_ENTITIES, (memory_id,))

Expand Down
55 changes: 55 additions & 0 deletions src/synapto/repositories/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@

_UPDATE_HRR = "UPDATE memories SET hrr_vector = %s, hrr_dim = %s WHERE id = %s;"

_UPDATE_MEMORY = """
UPDATE memories
SET
content = CASE WHEN %(content_provided)s THEN %(content)s ELSE content END,
summary = CASE WHEN %(summary_provided)s THEN %(summary)s ELSE summary END,
embedding = CASE WHEN %(embedding_provided)s THEN %(emb)s::vector ELSE embedding END,
embedding_dim = CASE WHEN %(dim_provided)s THEN %(dim)s ELSE embedding_dim END,
metadata = CASE
WHEN %(meta_provided)s THEN COALESCE(metadata, '{}'::jsonb) || %(meta)s::jsonb
ELSE metadata
END,
accessed_at = now()
WHERE id = %(id)s AND deleted_at IS NULL
RETURNING
id,
content,
summary,
type,
tenant,
depth_layer,
metadata,
decay_score,
trust_score,
access_count,
created_at,
accessed_at;
"""

_SOFT_DELETE = """
UPDATE memories SET deleted_at = now()
WHERE id = %s AND deleted_at IS NULL
Expand Down Expand Up @@ -180,6 +208,33 @@ async def create(
async def update_hrr(self, memory_id: UUID, hrr_vector: bytes, hrr_dim: int) -> None:
await self._db.execute(_UPDATE_HRR, (hrr_vector, hrr_dim, memory_id))

async def update(
self,
memory_id: str | UUID,
*,
content: str | None = None,
embedding: list[float] | None = None,
embedding_dim: int | None = None,
summary: str | None = None,
metadata_patch: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
return await self._db.execute_one(
_UPDATE_MEMORY,
{
"id": memory_id,
"content_provided": content is not None,
"content": content,
"summary_provided": summary is not None,
"summary": summary,
"embedding_provided": embedding is not None,
"emb": embedding,
"dim_provided": embedding_dim is not None,
"dim": embedding_dim,
"meta_provided": metadata_patch is not None,
"meta": Jsonb(metadata_patch or {}),
},
)

async def get_by_id(self, memory_id: str | UUID) -> dict[str, Any] | None:
return await self._db.execute_one(_GET_BY_ID, (memory_id,))

Expand Down
135 changes: 130 additions & 5 deletions src/synapto/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ async def _lifespan(server):
MAX_RECALL_PREVIEW_CHARS = 1000
DEFAULT_RECALL_PREVIEW_CHARS = 200
MAX_BULK_MEMORY_IDS = 20
MAX_SUMMARY_CHARS = 255
MAX_MEMORY_TYPE_CHARS = 20
MAX_TENANT_CHARS = 100
MAX_DEPTH_LAYER_CHARS = 20
RECALL_CONTENT_ELIDED = "[content elided - fetch full via get_memory(id)]"


Expand All @@ -147,6 +151,33 @@ def _format_json(value: dict[str, Any] | None) -> str:
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)


def _validate_max_chars(field: str, value: str | None, max_chars: int, *, guidance: str | None = None) -> None:
if value is None or len(value) <= max_chars:
return
message = f"{field} exceeds {max_chars} chars (got {len(value)})"
if guidance:
message += f" — {guidance}"
raise ToolError(message)


def _validate_memory_fields(
*,
memory_type: str | None = None,
tenant: str | None = None,
depth_layer: str | None = None,
summary: str | None = None,
) -> None:
_validate_max_chars("memory_type", memory_type, MAX_MEMORY_TYPE_CHARS)
_validate_max_chars("tenant", tenant, MAX_TENANT_CHARS)
_validate_max_chars("depth_layer", depth_layer, MAX_DEPTH_LAYER_CHARS)
_validate_max_chars(
"summary",
summary,
MAX_SUMMARY_CHARS,
guidance="pass the full text via 'content' and a short headline via 'summary'",
)


def _format_memory(
row: dict[str, Any],
*,
Expand Down Expand Up @@ -329,17 +360,20 @@ async def remember(
"""Store a memory with optional entity extraction.

Args:
content: the memory content to store
memory_type: category (general, user, feedback, project, reference)
tenant: project/tenant scope (defaults to config default)
depth_layer: core, stable, working, or ephemeral
summary: optional short summary
content: memory content to store (text; no Synapto length limit)
memory_type: category (general, user, feedback, project, reference; max 20 chars)
tenant: project/tenant scope (defaults to config default; max 100 chars)
depth_layer: core, stable, working, or ephemeral (max 20 chars)
summary: optional short summary (max 255 chars)
metadata: optional JSON metadata
extract_entities: auto-extract and link entities from content
"""
_validate_memory_fields(memory_type=memory_type, tenant=tenant, depth_layer=depth_layer, summary=summary)

pg = _get_pg()
provider = _get_provider()
t = tenant or _config.default_tenant
_validate_memory_fields(tenant=t)
repo = MemoryRepository(pg)

embedding = await provider.embed_one(content)
Expand Down Expand Up @@ -376,6 +410,97 @@ async def remember(
return f"stored memory {memory_id} ({depth_layer}, {entity_count} entities linked)"


@mcp.tool
@instrumented_tool
async def update_memory(
memory_id: str,
content: str | None = None,
summary: str | None = None,
metadata_patch: dict[str, Any] | None = None,
append: str | None = None,
) -> str:
"""Update an existing memory without re-sending the whole record.

Args:
memory_id: UUID of the memory to update
content: replacement memory content (text; no Synapto length limit)
summary: optional replacement summary (max 255 chars)
metadata_patch: optional JSON object shallow-merged into existing metadata
append: text appended to the existing content (mutually exclusive with content)
"""
if content is not None and append is not None:
raise ToolError("content and append are mutually exclusive; provide one or the other")
if content is None and summary is None and metadata_patch is None and append is None:
raise ToolError("provide at least one field to update")
if metadata_patch is not None and not isinstance(metadata_patch, dict):
raise ToolError("metadata_patch must be a JSON object")

_validate_memory_fields(summary=summary)

try:
parsed_id = UUID(memory_id)
except ValueError:
return f"invalid memory id: {memory_id}"

pg = _get_pg()
repo = MemoryRepository(pg)
row = await repo.get_by_id(parsed_id)
if not row:
return f"memory {memory_id} not found or deleted"

new_content = content
if append is not None:
new_content = row["content"] + append

embedding = None
embedding_dim = None
if new_content is not None:
provider = _get_provider()
embedding = await provider.embed_one(new_content)
embedding_dim = provider.dimension

updated = await repo.update(
parsed_id,
content=new_content,
embedding=embedding,
embedding_dim=embedding_dim,
summary=summary,
metadata_patch=metadata_patch,
)
if not updated:
return f"memory {memory_id} not found or deleted"

if new_content is not None:
entity_names = extract_entities_from_text(new_content)
provider = _get_provider()
ent_repo = EntityRepository(pg)
entity_ids = []
for entity_name in entity_names:
eid = await create_entity(pg, entity_name, "concept", updated["tenant"], provider=provider)
entity_ids.append(eid)
await ent_repo.replace_memory_links(parsed_id, entity_ids)

try:
hrr_vec = encode_fact(new_content, entity_names)
await repo.update_hrr(parsed_id, phases_to_bytes(hrr_vec), DEFAULT_DIM)
bank_name = f"{updated['tenant']}:{updated['type']}"
await rebuild_bank(pg, bank_name, updated["tenant"], type_filter=updated["type"])
except Exception as e:
logger.warning("post-update hrr refresh failed for %s: %s", parsed_id, e)

cache = _get_cache()
await cache.invalidate_memory(parsed_id)

changed = []
if new_content is not None:
changed.append("content")
if summary is not None:
changed.append("summary")
if metadata_patch is not None:
changed.append("metadata")
return f"updated memory {parsed_id} ({', '.join(changed)})"


@mcp.tool(meta=ALWAYS_LOAD_META)
@instrumented_tool
async def recall(
Expand Down
Loading
Loading