jarvis-assist/docs/superpowers/plans/2026-09-13-cross-chat-memor...

35 KiB

Chatuebergreifendes Gedaechtnis Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: JARVIS soll sich Dinge chatuebergreifend merken - explizite Fakten ("merke dir...") und automatische Zusammenfassungen frueherer Conversations, die per Aehnlichkeitssuche in neue Chats einfliessen.

Architecture: Zwei neue Postgres-Tabellen (memory_facts, conversation_summaries, letztere mit pgvector-Embedding wie die bestehende Knowledge-Base). Zwei neue Claude-Tools (remember_fact, forget_fact) analog zu den bestehenden Kalender/E-Mail-Tools. Nach jedem Chat-Turn laeuft im Hintergrund ein Claude-Call, der die Conversation zusammenfasst und embedded. Vor jedem Chat-Turn wird der System-Prompt um alle gespeicherten Fakten (vollstaendig) und die Top-3 aehnlichsten frueheren Zusammenfassungen (per Vector-Suche) ergaenzt.

Tech Stack: Python 3.11, FastAPI, psycopg2 (Postgres), Anthropic SDK (claude_client.messages.create), aiohttp (Ollama-Embeddings), pytest + pytest-asyncio + unittest.mock. Alles in der bestehenden einzigen Backend-Datei Claude outputs/main.py (etabliertes Pattern dieses Projekts - kein Split in mehrere Module).

Spec: docs/superpowers/specs/2026-09-13-cross-chat-memory-design.md

Global Constraints

  • Kein ivfflat/hnsw-Index auf conversation_summaries.embedding in dieser Phase (Sequential Scan reicht bei der erwarteten Datenmenge - gleiche Begruendung wie bei document_chunks, Phase 3a).
  • remember_fact wird ohne Rueckfrage aufgerufen, forget_fact erst nach expliziter Bestaetigung im Chat (gleiche Konvention wie create_calendar_event vs. delete_calendar_event).
  • Der Hintergrund-Task fuer die Zusammenfassung darf die Chat-Antwort an den Nutzer niemals verzoegern oder durch eine Exception beeintraechtigen - jeder Fehler wird geloggt, nie weitergeworfen.
  • Es gibt weiterhin nur einen Nutzer (DEFAULT_USER_ID) - kein Multi-User-Scoping fuer memory_facts noetig.
  • Alles landet in Claude outputs/main.py; Tests in Claude outputs/tests/; neue Migration in Claude outputs/migrations/006_memory.sql (dort liegen bereits 002-005, nicht im JARVIS-Root).
  • Tests laufen lokal nicht per pytest direkt (kein psycopg2-binary-Wheel fuer Python 3.14 lokal) - Ausfuehrung ueber den Docker-Container-Weg aus JARVIS_HANDOFF.md ("Backend-Tests lokal ausfuehren").

Task 1: Migration 006_memory.sql

Files:

  • Create: Claude outputs/migrations/006_memory.sql

Interfaces:

  • Produces: Tabellen memory_facts (id, user_id, content, created_at) und conversation_summaries (conversation_id PK, summary, embedding vector(768), updated_at), auf die alle folgenden Tasks per SQL zugreifen.

  • Step 1: Migration schreiben

