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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,14 @@ Synapto is designed to be the primary memory sink for MCP-compatible agents. Age

| User signal | Tool action | Recommended type/layer |
|-------------|-------------|------------------------|
| "always X", "never Y", "from now on" | Store as a rule | `feedback` / `core` |
| "don't do X", "that's wrong" | Store as a correction | `feedback` / `core` |
| "we use X for Y", "our architecture is..." | Store as project context | `project` / `stable` |
| "this sprint", "current PR", "release plan" | Store as active work | `project` / `working` |
| "tracked in Linear", "dashboard is..." | Store as external reference | `reference` / `stable` |
| "I work on...", "my preference is..." | Store as user context | `user` / `stable` |
| "always X", "never Y", "from now on" | Store as a rule | `feedback` / `core`, subtype `workflow` |
| "don't do X", "that's wrong" | Store as a correction | `feedback` / `core`, subtype `communication` or `workflow` |
| "we use X for Y", "our architecture is..." | Store as project context | `project` / `stable`, subtype `stable` |
| "this sprint", "current PR", "release plan" | Store as active work | `project` / `working`, subtype `working` |
| "tracked in Linear", "dashboard is..." | Store as external reference | `reference` / `stable`, subtype `external_system` |
| "I work on...", "my preference is..." | Store as user context | `user` / `stable`, subtype `preference` |

`subtype` is optional and free-form. Recommended values include `code_style`, `workflow`, `tooling`, `testing`, `security`, `communication`, `external_system`, `documentation`, `role`, `preference`, `skill`, and `constraint`.

## MCP Tools

Expand Down Expand Up @@ -181,6 +183,7 @@ get actionable errors instead of raw Postgres exceptions.
| `content` | Text; no Synapto length limit |
| `summary` | Max 255 characters |
| `memory_type` | Max 20 characters |
| `subtype` | Optional free-form subcategory, max 50 characters |
| `depth_layer` | Max 20 characters |
| `tenant` | Max 100 characters |
| `get_memories.memory_ids` | Max 20 IDs per call |
Expand Down
12 changes: 12 additions & 0 deletions migrations/004_add_memory_subtype.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- migrate:up

ALTER TABLE memories ADD COLUMN IF NOT EXISTS subtype VARCHAR(50);

CREATE INDEX IF NOT EXISTS idx_memories_tenant_subtype
ON memories (tenant, subtype)
WHERE deleted_at IS NULL AND subtype IS NOT NULL;

-- migrate:down

