From 43a5513f6c3d89896586c456bac61cd9c7f8490c Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 8 Apr 2026 13:27:58 +0000 Subject: [PATCH] feat: make all memories public by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/claude_memory/api/app.py | 219 ++++++----------------------------- tests/test_api.py | 54 ++++----- 2 files changed, 58 insertions(+), 215 deletions(-) diff --git a/src/claude_memory/api/app.py b/src/claude_memory/api/app.py index f1fdc15..305c46d 100644 --- a/src/claude_memory/api/app.py +++ b/src/claude_memory/api/app.py @@ -213,16 +213,15 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre params.append(body.category) async with pool.acquire() as conn: - # Own memories (AND-match) + # All memories (public by default) — AND-match rows = await conn.fetch( f""" SELECT id, content, category, tags, importance, is_sensitive, ts_rank(search_vector, query) AS rank, - created_at, updated_at, - NULL::text AS shared_by, NULL::text AS share_permission + created_at, updated_at, 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 - WHERE user_id = $1 - AND deleted_at IS NULL + WHERE deleted_at IS NULL AND (search_vector @@ query OR $2 = '') {category_filter} ORDER BY {order_clause} @@ -231,64 +230,9 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre *params, ) - # Individually shared memories - 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, - ) + all_rows = list(rows) - # Tag-shared memories - 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 AND-match returned too few results, broaden to OR-match if len(all_rows) < body.limit and query_text: words = query_text.split() if len(words) > 1: @@ -298,15 +242,15 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre if body.category: or_cat_filter = "AND category = $4" or_params.append(body.category) + seen_ids = {r["id"] for r in all_rows} or_rows = await conn.fetch( f""" SELECT id, content, category, tags, importance, is_sensitive, ts_rank(search_vector, query) AS rank, - created_at, updated_at, - NULL::text AS shared_by, NULL::text AS share_permission + created_at, updated_at, user_id AS owner, + CASE WHEN user_id = $1 THEN NULL ELSE user_id END AS shared_by FROM memories, to_tsquery('english', $2) query - WHERE user_id = $1 - AND deleted_at IS NULL + WHERE deleted_at IS NULL AND search_vector @@ query {or_cat_filter} ORDER BY {order_clause} @@ -331,10 +275,10 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre "importance": row["importance"], "is_sensitive": row["is_sensitive"], "rank": float(row["rank"]), + "owner": row["owner"], "created_at": row["created_at"].isoformat(), "updated_at": row["updated_at"].isoformat(), "shared_by": row["shared_by"], - "share_permission": row["share_permission"], } ) @@ -351,10 +295,10 @@ async def list_memories( ) -> dict[str, Any]: pool = await get_pool() - # Build WHERE clauses dynamically - where_clauses = ["user_id = $1", "deleted_at IS NULL"] - count_params: list[Any] = [user.user_id] - param_idx = 2 + # Build WHERE clauses dynamically — all memories are public + where_clauses = ["deleted_at IS NULL"] + count_params: list[Any] = [] + param_idx = 1 if category: where_clauses.append(f"category = ${param_idx}") @@ -373,7 +317,7 @@ async def list_memories( params: list[Any] = [*count_params, limit, offset] 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} ORDER BY importance DESC LIMIT ${param_idx} OFFSET ${param_idx + 1} """ @@ -395,6 +339,7 @@ async def list_memories( "tags": row["tags"], "importance": row["importance"], "is_sensitive": row["is_sensitive"], + "owner": row["owner"], "created_at": row["created_at"].isoformat(), "updated_at": row["updated_at"].isoformat(), } @@ -405,30 +350,28 @@ async def list_memories( @app.get("/api/categories") 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() async with pool.acquire() as conn: rows = await conn.fetch( - "SELECT DISTINCT category FROM memories WHERE user_id = $1 AND deleted_at IS NULL ORDER BY category", - user.user_id, + "SELECT DISTINCT category FROM memories WHERE deleted_at IS NULL ORDER BY category", ) return {"categories": [r["category"] for r in rows]} @app.get("/api/tags") 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() async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT trim(t) as tag, COUNT(*) as count 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) ORDER BY count DESC """, - user.user_id, ) 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""" SELECT id, content, category, tags, importance, is_sensitive, 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 - WHERE user_id = $1 AND deleted_at IS NULL + WHERE deleted_at IS NULL AND (search_vector @@ query OR $2 = '') {category_filter} ORDER BY {order_clause} @@ -957,34 +901,8 @@ async def memory_recall(context: str, expanded_query: str = "", *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 = [] for row in rows: - seen_ids.add(row["id"]) c = row["content"] if row["is_sensitive"]: 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"], "tags": row["tags"], "importance": row["importance"], "rank": float(row["rank"]), + "owner": row["owner"], "created_at": row["created_at"].isoformat(), "updated_at": row["updated_at"].isoformat(), } + if row["shared_by"]: + entry["shared_by"] = row["shared_by"] 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}) @@ -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: """List stored memories.""" pool = await get_pool() - user_id = _current_user.get() if category: - query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at - FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2 - 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 + query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at, user_id AS owner + FROM memories WHERE deleted_at IS NULL AND category = $1 ORDER BY importance DESC LIMIT $2""" - params = [user_id, 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] + params: list[Any] = [category, limit] else: - 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.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] + query = """SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at, user_id AS owner + FROM memories WHERE deleted_at IS NULL + ORDER BY importance DESC LIMIT $1""" + params = [limit] async with pool.acquire() as conn: rows = await conn.fetch(query, *params) - shared_rows = await conn.fetch(shared_query, *shared_params) - seen_ids = set() results = [] for row in rows: - 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"], - "created_at": row["created_at"].isoformat(), - "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"], + "owner": row["owner"], "created_at": row["created_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: """Count total memories.""" pool = await get_pool() - user_id = _current_user.get() 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}) diff --git a/tests/test_api.py b/tests/test_api.py index b79bb7d..d5e7765 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -37,6 +37,7 @@ def _make_memory_row(**overrides): "created_at": now, "updated_at": now, "deleted_at": None, + "owner": "testuser", "shared_by": None, "share_permission": None, } @@ -139,14 +140,11 @@ async def test_store_memory_creates_record_with_user_id(client): @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 - # recall calls fetch 3 times: own, shared, tag-shared; plus OR-fallback if < limit - conn.fetch.side_effect = [ - [_make_memory_row(id=1, content="user memory", is_sensitive=False)], # own - [], # individually shared - [], # tag-shared - [], # OR-match fallback + # recall now runs a single query (all memories are public) + conn.fetch.return_value = [ + _make_memory_row(id=1, content="user memory", is_sensitive=False, owner="testuser", shared_by=None), ] async with ac: @@ -161,19 +159,14 @@ async def test_recall_returns_only_user_memories(client): results = data["memories"] assert len(results) == 1 assert results[0]["content"] == "user memory" - - # Verify query includes user_id filter - call_args = conn.fetch.call_args - assert call_args[0][1] == "testuser" + assert results[0]["owner"] == "testuser" @pytest.mark.asyncio async def test_recall_redacts_sensitive_memories(client): ac, conn, app_mod = client - conn.fetch.side_effect = [ - [_make_memory_row(id=5, content="[REDACTED]", is_sensitive=True)], # own - [], # individually shared - [], # tag-shared + conn.fetch.return_value = [ + _make_memory_row(id=5, content="[REDACTED]", is_sensitive=True, owner="testuser", shared_by=None), ] async with ac: @@ -191,11 +184,11 @@ async def test_recall_redacts_sensitive_memories(client): @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 conn.fetch.return_value = [ - _make_memory_row(id=1, content="mem1"), - _make_memory_row(id=2, content="mem2"), + _make_memory_row(id=1, content="mem1", owner="testuser"), + _make_memory_row(id=2, content="mem2", owner="otheruser"), ] async with ac: @@ -208,10 +201,8 @@ async def test_list_returns_only_user_memories(client): data = resp.json() results = data["memories"] assert len(results) == 2 - - # Verify user_id filter - call_args = conn.fetch.call_args - assert call_args[0][1] == "testuser" + assert results[0]["owner"] == "testuser" + assert results[1]["owner"] == "otheruser" @pytest.mark.asyncio @@ -601,15 +592,14 @@ async def test_my_shares_returns_outgoing_shares(client): @pytest.mark.asyncio -async def test_recall_includes_shared_memories(client): - """POST /api/memories/recall includes shared memories with shared_by field.""" +async def test_recall_includes_all_users_memories(client): + """POST /api/memories/recall returns all users' memories with owner field.""" ac, conn, app_mod = client - # recall calls fetch multiple times: own, shared, tag-shared, OR-fallback - conn.fetch.side_effect = [ - [_make_memory_row(id=1, content="own memory", user_id="testuser", shared_by=None)], # own - [_make_memory_row(id=2, content="shared memory", user_id="owner1", shared_by="owner1")], # shared - [_make_memory_row(id=3, content="tag shared", user_id="owner2", shared_by="owner2")], # tag-shared - [], # OR-fallback + # Single query returns all memories (public by default) + conn.fetch.return_value = [ + _make_memory_row(id=1, content="own memory", owner="testuser", shared_by=None), + _make_memory_row(id=2, content="other memory", owner="owner1", shared_by="owner1"), + _make_memory_row(id=3, content="another memory", owner="owner2", shared_by="owner2"), ] async with ac: @@ -623,10 +613,10 @@ async def test_recall_includes_shared_memories(client): data = resp.json() results = data["memories"] 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[1]["owner"] == "owner1" assert results[1]["shared_by"] == "owner1" - assert results[2]["shared_by"] == "owner2" @pytest.mark.asyncio