CREATE TABLE memory_facts (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE conversation_summaries (
    conversation_id INTEGER PRIMARY KEY REFERENCES conversations(id),
    summary TEXT NOT NULL,
    embedding vector(768) NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
  • Step 2: Kein automatisierter Test moeglich

Reines SQL-DDL ohne lokale Postgres-Instanz (siehe Global Constraints) - kann nicht per pytest verifiziert werden. Die tatsaechliche Anwendung und Verifikation (\d memory_facts, \d conversation_summaries) passiert in Task 8 gegen die echte VPS-Datenbank.

  • Step 3: Commit
git add "Claude outputs/migrations/006_memory.sql"
git commit -m "feat: add memory_facts and conversation_summaries tables"

Task 2: memory_facts DB-Helper

Files:

  • Modify: Claude outputs/main.py (neue Funktionen direkt unter get_latest_emails, vor _caldav_calendar)
  • Test: Claude outputs/tests/test_memory.py (neu)

Interfaces:

  • Consumes: db_query(query, params, fetch) (bestehend, main.py:143)

  • Produces: insert_memory_fact(user_id: int, content: str) -> int, search_memory_facts(query: str) -> list[dict] (Zeilen mit id, content), delete_memory_fact(fact_id: int) -> None, get_all_memory_facts() -> list[dict] (Zeilen mit id, content) - werden von Task 4 (Tools) und Task 5 (Prompt-Context) konsumiert.

  • Step 1: Failing tests schreiben

# Claude outputs/tests/test_memory.py
import os
import sys
from unittest.mock import AsyncMock, patch

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pytest

import main


@pytest.mark.asyncio
async def test_insert_memory_fact_returns_new_id():
    with patch.object(main, "db_query", new=AsyncMock(return_value={"id": 42})) as mock_query:
        fact_id = await main.insert_memory_fact(1, "Mag lieber Tee als Kaffee")

    assert fact_id == 42
    args, kwargs = mock_query.call_args
    assert "INSERT INTO memory_facts" in args[0]
    assert args[1] == (1, "Mag lieber Tee als Kaffee")


@pytest.mark.asyncio
async def test_search_memory_facts_uses_ilike():
    rows = [{"id": 1, "content": "Hund heisst Bruno"}]
    with patch.object(main, "db_query", new=AsyncMock(return_value=rows)) as mock_query:
        result = await main.search_memory_facts("Bruno")

    assert result == rows
    args, kwargs = mock_query.call_args
    assert "ILIKE" in args[0]
    assert args[1] == ("%Bruno%",)


@pytest.mark.asyncio
async def test_delete_memory_fact_deletes_by_id():
    with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
        await main.delete_memory_fact(7)

    args, kwargs = mock_query.call_args
    assert "DELETE FROM memory_facts" in args[0]
    assert args[1] == (7,)


@pytest.mark.asyncio
async def test_get_all_memory_facts_returns_rows():
    rows = [{"id": 1, "content": "A"}, {"id": 2, "content": "B"}]
    with patch.object(main, "db_query", new=AsyncMock(return_value=rows)):
        result = await main.get_all_memory_facts()

    assert result == rows
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_memory.py -v (im Docker-Testcontainer, siehe JARVIS_HANDOFF.md) Expected: FAIL mit AttributeError: module 'main' has no attribute 'insert_memory_fact' (und analog fuer die anderen drei Funktionen)

  • Step 3: Implementierung

In main.py, direkt nach get_latest_emails:

async def insert_memory_fact(user_id: int, content: str) -> int:
    row = await db_query(
        "INSERT INTO memory_facts (user_id, content) VALUES (%s, %s) RETURNING id",
        (user_id, content),
        fetch="one",
    )
    return row["id"]


async def search_memory_facts(query: str) -> list:
    return await db_query(
        "SELECT id, content FROM memory_facts WHERE content ILIKE %s ORDER BY created_at ASC",
        (f"%{query}%",),
        fetch="all",
    )


async def delete_memory_fact(fact_id: int):
    await db_query("DELETE FROM memory_facts WHERE id = %s", (fact_id,))


async def get_all_memory_facts() -> list:
    return await db_query(
        "SELECT id, content FROM memory_facts ORDER BY created_at ASC",
        fetch="all",
    )
  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 4 PASS

  • Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add memory_facts DB helpers"

Task 3: conversation_summaries DB-Helper

Files:

  • Modify: Claude outputs/main.py (neue Funktionen direkt nach den in Task 2 hinzugefuegten)
  • Test: Claude outputs/tests/test_memory.py (erweitern)

Interfaces:

  • Consumes: db_query (bestehend), _vector_literal(embedding: list) -> str (bestehend, main.py:220)

  • Produces: upsert_conversation_summary(conversation_id: int, summary: str, embedding: list) -> None, search_similar_conversation_summaries(query_embedding: list, exclude_conversation_id: int, limit: int = 3) -> list[dict] (Zeilen mit conversation_id, summary, distance) - werden von Task 5 (Retrieval) und Task 7 (Hintergrund-Summary) konsumiert.

  • Step 1: Failing tests schreiben

An Claude outputs/tests/test_memory.py anhaengen:

@pytest.mark.asyncio
async def test_upsert_conversation_summary_inserts_with_vector_literal():
    with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
        await main.upsert_conversation_summary(5, "Kurze Zusammenfassung", [0.1, 0.2])

    args, kwargs = mock_query.call_args
    assert "INSERT INTO conversation_summaries" in args[0]
    assert "ON CONFLICT (conversation_id) DO UPDATE" in args[0]
    assert args[1] == (5, "Kurze Zusammenfassung", "[0.1,0.2]")


@pytest.mark.asyncio
async def test_search_similar_conversation_summaries_excludes_current_conversation():
    rows = [{"conversation_id": 3, "summary": "Ueber Kalender", "distance": 0.1}]
    with patch.object(main, "db_query", new=AsyncMock(return_value=rows)) as mock_query:
        result = await main.search_similar_conversation_summaries([0.1, 0.2], exclude_conversation_id=9, limit=3)

    assert result == rows
    args, kwargs = mock_query.call_args
    assert "conversation_id != %s" in args[0]
    assert args[1] == ("[0.1,0.2]", 9, 3)
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 2 neue FAIL mit AttributeError

  • Step 3: Implementierung
async def upsert_conversation_summary(conversation_id: int, summary: str, embedding: list):
    await db_query(
        """
        INSERT INTO conversation_summaries (conversation_id, summary, embedding)
        VALUES (%s, %s, %s::vector)
        ON CONFLICT (conversation_id) DO UPDATE
        SET summary = EXCLUDED.summary, embedding = EXCLUDED.embedding, updated_at = CURRENT_TIMESTAMP
        """,
        (conversation_id, summary, _vector_literal(embedding)),
    )


async def search_similar_conversation_summaries(query_embedding: list, exclude_conversation_id: int, limit: int = 3) -> list:
    return await db_query(
        """
        SELECT conversation_id, summary, embedding <=> %s::vector AS distance
        FROM conversation_summaries
        WHERE conversation_id != %s
        ORDER BY distance ASC
        LIMIT %s
        """,
        (_vector_literal(query_embedding), exclude_conversation_id, limit),
        fetch="all",
    )

Beachte: upsert_conversation_summary ruft db_query mit drei Positional- Parametern auf (query, params) - die Test-Assertion auf args[1] prueft das Tupel (conversation_id, summary, vector_literal), passend zu den drei %s-Platzhaltern in der Query.

  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 6 PASS insgesamt

  • Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add conversation_summaries DB helpers"

Task 4: remember_fact / forget_fact Tools

Files:

  • Modify: Claude outputs/main.py (Business-Logik nach send_email/async def send_email, Tool-Liste nach CALENDAR_TOOLS, Instructions-Text nach CALENDAR_ASSISTANT_INSTRUCTIONS, execute_tool erweitern)
  • Test: Claude outputs/tests/test_memory.py (erweitern)

Interfaces:

  • Consumes: insert_memory_fact, search_memory_facts, delete_memory_fact (Task 2), DEFAULT_USER_ID (bestehende globale Variable)

  • Produces: remember_fact(fact: str) -> dict, forget_fact(query: str) -> dict, Liste MEMORY_TOOLS, String MEMORY_ASSISTANT_INSTRUCTIONS, execute_tool kennt "remember_fact"/"forget_fact" - werden von Task 6 (Wiring in run_chat_completion) konsumiert.

  • Step 1: Failing tests schreiben

An Claude outputs/tests/test_memory.py anhaengen:

@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"}
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 5 neue FAIL (AttributeError bzw. ValueError: Unknown tool)

  • Step 3: Implementierung

In main.py nach async def send_email(...):

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"]}

Nach der CALENDAR_TOOLS-Liste (vor CALENDAR_ASSISTANT_INSTRUCTIONS):

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"],
        },
    },
]