DROP INDEX IF EXISTS idx_memories_tenant_subtype;
ALTER TABLE memories DROP COLUMN IF EXISTS subtype;
13 changes: 8 additions & 5 deletions src/synapto/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ async def _export():
t = tenant or config.default_tenant
rows = await client.execute(
"""
SELECT id, content, summary, type, tenant, depth_layer, metadata, created_at, accessed_at
SELECT id, content, summary, type, subtype, tenant, depth_layer, metadata, created_at, accessed_at
FROM memories WHERE deleted_at IS NULL AND tenant = %s ORDER BY created_at;
""",
(t,),
Expand Down Expand Up @@ -571,15 +571,17 @@ async def _import():
emb = await provider.embed_one(content)
await client.execute(
"""
INSERT INTO memories (content, summary, embedding, embedding_dim, type, tenant, depth_layer, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
INSERT INTO memories
(content, summary, embedding, embedding_dim, type, subtype, tenant, depth_layer, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);
""",
(
content,
item.get("summary"),
emb,
provider.dimension,
item.get("type", "general"),
item.get("subtype"),
t,
item.get("depth_layer", "stable"),
Jsonb(item.get("metadata", {})),
Expand Down Expand Up @@ -818,15 +820,16 @@ async def _do_import():
"""
INSERT INTO memories
(content, summary, embedding, embedding_dim,
type, tenant, depth_layer, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
type, subtype, tenant, depth_layer, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);
""",
(
mem.content,
mem.summary,
embedding,
provider.dimension,
mem.memory_type,
None,
config.default_tenant,
mem.depth_layer,
Jsonb(meta),
Expand Down
14 changes: 11 additions & 3 deletions src/synapto/repositories/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
# ---------------------------------------------------------------------------

_INSERT = """
INSERT INTO memories (content, summary, embedding, embedding_dim, type, tenant, depth_layer, metadata)
VALUES (%(content)s, %(summary)s, %(emb)s, %(dim)s, %(type)s, %(tenant)s, %(depth)s, %(meta)s)
INSERT INTO memories (content, summary, embedding, embedding_dim, type, subtype, tenant, depth_layer, metadata)
VALUES (
%(content)s, %(summary)s, %(emb)s, %(dim)s, %(type)s, %(subtype)s,
%(tenant)s, %(depth)s, %(meta)s
)
RETURNING id;
"""

Expand All @@ -29,6 +32,7 @@
content,
summary,
type,
subtype,
tenant,
depth_layer,
metadata,
Expand All @@ -47,6 +51,7 @@
content,
summary,
type,
subtype,
tenant,
depth_layer,
metadata,
Expand Down Expand Up @@ -79,6 +84,7 @@
content,
summary,
type,
subtype,
tenant,
depth_layer,
metadata,
Expand Down Expand Up @@ -138,7 +144,7 @@
"""

_SELECT_WITH_HRR = """
SELECT id, content, type, tenant, depth_layer, trust_score, hrr_vector
SELECT id, content, type, subtype, tenant, depth_layer, trust_score, hrr_vector
FROM memories
WHERE {where_clause}
LIMIT %s;
Expand Down Expand Up @@ -187,6 +193,7 @@ async def create(
memory_type: str,
tenant: str,
depth_layer: str,
subtype: str | None = None,
summary: str | None = None,
metadata: dict[str, Any] | None = None,
) -> UUID:
Expand All @@ -198,6 +205,7 @@ async def create(
"emb": embedding,
"dim": embedding_dim,
"type": memory_type,
"subtype": subtype,
"tenant": tenant,
"depth": depth_layer,
"meta": Jsonb(metadata or {}),
Expand Down
60 changes: 46 additions & 14 deletions src/synapto/search/hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
FROM memories
WHERE deleted_at IS NULL
AND tenant = %(tenant)s
{{depth_filter}}
{{filters}}
ORDER BY embedding::vector({dim}) <=> %(embedding)s::vector({dim})
LIMIT 20
),
Expand All @@ -50,7 +50,7 @@
WHERE deleted_at IS NULL
AND tenant = %(tenant)s
AND tsv @@ plainto_tsquery('english', %(query)s)
{{depth_filter}}
{{filters}}
ORDER BY ts_rank_cd(tsv, plainto_tsquery('english', %(query)s)) DESC
LIMIT 20
)
Expand All @@ -59,6 +59,7 @@
m.content,
m.summary,
m.type,
m.subtype,
m.tenant,
m.depth_layer,
m.decay_score,
Expand Down Expand Up @@ -96,6 +97,7 @@ class SearchResult:
content: str
summary: str | None
type: str
subtype: str | None
tenant: str
depth_layer: str
decay_score: float
Expand Down Expand Up @@ -127,32 +129,57 @@ def _compute_hrr_boost(query: str, hrr_vector: bytes | None, hrr_weight: float =
return 0.0


def _build_memory_filters(
*,
depth_layer: str | None = None,
subtype: str | None = None,
indent: str,
) -> tuple[str, dict[str, str]]:
"""Build shared optional memory filters.

Complexity: O(1) time and space because the supported filter set is fixed.
User values stay in params so SQL rendering remains injection-safe.
"""
filters = []
params = {}
if depth_layer:
filters.append("AND depth_layer = %(depth_layer)s")
params["depth_layer"] = depth_layer
if subtype:
filters.append("AND subtype = %(subtype)s")
params["subtype"] = subtype
return f"\n{indent}".join(filters), params


async def hybrid_search(
client: PostgresClient,
provider: EmbeddingProvider,
query: str,
tenant: str = "default",
depth_layer: str | None = None,
subtype: str | None = None,
limit: int = 10,
rrf_k: int = 60,
) -> list[SearchResult]:
"""Execute 3-way hybrid RRF search: vector similarity + full-text + HRR."""
embedding = await provider.embed_one(query)
dim = provider.dimension

depth_filter = ""
params: dict[str, Any] = {
"embedding": embedding,
"query": query,
"tenant": tenant,
"rrf_k": rrf_k,
"limit": limit * 2, # fetch extra for HRR reranking
}
if depth_layer:
depth_filter = "AND depth_layer = %(depth_layer)s"
params["depth_layer"] = depth_layer
filter_sql, filter_params = _build_memory_filters(
depth_layer=depth_layer,
subtype=subtype,
indent=" ",
)
params.update(filter_params)

sql = RRF_QUERY_TEMPLATE.format(dim=dim).format(depth_filter=depth_filter)
sql = RRF_QUERY_TEMPLATE.format(dim=dim).format(filters=filter_sql)

rows = await client.execute(sql, params)

Expand All @@ -176,6 +203,7 @@ async def hybrid_search(
content=row["content"],
summary=row["summary"],
type=row["type"],
subtype=row.get("subtype"),
tenant=row["tenant"],
depth_layer=row["depth_layer"],
decay_score=row["decay_score"],
Expand All @@ -192,13 +220,13 @@ async def hybrid_search(

VECTOR_ONLY_TEMPLATE = """
SELECT
id, content, summary, type, tenant, depth_layer, decay_score, trust_score, metadata,
id, content, summary, type, subtype, tenant, depth_layer, decay_score, trust_score, metadata,
access_count, created_at, accessed_at,
1 - (embedding::vector({dim}) <=> %(embedding)s::vector({dim})) AS similarity
FROM memories
WHERE deleted_at IS NULL
AND tenant = %(tenant)s
{{depth_filter}}
{{filters}}
ORDER BY embedding::vector({dim}) <=> %(embedding)s::vector({dim})
LIMIT %(limit)s;
"""
Expand All @@ -210,23 +238,26 @@ async def vector_search(
query: str,
tenant: str = "default",
depth_layer: str | None = None,
subtype: str | None = None,
limit: int = 10,
) -> list[SearchResult]:
"""Pure vector similarity search (no keyword component)."""
embedding = await provider.embed_one(query)
dim = provider.dimension

depth_filter = ""
params: dict[str, Any] = {
"embedding": embedding,
"tenant": tenant,
"limit": limit,
}
if depth_layer:
depth_filter = "AND depth_layer = %(depth_layer)s"
params["depth_layer"] = depth_layer
filter_sql, filter_params = _build_memory_filters(
depth_layer=depth_layer,
subtype=subtype,
indent=" ",
)
params.update(filter_params)

sql = VECTOR_ONLY_TEMPLATE.format(dim=dim).format(depth_filter=depth_filter)
sql = VECTOR_ONLY_TEMPLATE.format(dim=dim).format(filters=filter_sql)

rows = await client.execute(sql, params)

Expand All @@ -236,6 +267,7 @@ async def vector_search(
content=row["content"],
summary=row["summary"],
type=row["type"],
subtype=row.get("subtype"),
tenant=row["tenant"],
depth_layer=row["depth_layer"],
decay_score=row["decay_score"],
Expand Down
Loading