File size: 6,750 Bytes
9c9bb30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb1c19a
9c9bb30
 
eb1c19a
 
 
 
9c9bb30
 
 
 
 
 
 
 
 
 
 
 
eb1c19a
9c9bb30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb1c19a
9c9bb30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""Inline memory extraction - zero extra latency.

Instead of making a separate LLM call to extract memories,
we modify the system prompt so the LLM outputs memories
as part of its response. This is the Letta/MemGPT approach.

Benefits:
- Zero extra latency (memory is part of response)
- Zero extra API cost (already paying for response tokens)
- Higher quality (LLM has full context)
- Intelligent filtering (LLM decides what's relevant)
"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from typing import Any

logger = logging.getLogger(__name__)


# Memory extraction instruction to append to system prompt
MEMORY_INSTRUCTION = """

## Memory Instructions
After your response, if there are facts worth remembering about the user/entity for future conversations, output them in a <memory> block:

<memory>
{"memories": [{"content": "fact to remember"}]}
</memory>

What to remember:
- User preferences (likes, dislikes, preferred tools/languages/styles)
- User facts (identity, role, job, location, constraints)
- Context (current goals, ongoing tasks, recent events)

Only output memories for significant, reusable information. Skip for:
- Greetings, thanks, small talk
- One-time questions
- Information already known

If nothing worth remembering: <memory>{"memories": []}</memory>
"""

# Shorter version for token efficiency
MEMORY_INSTRUCTION_SHORT = """

After responding, output facts to remember: <memory>{"memories": [{"content": "..."}]}</memory>
Skip for greetings/small talk. If nothing: <memory>{"memories": []}</memory>"""


@dataclass
class ParsedResponse:
    """Response with extracted memories."""

    content: str  # The actual response (without memory block)
    memories: list[dict[str, Any]]  # Extracted memories
    raw: str  # Original full response


def inject_memory_instruction(
    messages: list[dict[str, Any]],
    short: bool = True,
) -> list[dict[str, Any]]:
    """Inject memory extraction instruction into system prompt.

    Args:
        messages: Original messages list
        short: Use short instruction (fewer tokens)

    Returns:
        Modified messages with memory instruction
    """
    instruction = MEMORY_INSTRUCTION_SHORT if short else MEMORY_INSTRUCTION
    messages = [m.copy() for m in messages]  # Don't modify original

    # Find or create system message
    has_system = False
    for i, msg in enumerate(messages):
        if msg.get("role") == "system":
            messages[i] = {
                **msg,
                "content": msg.get("content", "") + instruction,
            }
            has_system = True
            break

    if not has_system:
        # Prepend system message
        messages.insert(
            0,
            {
                "role": "system",
                "content": "You are a helpful assistant." + instruction,
            },
        )

    return messages


def parse_response_with_memory(response_text: str) -> ParsedResponse:
    """Parse LLM response to extract memories.

    Args:
        response_text: Raw LLM response

    Returns:
        ParsedResponse with content and memories separated
    """
    memories: list[dict[str, Any]] = []
    content = response_text

    # Extract <memory> block
    memory_pattern = r"<memory>\s*(.*?)\s*</memory>"
    match = re.search(memory_pattern, response_text, re.DOTALL | re.IGNORECASE)

    if match:
        memory_json = match.group(1).strip()

        # Remove the memory block from content
        content = re.sub(memory_pattern, "", response_text, flags=re.DOTALL | re.IGNORECASE).strip()

        # Parse the JSON
        try:
            data = json.loads(memory_json)
            memories = data.get("memories", [])
        except json.JSONDecodeError as e:
            logger.warning(f"Failed to parse memory JSON: {e}")

    return ParsedResponse(
        content=content,
        memories=memories,
        raw=response_text,
    )


class InlineMemoryWrapper:
    """Wrapper that extracts memories from LLM responses inline.

    This is the zero-latency approach - memories are extracted
    as part of the response, not in a separate call.

    Usage:
        wrapper = InlineMemoryWrapper(openai_client)
        response, memories = wrapper.chat(
            messages=[{"role": "user", "content": "I prefer Python"}],
            model="gpt-4o-mini"
        )
        # response = "Great choice! Python is excellent..."
        # memories = [{"content": "User prefers Python"}]
    """

    def __init__(self, client: Any):
        """Initialize wrapper.

        Args:
            client: OpenAI-compatible client
        """
        self.client = client

    def chat(
        self,
        messages: list[dict[str, Any]],
        model: str = "gpt-4o-mini",
        short_instruction: bool = True,
        **kwargs: Any,
    ) -> tuple[str, list[dict[str, Any]]]:
        """Send chat request and extract memories inline.

        Args:
            messages: Chat messages
            model: Model to use
            short_instruction: Use shorter memory instruction
            **kwargs: Additional args for chat completion

        Returns:
            Tuple of (response_content, extracted_memories)
        """
        # Inject memory instruction
        modified_messages = inject_memory_instruction(messages, short=short_instruction)

        # Call LLM
        response = self.client.chat.completions.create(
            model=model,
            messages=modified_messages,
            **kwargs,
        )

        raw_content = response.choices[0].message.content

        # Parse response and extract memories
        parsed = parse_response_with_memory(raw_content)

        return parsed.content, parsed.memories

    def chat_with_response(
        self,
        messages: list[dict[str, Any]],
        model: str = "gpt-4o-mini",
        **kwargs: Any,
    ) -> tuple[Any, str, list[dict[str, Any]]]:
        """Send chat request and return full response object.

        Args:
            messages: Chat messages
            model: Model to use
            **kwargs: Additional args for chat completion

        Returns:
            Tuple of (response_object, content, memories)
        """
        modified_messages = inject_memory_instruction(messages)

        response = self.client.chat.completions.create(
            model=model,
            messages=modified_messages,
            **kwargs,
        )

        raw_content = response.choices[0].message.content
        parsed = parse_response_with_memory(raw_content)

        # Modify response to have clean content
        response.choices[0].message.content = parsed.content

        return response, parsed.content, parsed.memories