Nach CALENDAR_ASSISTANT_INSTRUCTIONS:

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."
)

In execute_tool, vor der abschliessenden raise ValueError-Zeile:

    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)
  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 11 PASS insgesamt

  • Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add remember_fact and forget_fact chat tools"

Task 5: build_memory_context (Prompt-Zusammensetzung)

Files:

  • Modify: Claude outputs/main.py (neue Funktion nach search_similar_conversation_summaries)
  • Test: Claude outputs/tests/test_memory.py (erweitern)

Interfaces:

  • Consumes: get_all_memory_facts() (Task 2), get_embedding(text: str) -> list (bestehend, main.py:128), search_similar_conversation_summaries (Task 3)

  • Produces: build_memory_context(user_message: str, conversation_id: int) -> str (leerer String, wenn nichts zu ergaenzen ist) - wird von Task 6 in run_chat_completion konsumiert.

  • Step 1: Failing tests schreiben

An Claude outputs/tests/test_memory.py anhaengen:

@pytest.mark.asyncio
async def test_build_memory_context_empty_when_nothing_stored():
    with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
        main, "get_embedding", new=AsyncMock(return_value=[0.1])
    ), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=[])):
        context = await main.build_memory_context("Hallo", conversation_id=1)

    assert context == ""


@pytest.mark.asyncio
async def test_build_memory_context_includes_facts_section():
    facts = [{"id": 1, "content": "Hund heisst Bruno"}]
    with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=facts)), patch.object(
        main, "get_embedding", new=AsyncMock(return_value=[0.1])
    ), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=[])):
        context = await main.build_memory_context("Wie geht es meinem Hund?", conversation_id=1)

    assert "Bekannte Fakten ueber den Nutzer" in context
    assert "Hund heisst Bruno" in context
    assert "Relevante fruehere Gespraeche" not in context


