diff --git a/Claude outputs/main.py b/Claude outputs/main.py index aff3928..6ee6584 100644 --- a/Claude outputs/main.py +++ b/Claude outputs/main.py @@ -497,6 +497,21 @@ async def send_email(to: str, subject: str, body: str) -> dict: return await asyncio.to_thread(_send_email_sync, to, subject, body) +async def remember_fact(fact: str) -> dict: + fact_id = await insert_memory_fact(DEFAULT_USER_ID, fact) + return {"id": fact_id, "content": fact} + + +async def forget_fact(query: str) -> dict: + matches = await search_memory_facts(query) + if not matches: + return {"deleted": False, "matches": [], "message": "Kein passender Fakt gefunden."} + if len(matches) > 1: + return {"deleted": False, "matches": [{"id": m["id"], "content": m["content"]} for m in matches]} + await delete_memory_fact(matches[0]["id"]) + return {"deleted": True, "content": matches[0]["content"]} + + CALENDAR_TOOLS = [ { "name": "list_calendar_events", @@ -590,6 +605,35 @@ CALENDAR_TOOLS = [ }, ] +MEMORY_TOOLS = [ + { + "name": "remember_fact", + "description": "Store a fact the user explicitly asked to remember, so it is available in future chats.", + "input_schema": { + "type": "object", + "properties": { + "fact": {"type": "string", "description": "The fact to remember, phrased as a standalone statement"}, + }, + "required": ["fact"], + }, + }, + { + "name": "forget_fact", + "description": ( + "Search remembered facts matching a query and delete it if exactly " + "one matches. Only call after the user has explicitly confirmed " + "which fact to forget in the conversation." + ), + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Text to search for among remembered facts"}, + }, + "required": ["query"], + }, + }, +] + CALENDAR_ASSISTANT_INSTRUCTIONS = ( "Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools " "list_calendar_events, create_calendar_event, update_calendar_event und " @@ -610,6 +654,18 @@ CALENDAR_ASSISTANT_INSTRUCTIONS = ( "versendete Mail laesst sich nicht zurueckholen." ) +MEMORY_ASSISTANT_INSTRUCTIONS = ( + "Du hast ausserdem Zugriff auf ein chatuebergreifendes Gedaechtnis ueber " + "die Tools remember_fact und forget_fact. Wenn der Nutzer dich explizit " + "bittet, dir etwas zu merken (z.B. 'merke dir, dass...'), rufe " + "remember_fact direkt auf - keine Rueckfrage noetig. Wenn der Nutzer " + "dich bittet, einen gemerkten Fakt zu vergessen, frage zuerst explizit " + "im Klartext nach Bestaetigung, welcher Fakt gemeint ist, und rufe " + "forget_fact erst auf, nachdem der Nutzer zugestimmt hat. Gibt " + "forget_fact mehrere moegliche Treffer zurueck, liste sie im Chat auf " + "und frage nach, welcher gemeint ist, statt den falschen zu loeschen." +) + async def execute_tool(name: str, tool_input: dict) -> str: if name == "list_calendar_events": @@ -641,6 +697,12 @@ async def execute_tool(name: str, tool_input: dict) -> str: if name == "send_email": result = await send_email(tool_input["to"], tool_input["subject"], tool_input["body"]) return json.dumps(result) + if name == "remember_fact": + result = await remember_fact(tool_input["fact"]) + return json.dumps(result) + if name == "forget_fact": + result = await forget_fact(tool_input["query"]) + return json.dumps(result) raise ValueError(f"Unknown tool: {name}") diff --git a/Claude outputs/tests/test_memory.py b/Claude outputs/tests/test_memory.py index 7222e17..fc12d5c 100644 --- a/Claude outputs/tests/test_memory.py +++ b/Claude outputs/tests/test_memory.py @@ -82,3 +82,62 @@ async def test_search_similar_conversation_summaries_excludes_current_conversati args, kwargs = mock_query.call_args assert "conversation_id != %s" in args[0] assert args[1] == ("[0.1,0.2]", 9, 3) + + +@pytest.mark.asyncio +async def test_remember_fact_inserts_and_returns_content(): + main.DEFAULT_USER_ID = 1 + with patch.object(main, "insert_memory_fact", new=AsyncMock(return_value=99)): + result = await main.remember_fact("Hund heisst Bruno") + + assert result == {"id": 99, "content": "Hund heisst Bruno"} + + +@pytest.mark.asyncio +async def test_forget_fact_no_match_returns_message(): + with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=[])): + result = await main.forget_fact("Katze") + + assert result["deleted"] is False + assert result["matches"] == [] + + +@pytest.mark.asyncio +async def test_forget_fact_single_match_deletes(): + matches = [{"id": 5, "content": "Hund heisst Bruno"}] + with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=matches)), patch.object( + main, "delete_memory_fact", new=AsyncMock() + ) as mock_delete: + result = await main.forget_fact("Bruno") + + mock_delete.assert_called_once_with(5) + assert result == {"deleted": True, "content": "Hund heisst Bruno"} + + +@pytest.mark.asyncio +async def test_forget_fact_multiple_matches_does_not_delete(): + matches = [ + {"id": 5, "content": "Hund heisst Bruno"}, + {"id": 6, "content": "Bruno ist der Nachbar"}, + ] + with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=matches)), patch.object( + main, "delete_memory_fact", new=AsyncMock() + ) as mock_delete: + result = await main.forget_fact("Bruno") + + mock_delete.assert_not_called() + assert result["deleted"] is False + assert len(result["matches"]) == 2 + + +@pytest.mark.asyncio +async def test_execute_tool_dispatches_remember_and_forget_fact(): + import json + + with patch.object(main, "remember_fact", new=AsyncMock(return_value={"id": 1, "content": "X"})): + result = await main.execute_tool("remember_fact", {"fact": "X"}) + assert json.loads(result) == {"id": 1, "content": "X"} + + with patch.object(main, "forget_fact", new=AsyncMock(return_value={"deleted": True, "content": "X"})): + result = await main.execute_tool("forget_fact", {"query": "X"}) + assert json.loads(result) == {"deleted": True, "content": "X"}