feat: make all memories public by default

All memories are now visible to all users in recall/list/count queries.
Each memory still has an owner (user_id) who retains exclusive delete
rights. This removes the need for explicit sharing — wizard and emo
automatically see each other's memories.

Changes:
- recall/list: single query without user_id filter, added owner field
- count: counts all memories globally
- REST categories/tags: show all users' data
- Delete/update: unchanged (owner-only or write-share)
- Sync: unchanged (stays user-scoped)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-08 13:27:58 +00:00
parent 03681aae49
commit 43a5513f6c
2 changed files with 58 additions and 215 deletions

View file

@ -213,16 +213,15 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
params.append(body.category) params.append(body.category)
async with pool.acquire() as conn: async with pool.acquire() as conn:
# Own memories (AND-match) # All memories (public by default) — AND-match
rows = await conn.fetch( rows = await conn.fetch(
f""" f"""
SELECT id, content, category, tags, importance, is_sensitive, SELECT id, content, category, tags, importance, is_sensitive,
ts_rank(search_vector, query) AS rank, ts_rank(search_vector, query) AS rank,
created_at, updated_at, created_at, updated_at, user_id AS owner,
NULL::text AS shared_by, NULL::text AS share_permission CASE WHEN user_id = $1 THEN NULL ELSE user_id END AS shared_by
FROM memories, plainto_tsquery('english', $2) query FROM memories, plainto_tsquery('english', $2) query
WHERE user_id = $1 WHERE deleted_at IS NULL
AND deleted_at IS NULL
AND (search_vector @@ query OR $2 = '') AND (search_vector @@ query OR $2 = '')
{category_filter} {category_filter}
ORDER BY {order_clause} ORDER BY {order_clause}
@ -231,64 +230,9 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
*params, *params,
) )
# Individually shared memories all_rows = list(rows)
shared_rows = await conn.fetch(
f"""
SELECT m.id, m.content, m.category, m.tags, m.importance, m.is_sensitive,
ts_rank(m.search_vector, query) AS rank,
m.created_at, m.updated_at,
m.user_id AS shared_by, ms.permission AS share_permission
FROM memories m
JOIN memory_shares ms ON ms.memory_id = m.id,
plainto_tsquery('english', $2) query
WHERE ms.shared_with = $1
AND m.deleted_at IS NULL
AND (m.search_vector @@ query OR $2 = '')
{category_filter}
ORDER BY {order_clause}
LIMIT $3
""",
*params,
)
# Tag-shared memories # If AND-match returned too few results, broaden to OR-match
tag_shared_rows = await conn.fetch(
f"""
SELECT DISTINCT ON (m.id)
m.id, m.content, m.category, m.tags, m.importance, m.is_sensitive,
ts_rank(m.search_vector, query) AS rank,
m.created_at, m.updated_at,
m.user_id AS shared_by, ts.permission AS share_permission
FROM memories m
JOIN tag_shares ts ON ts.owner_id = m.user_id,
plainto_tsquery('english', $2) query
WHERE ts.shared_with = $1
AND m.deleted_at IS NULL
AND (m.search_vector @@ query OR $2 = '')
AND EXISTS (
SELECT 1 FROM unnest(string_to_array(m.tags, ',')) t
WHERE trim(t) = ts.tag
)
{category_filter}
ORDER BY m.id
LIMIT $3
""",
*params,
)
# Merge and deduplicate
seen_ids: set[int] = set()
all_rows = []
for row in list(rows) + list(shared_rows) + list(tag_shared_rows):
if row["id"] not in seen_ids:
seen_ids.add(row["id"])
all_rows.append(row)
# Sort merged results by importance desc and trim
all_rows.sort(key=lambda r: r["importance"], reverse=True)
all_rows = all_rows[:body.limit]
# If AND-match returned too few results, broaden to OR-match (own memories only)
if len(all_rows) < body.limit and query_text: if len(all_rows) < body.limit and query_text:
words = query_text.split() words = query_text.split()
if len(words) > 1: if len(words) > 1:
@ -298,15 +242,15 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
if body.category: if body.category:
or_cat_filter = "AND category = $4" or_cat_filter = "AND category = $4"
or_params.append(body.category) or_params.append(body.category)
seen_ids = {r["id"] for r in all_rows}
or_rows = await conn.fetch( or_rows = await conn.fetch(
f""" f"""
SELECT id, content, category, tags, importance, is_sensitive, SELECT id, content, category, tags, importance, is_sensitive,
ts_rank(search_vector, query) AS rank, ts_rank(search_vector, query) AS rank,
created_at, updated_at, created_at, updated_at, user_id AS owner,
NULL::text AS shared_by, NULL::text AS share_permission CASE WHEN user_id = $1 THEN NULL ELSE user_id END AS shared_by
FROM memories, to_tsquery('english', $2) query FROM memories, to_tsquery('english', $2) query
WHERE user_id = $1 WHERE deleted_at IS NULL
AND deleted_at IS NULL
AND search_vector @@ query AND search_vector @@ query
{or_cat_filter} {or_cat_filter}
ORDER BY {order_clause} ORDER BY {order_clause}
@ -331,10 +275,10 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
"importance": row["importance"], "importance": row["importance"],
"is_sensitive": row["is_sensitive"], "is_sensitive": row["is_sensitive"],
"rank": float(row["rank"]), "rank": float(row["rank"]),
"owner": row["owner"],
"created_at": row["created_at"].isoformat(), "created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(), "updated_at": row["updated_at"].isoformat(),
"shared_by": row["shared_by"], "shared_by": row["shared_by"],
"share_permission": row["share_permission"],
} }
) )
@ -351,10 +295,10 @@ async def list_memories(
) -> dict[str, Any]: ) -> dict[str, Any]:
pool = await get_pool() pool = await get_pool()
# Build WHERE clauses dynamically # Build WHERE clauses dynamically — all memories are public
where_clauses = ["user_id = $1", "deleted_at IS NULL"] where_clauses = ["deleted_at IS NULL"]
count_params: list[Any] = [user.user_id] count_params: list[Any] = []
param_idx = 2 param_idx = 1
if category: if category:
where_clauses.append(f"category = ${param_idx}") where_clauses.append(f"category = ${param_idx}")
@ -373,7 +317,7 @@ async def list_memories(
params: list[Any] = [*count_params, limit, offset] params: list[Any] = [*count_params, limit, offset]
query = f""" query = f"""
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at, user_id AS owner
FROM memories WHERE {where} FROM memories WHERE {where}
ORDER BY importance DESC LIMIT ${param_idx} OFFSET ${param_idx + 1} ORDER BY importance DESC LIMIT ${param_idx} OFFSET ${param_idx + 1}
""" """
@ -395,6 +339,7 @@ async def list_memories(
"tags": row["tags"], "tags": row["tags"],
"importance": row["importance"], "importance": row["importance"],
"is_sensitive": row["is_sensitive"], "is_sensitive": row["is_sensitive"],
"owner": row["owner"],
"created_at": row["created_at"].isoformat(), "created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(), "updated_at": row["updated_at"].isoformat(),
} }
@ -405,30 +350,28 @@ async def list_memories(
@app.get("/api/categories") @app.get("/api/categories")
async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""Return distinct category values for the current user.""" """Return distinct category values across all users."""
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( rows = await conn.fetch(
"SELECT DISTINCT category FROM memories WHERE user_id = $1 AND deleted_at IS NULL ORDER BY category", "SELECT DISTINCT category FROM memories WHERE deleted_at IS NULL ORDER BY category",
user.user_id,
) )
return {"categories": [r["category"] for r in rows]} return {"categories": [r["category"] for r in rows]}
@app.get("/api/tags") @app.get("/api/tags")
async def list_tags(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def list_tags(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""Return all distinct tags with memory counts for the current user.""" """Return all distinct tags with memory counts across all users."""
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT trim(t) as tag, COUNT(*) as count SELECT trim(t) as tag, COUNT(*) as count
FROM memories, unnest(string_to_array(tags, ',')) AS t FROM memories, unnest(string_to_array(tags, ',')) AS t
WHERE user_id = $1 AND deleted_at IS NULL AND tags != '' AND tags IS NOT NULL WHERE deleted_at IS NULL AND tags != '' AND tags IS NOT NULL
GROUP BY trim(t) GROUP BY trim(t)
ORDER BY count DESC ORDER BY count DESC
""", """,
user.user_id,
) )
return {"tags": [{"tag": r["tag"], "count": r["count"]} for r in rows]} return {"tags": [{"tag": r["tag"], "count": r["count"]} for r in rows]}
@ -946,9 +889,10 @@ async def memory_recall(context: str, expanded_query: str = "",
f""" f"""
SELECT id, content, category, tags, importance, is_sensitive, SELECT id, content, category, tags, importance, is_sensitive,
ts_rank(search_vector, query) AS rank, created_at, updated_at, ts_rank(search_vector, query) AS rank, created_at, updated_at,
NULL::text AS shared_by user_id AS owner,
CASE WHEN user_id = $1 THEN NULL ELSE user_id END AS shared_by
FROM memories, plainto_tsquery('english', $2) query FROM memories, plainto_tsquery('english', $2) query
WHERE user_id = $1 AND deleted_at IS NULL WHERE deleted_at IS NULL
AND (search_vector @@ query OR $2 = '') AND (search_vector @@ query OR $2 = '')
{category_filter} {category_filter}
ORDER BY {order_clause} ORDER BY {order_clause}
@ -957,34 +901,8 @@ async def memory_recall(context: str, expanded_query: str = "",
*params, *params,
) )
# Also fetch shared memories (individual + tag-based)
shared_rows = await conn.fetch(
"""
SELECT DISTINCT ON (m.id) m.id, m.content, m.category, m.tags, m.importance,
m.is_sensitive, ts_rank(m.search_vector, query) AS rank,
m.created_at, m.updated_at, m.user_id AS shared_by
FROM memories m, plainto_tsquery('english', $2) query
WHERE m.deleted_at IS NULL
AND (m.search_vector @@ query OR $2 = '')
AND m.user_id != $1
AND (
EXISTS (SELECT 1 FROM memory_shares ms WHERE ms.memory_id = m.id AND ms.shared_with = $1)
OR EXISTS (
SELECT 1 FROM tag_shares ts
WHERE ts.owner_id = m.user_id AND ts.shared_with = $1
AND EXISTS (SELECT 1 FROM unnest(string_to_array(m.tags, ',')) t WHERE trim(t) = ts.tag)
)
)
ORDER BY m.id
LIMIT $3
""",
*params,
)
seen_ids = set()
results = [] results = []
for row in rows: for row in rows:
seen_ids.add(row["id"])
c = row["content"] c = row["content"]
if row["is_sensitive"]: if row["is_sensitive"]:
c = f"[SENSITIVE - use secret_get(id={row['id']})]" c = f"[SENSITIVE - use secret_get(id={row['id']})]"
@ -992,27 +910,14 @@ async def memory_recall(context: str, expanded_query: str = "",
"id": row["id"], "content": c, "category": row["category"], "id": row["id"], "content": c, "category": row["category"],
"tags": row["tags"], "importance": row["importance"], "tags": row["tags"], "importance": row["importance"],
"rank": float(row["rank"]), "rank": float(row["rank"]),
"owner": row["owner"],
"created_at": row["created_at"].isoformat(), "created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(), "updated_at": row["updated_at"].isoformat(),
} }
if row["shared_by"]:
entry["shared_by"] = row["shared_by"]
results.append(entry) results.append(entry)
for row in shared_rows:
if row["id"] in seen_ids:
continue
seen_ids.add(row["id"])
c = row["content"]
if row["is_sensitive"]:
c = f"[SENSITIVE - use secret_get(id={row['id']})]"
results.append({
"id": row["id"], "content": c, "category": row["category"],
"tags": row["tags"], "importance": row["importance"],
"rank": float(row["rank"]),
"shared_by": row["shared_by"],
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
})
return json.dumps({"memories": results}) return json.dumps({"memories": results})
@ -1020,81 +925,30 @@ async def memory_recall(context: str, expanded_query: str = "",
async def memory_list(category: str | None = None, limit: int = 20) -> str: async def memory_list(category: str | None = None, limit: int = 20) -> str:
"""List stored memories.""" """List stored memories."""
pool = await get_pool() pool = await get_pool()
user_id = _current_user.get()
if category: if category:
query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at, user_id AS owner
FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2 FROM memories WHERE deleted_at IS NULL AND category = $1
ORDER BY importance DESC LIMIT $3"""
params: list[Any] = [user_id, category, limit]
else:
query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
FROM memories WHERE user_id = $1 AND deleted_at IS NULL
ORDER BY importance DESC LIMIT $2""" ORDER BY importance DESC LIMIT $2"""
params = [user_id, limit] params: list[Any] = [category, limit]
if category:
shared_query = """
SELECT DISTINCT ON (m.id) m.id, m.content, m.category, m.tags, m.importance,
m.is_sensitive, m.created_at, m.updated_at, m.user_id AS shared_by
FROM memories m
WHERE m.deleted_at IS NULL AND m.category = $2 AND m.user_id != $1
AND (
EXISTS (SELECT 1 FROM memory_shares ms WHERE ms.memory_id = m.id AND ms.shared_with = $1)
OR EXISTS (
SELECT 1 FROM tag_shares ts
WHERE ts.owner_id = m.user_id AND ts.shared_with = $1
AND EXISTS (SELECT 1 FROM unnest(string_to_array(m.tags, ',')) t WHERE trim(t) = ts.tag)
)
)
ORDER BY m.id LIMIT $3"""
shared_params: list[Any] = [user_id, category, limit]
else: else:
shared_query = """ query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at, user_id AS owner
SELECT DISTINCT ON (m.id) m.id, m.content, m.category, m.tags, m.importance, FROM memories WHERE deleted_at IS NULL
m.is_sensitive, m.created_at, m.updated_at, m.user_id AS shared_by ORDER BY importance DESC LIMIT $1"""
FROM memories m params = [limit]
WHERE m.deleted_at IS NULL AND m.user_id != $1
AND (
EXISTS (SELECT 1 FROM memory_shares ms WHERE ms.memory_id = m.id AND ms.shared_with = $1)
OR EXISTS (
SELECT 1 FROM tag_shares ts
WHERE ts.owner_id = m.user_id AND ts.shared_with = $1
AND EXISTS (SELECT 1 FROM unnest(string_to_array(m.tags, ',')) t WHERE trim(t) = ts.tag)
)
)
ORDER BY m.id LIMIT $2"""
shared_params = [user_id, limit]
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch(query, *params) rows = await conn.fetch(query, *params)
shared_rows = await conn.fetch(shared_query, *shared_params)
seen_ids = set()
results = [] results = []
for row in rows: for row in rows:
seen_ids.add(row["id"])
c = row["content"] c = row["content"]
if row["is_sensitive"]: if row["is_sensitive"]:
c = f"[SENSITIVE - use secret_get(id={row['id']})]" c = f"[SENSITIVE - use secret_get(id={row['id']})]"
results.append({ results.append({
"id": row["id"], "content": c, "category": row["category"], "id": row["id"], "content": c, "category": row["category"],
"tags": row["tags"], "importance": row["importance"], "tags": row["tags"], "importance": row["importance"],
"created_at": row["created_at"].isoformat(), "owner": row["owner"],
"updated_at": row["updated_at"].isoformat(),
})
for row in shared_rows:
if row["id"] in seen_ids:
continue
seen_ids.add(row["id"])
c = row["content"]
if row["is_sensitive"]:
c = f"[SENSITIVE - use secret_get(id={row['id']})]"
results.append({
"id": row["id"], "content": c, "category": row["category"],
"tags": row["tags"], "importance": row["importance"],
"shared_by": row["shared_by"],
"created_at": row["created_at"].isoformat(), "created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(), "updated_at": row["updated_at"].isoformat(),
}) })
@ -1131,9 +985,8 @@ async def memory_delete(memory_id: int) -> str:
async def memory_count() -> str: async def memory_count() -> str:
"""Count total memories.""" """Count total memories."""
pool = await get_pool() pool = await get_pool()
user_id = _current_user.get()
async with pool.acquire() as conn: async with pool.acquire() as conn:
count = await conn.fetchval("SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL", user_id) count = await conn.fetchval("SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL")
return json.dumps({"count": count}) return json.dumps({"count": count})

View file

@ -37,6 +37,7 @@ def _make_memory_row(**overrides):
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
"deleted_at": None, "deleted_at": None,
"owner": "testuser",
"shared_by": None, "shared_by": None,
"share_permission": None, "share_permission": None,
} }
@ -139,14 +140,11 @@ async def test_store_memory_creates_record_with_user_id(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recall_returns_only_user_memories(client): async def test_recall_returns_all_memories(client):
ac, conn, app_mod = client ac, conn, app_mod = client
# recall calls fetch 3 times: own, shared, tag-shared; plus OR-fallback if < limit # recall now runs a single query (all memories are public)
conn.fetch.side_effect = [ conn.fetch.return_value = [
[_make_memory_row(id=1, content="user memory", is_sensitive=False)], # own _make_memory_row(id=1, content="user memory", is_sensitive=False, owner="testuser", shared_by=None),
[], # individually shared
[], # tag-shared
[], # OR-match fallback
] ]
async with ac: async with ac:
@ -161,19 +159,14 @@ async def test_recall_returns_only_user_memories(client):
results = data["memories"] results = data["memories"]
assert len(results) == 1 assert len(results) == 1
assert results[0]["content"] == "user memory" assert results[0]["content"] == "user memory"
assert results[0]["owner"] == "testuser"
# Verify query includes user_id filter
call_args = conn.fetch.call_args
assert call_args[0][1] == "testuser"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recall_redacts_sensitive_memories(client): async def test_recall_redacts_sensitive_memories(client):
ac, conn, app_mod = client ac, conn, app_mod = client
conn.fetch.side_effect = [ conn.fetch.return_value = [
[_make_memory_row(id=5, content="[REDACTED]", is_sensitive=True)], # own _make_memory_row(id=5, content="[REDACTED]", is_sensitive=True, owner="testuser", shared_by=None),
[], # individually shared
[], # tag-shared
] ]
async with ac: async with ac:
@ -191,11 +184,11 @@ async def test_recall_redacts_sensitive_memories(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_returns_only_user_memories(client): async def test_list_returns_all_memories(client):
ac, conn, app_mod = client ac, conn, app_mod = client
conn.fetch.return_value = [ conn.fetch.return_value = [
_make_memory_row(id=1, content="mem1"), _make_memory_row(id=1, content="mem1", owner="testuser"),
_make_memory_row(id=2, content="mem2"), _make_memory_row(id=2, content="mem2", owner="otheruser"),
] ]
async with ac: async with ac:
@ -208,10 +201,8 @@ async def test_list_returns_only_user_memories(client):
data = resp.json() data = resp.json()
results = data["memories"] results = data["memories"]
assert len(results) == 2 assert len(results) == 2
assert results[0]["owner"] == "testuser"
# Verify user_id filter assert results[1]["owner"] == "otheruser"
call_args = conn.fetch.call_args
assert call_args[0][1] == "testuser"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -601,15 +592,14 @@ async def test_my_shares_returns_outgoing_shares(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recall_includes_shared_memories(client): async def test_recall_includes_all_users_memories(client):
"""POST /api/memories/recall includes shared memories with shared_by field.""" """POST /api/memories/recall returns all users' memories with owner field."""
ac, conn, app_mod = client ac, conn, app_mod = client
# recall calls fetch multiple times: own, shared, tag-shared, OR-fallback # Single query returns all memories (public by default)
conn.fetch.side_effect = [ conn.fetch.return_value = [
[_make_memory_row(id=1, content="own memory", user_id="testuser", shared_by=None)], # own _make_memory_row(id=1, content="own memory", owner="testuser", shared_by=None),
[_make_memory_row(id=2, content="shared memory", user_id="owner1", shared_by="owner1")], # shared _make_memory_row(id=2, content="other memory", owner="owner1", shared_by="owner1"),
[_make_memory_row(id=3, content="tag shared", user_id="owner2", shared_by="owner2")], # tag-shared _make_memory_row(id=3, content="another memory", owner="owner2", shared_by="owner2"),
[], # OR-fallback
] ]
async with ac: async with ac:
@ -623,10 +613,10 @@ async def test_recall_includes_shared_memories(client):
data = resp.json() data = resp.json()
results = data["memories"] results = data["memories"]
assert len(results) == 3 assert len(results) == 3
# Check that shared_by field appears in shared memories assert results[0]["owner"] == "testuser"
assert results[0]["shared_by"] is None assert results[0]["shared_by"] is None
assert results[1]["owner"] == "owner1"
assert results[1]["shared_by"] == "owner1" assert results[1]["shared_by"] == "owner1"
assert results[2]["shared_by"] == "owner2"
@pytest.mark.asyncio @pytest.mark.asyncio