@pytest.mark.asyncio
async def test_build_memory_context_includes_summaries_section():
    summaries = [{"conversation_id": 2, "summary": "Ging um den Kalender", "distance": 0.05}]
    with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
        main, "get_embedding", new=AsyncMock(return_value=[0.1])
    ), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=summaries)):
        context = await main.build_memory_context("Was war nochmal mit dem Termin?", conversation_id=1)

    assert "Relevante fruehere Gespraeche" in context
    assert "Ging um den Kalender" in context


@pytest.mark.asyncio
async def test_build_memory_context_skips_summaries_when_embedding_fails():
    with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
        main, "get_embedding", new=AsyncMock(side_effect=RuntimeError("Ollama down"))
    ):
        context = await main.build_memory_context("Hallo", conversation_id=1)

    assert context == ""
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 4 neue FAIL mit AttributeError

  • Step 3: Implementierung
async def build_memory_context(user_message: str, conversation_id: int) -> str:
    sections = []

    facts = await get_all_memory_facts()
    if facts:
        facts_lines = "\n".join(f"- {f['content']}" for f in facts)
        sections.append(f"Bekannte Fakten ueber den Nutzer:\n{facts_lines}")

    try:
        query_embedding = await get_embedding(user_message)
        summaries = await search_similar_conversation_summaries(query_embedding, conversation_id, limit=3)
    except Exception as e:
        logger.warning(f"Memory retrieval skipped, embedding/search failed: {e}")
        summaries = []

    if summaries:
        summary_lines = "\n".join(f"- {s['summary']}" for s in summaries)
        sections.append(f"Relevante fruehere Gespraeche:\n{summary_lines}")

    return "\n\n".join(sections)
  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 15 PASS insgesamt

  • Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: assemble memory context from facts and similar summaries"

Task 6: Memory in run_chat_completion einbinden

Files:

  • Modify: Claude outputs/main.py (run_chat_completion-Signatur und System-Prompt-Aufbau, Aufrufstelle in chat())
  • Test: Claude outputs/tests/test_chat_tools.py (bestehende 4 Tests anpassen), Claude outputs/tests/test_memory.py (neuer Test)

Interfaces:

  • Consumes: build_memory_context(user_message, conversation_id) (Task 5), MEMORY_TOOLS, MEMORY_ASSISTANT_INSTRUCTIONS (Task 4)

  • Produces: run_chat_completion(claude_messages: list, conversation_id: int) (neue Signatur, breaking change gegenueber vorher run_chat_completion(claude_messages)) - Aufrufstelle in chat() (main.py:765) wird in diesem Task mit angepasst.

  • Step 1: Bestehende Tests an neue Signatur anpassen + neuen Test schreiben

In Claude outputs/tests/test_chat_tools.py: bei allen 4 bestehenden Aufrufen von main.run_chat_completion(...) das zweite Argument conversation_id=1 ergaenzen und build_memory_context patchen, damit kein echter Ollama-/DB-Zugriff noetig ist. Beispiel fuer die erste Testfunktion (die anderen 3 analog):

@pytest.mark.asyncio
async def test_run_chat_completion_without_tool_use():
    completion = MagicMock()
    completion.stop_reason = "end_turn"
    completion.content = [_text_block("Hallo!")]
    completion.usage = _usage(10, 5)

    main.claude_client = MagicMock()
    main.claude_client.messages.create.return_value = completion

    with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")):
        text, output_tokens, total_tokens = await main.run_chat_completion(
            [{"role": "user", "content": "Hi"}], conversation_id=1
        )

    assert text == "Hallo!"
    assert output_tokens == 5
    assert total_tokens == 15

Wende dasselbe Muster (zusaetzliches conversation_id=1 Argument, with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")) um den bestehenden with patch.object(...)-Block herum bzw. als zusaetzlichen patch.object-Parameter) auf die restlichen 3 Tests in dieser Datei an: test_run_chat_completion_executes_tool_and_returns_followup, test_run_chat_completion_handles_two_sequential_tool_calls, test_run_chat_completion_lists_recent_emails.

Neuer Test in Claude outputs/tests/test_memory.py - dafuer zuerst den Import am Dateianfang um MagicMock erweitern (wird ab hier und in Task 7 gebraucht):

from unittest.mock import AsyncMock, MagicMock, patch
@pytest.mark.asyncio
async def test_run_chat_completion_includes_memory_context_in_system_prompt():
    completion = MagicMock()
    completion.stop_reason = "end_turn"
    text_block = MagicMock()
    text_block.type = "text"
    text_block.text = "Bruno geht es gut."
    completion.content = [text_block]
    usage = MagicMock()
    usage.input_tokens = 10
    usage.output_tokens = 5
    completion.usage = usage

    main.claude_client = MagicMock()
    main.claude_client.messages.create.return_value = completion

    with patch.object(
        main, "build_memory_context", new=AsyncMock(return_value="Bekannte Fakten ueber den Nutzer:\n- Hund heisst Bruno")
    ):
        await main.run_chat_completion([{"role": "user", "content": "Wie geht es meinem Hund?"}], conversation_id=1)

    _, kwargs = main.claude_client.messages.create.call_args
    assert "Hund heisst Bruno" in kwargs["system"]
    assert kwargs["tools"] == main.CALENDAR_TOOLS + main.MEMORY_TOOLS
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_chat_tools.py tests/test_memory.py -v Expected: die 4 angepassten Tests FAILEN mit TypeError: run_chat_completion() takes 1 positional argument but 2 were given; der neue Test FAILT ebenso.

  • Step 3: Implementierung

run_chat_completion in main.py ersetzen durch:

async def run_chat_completion(claude_messages: list, conversation_id: int):
    latest_user_message = claude_messages[-1]["content"]
    memory_context = await build_memory_context(latest_user_message, conversation_id)

    system_prompt = f"{CLAUDE_SYSTEM_PROMPT}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}\n\n{MEMORY_ASSISTANT_INSTRUCTIONS}"
    if memory_context:
        system_prompt = f"{system_prompt}\n\n{memory_context}"

    messages = list(claude_messages)
    total_input = 0
    total_output = 0

    for _ in range(MAX_TOOL_ROUNDS):
        completion = await asyncio.to_thread(
            claude_client.messages.create,
            model=CLAUDE_MODEL,
            max_tokens=1024,
            system=system_prompt,
            tools=CALENDAR_TOOLS + MEMORY_TOOLS,
            messages=messages,
        )
        total_input += completion.usage.input_tokens
        total_output += completion.usage.output_tokens

        if completion.stop_reason != "tool_use":
            response_text = "".join(b.text for b in completion.content if b.type == "text")
            return response_text, total_output, total_input + total_output

        tool_results = []
        for block in completion.content:
            if block.type != "tool_use":
                continue
            try:
                result_text = await execute_tool(block.name, block.input)
                tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result_text})
            except Exception as e:
                tool_results.append(
                    {"type": "tool_result", "tool_use_id": block.id, "content": str(e), "is_error": True}
                )

        messages = messages + [
            {"role": "assistant", "content": completion.content},
            {"role": "user", "content": tool_results},
        ]

    return (
        "Entschuldigung, das hat zu viele Zwischenschritte gebraucht. Bitte formuliere die Anfrage neu.",
        total_output,
        total_input + total_output,
    )

(Einzige Aenderungen gegenueber vorher: neuer Parameter conversation_id, die zwei neuen Zeilen fuer memory_context, der um MEMORY_ASSISTANT_INSTRUCTIONS erweiterte system_prompt und tools=CALENDAR_TOOLS + MEMORY_TOOLS statt tools=CALENDAR_TOOLS.)

In chat() die Aufrufstelle anpassen (main.py:765):

        response_text, output_tokens, tokens_used = await run_chat_completion(claude_messages, conversation_id)
  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_chat_tools.py tests/test_memory.py -v Expected: alle PASS (4 angepasste + 16 aus test_memory.py)

  • Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_chat_tools.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: inject remembered facts and similar summaries into chat system prompt"

Task 7: Automatische Zusammenfassung im Hintergrund

Files:

  • Modify: Claude outputs/main.py (neue Funktionen nach run_chat_completion, Aufruf in chat())
  • Test: Claude outputs/tests/test_memory.py (erweitern)

Interfaces:

  • Consumes: get_messages(conversation_id) (bestehend), claude_client.messages.create (bestehend), get_embedding (bestehend), upsert_conversation_summary (Task 3)

  • Produces: update_conversation_summary(conversation_id: int) -> None (faengt alle Exceptions intern ab), _spawn_background_task(coro) -> asyncio.Task - wird in chat() nach dem Speichern der Assistant-Antwort aufgerufen.

  • Step 1: Failing tests schreiben

An Claude outputs/tests/test_memory.py anhaengen:

@pytest.mark.asyncio
async def test_update_conversation_summary_upserts_summary_and_embedding():
    history = [
        {"role": "user", "content": "Wie geht es meinem Hund?", "tokens_used": None, "created_at": None},
        {"role": "assistant", "content": "Bruno geht es gut.", "tokens_used": 5, "created_at": None},
    ]
    completion = MagicMock()
    text_block = MagicMock()
    text_block.type = "text"
    text_block.text = "Nutzer fragte nach seinem Hund Bruno."
    completion.content = [text_block]

    main.claude_client = MagicMock()
    main.claude_client.messages.create.return_value = completion

    with patch.object(main, "get_messages", new=AsyncMock(return_value=history)), patch.object(
        main, "get_embedding", new=AsyncMock(return_value=[0.1, 0.2])
    ), patch.object(main, "upsert_conversation_summary", new=AsyncMock()) as mock_upsert:
        await main.update_conversation_summary(conversation_id=3)

    mock_upsert.assert_called_once_with(3, "Nutzer fragte nach seinem Hund Bruno.", [0.1, 0.2])


@pytest.mark.asyncio
async def test_update_conversation_summary_swallows_exceptions():
    with patch.object(main, "get_messages", new=AsyncMock(side_effect=RuntimeError("DB down"))):
        await main.update_conversation_summary(conversation_id=3)
    # kein Raise - das ist der Test


def test_spawn_background_task_tracks_and_releases_task():
    import asyncio as aio

    async def _run():
        async def noop():
            return "done"

        task = main._spawn_background_task(noop())
        assert task in main._background_tasks
        result = await task
        assert result == "done"
        assert task not in main._background_tasks

    aio.run(_run())
  • Step 2: Tests laufen lassen, Fehlschlag pruefen

Run: python -m pytest tests/test_memory.py -v Expected: 3 neue FAIL mit AttributeError

  • Step 3: Implementierung

In main.py nach run_chat_completion:

SUMMARY_SYSTEM_PROMPT = (
    "Fasse das folgende Gespraech in 2-3 Saetzen auf Deutsch zusammen, damit "
    "ein spaeterer Chat den Kontext wiedererkennt. Gib nur die Zusammenfassung "
    "aus, ohne Einleitung."
)


async def update_conversation_summary(conversation_id: int):
    try:
        history = await get_messages(conversation_id)
        claude_messages = [{"role": m["role"], "content": m["content"]} for m in history]
        completion = await asyncio.to_thread(
            claude_client.messages.create,
            model=CLAUDE_MODEL,
            max_tokens=200,
            system=SUMMARY_SYSTEM_PROMPT,
            messages=claude_messages,
        )
        summary_text = "".join(b.text for b in completion.content if b.type == "text")
        embedding = await get_embedding(summary_text)
        await upsert_conversation_summary(conversation_id, summary_text, embedding)
    except Exception as e:
        logger.error(f"Conversation summary update failed for conversation {conversation_id}: {e}")


_background_tasks: set = set()


def _spawn_background_task(coro):
    task = asyncio.create_task(coro)
    _background_tasks.add(task)
    task.add_done_callback(_background_tasks.discard)
    return task

In chat(), direkt nach await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens):

        _spawn_background_task(update_conversation_summary(conversation_id))
  • Step 4: Tests laufen lassen, Erfolg pruefen

Run: python -m pytest tests/test_memory.py -v Expected: alle PASS (19 insgesamt)

  • Step 5: Gesamten Testlauf pruefen

Run: python -m pytest tests/ -v (kompletter Testcontainer-Durchlauf aus JARVIS_HANDOFF.md) Expected: alle Tests aller Dateien PASS, keine Regression

  • Step 6: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: summarize conversations in the background after each turn"

Task 8: Migration anwenden & Deployment

Files:

  • Keine Code-Aenderungen - reine Deployment-Aktion gegen die echte VPS-Infrastruktur (Befehle aus JARVIS_HANDOFF.md, Abschnitte "Database Management" und "Backend-Code aktualisieren").

Interfaces:

  • Consumes: alle vorherigen Tasks (fertiger, getesteter Code + Migration)

  • Produces: laufendes Feature auf https://jarvis.mbo-tech-it.de

  • Step 1: Migration auf die VPS kopieren und anwenden

scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
  "Claude outputs/migrations/006_memory.sql" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/migrations/
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
  "docker exec -i jarvis-postgres psql -U jarvis -d jarvis < /home/jarvis-core/jarvis/migrations/006_memory.sql"
  • Step 2: Migration verifizieren
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
  "docker exec jarvis-postgres psql -U jarvis -d jarvis -c '\d memory_facts' -c '\d conversation_summaries'"

Expected: beide Tabellen mit den erwarteten Spalten werden angezeigt.

  • Step 3: Backend deployen
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
  "Claude outputs/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
  "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api"
  • Step 4: Manueller End-to-End-Test

Im Chat (Web-UI):

  1. "Merke dir, dass mein Hund Bruno heisst" - Antwort sollte die Speicherung bestaetigen.
  2. Ueber den "Neuer Chat"-Button einen neuen Chat starten.
  3. "Wie heisst mein Hund?" fragen - Antwort sollte "Bruno" korrekt verwenden (Fakten-Pfad).
  4. Ein thematisch verwandtes, aber nicht identisches Thema aus dem ersten Chat ansprechen und pruefen, dass JARVIS darauf Bezug nehmen kann (Zusammenfassungs-Retrieval-Pfad - ggf. kurz warten, da die Zusammenfassung erst nach der ersten Antwort im Hintergrund entsteht).
  5. In einem dritten Chat "Vergiss, dass mein Hund Bruno heisst" sagen, die Rueckfrage bestaetigen, und in einem vierten Chat verifizieren, dass der Fakt nicht mehr bekannt ist.
  • Step 5: Logs pruefen
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
  "docker compose -f /home/jarvis-core/jarvis/docker-compose.yml logs --tail 100 jarvis-api"

Expected: keine unerwarteten Exceptions, insbesondere keine "Conversation summary update failed" ausser bei absichtlich simulierten Fehlern.