remdb 0.3.242__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. remdb-0.3.242.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,310 @@
1
+ """Convert stored session messages to pydantic-ai native message format.
2
+
3
+ This module enables proper conversation history replay by converting our simplified
4
+ storage format into pydantic-ai's native ModelRequest/ModelResponse types.
5
+
6
+ Key insight: When we store tool results, we only store the result (ToolReturnPart).
7
+ But LLM APIs require matching ToolCallPart for each ToolReturnPart. So we synthesize
8
+ the ToolCallPart from stored metadata (tool_name, tool_call_id) and arguments.
9
+
10
+ Tool arguments can come from two places:
11
+ - Parent tool calls (ask_agent): tool_arguments stored in metadata (content = result)
12
+ - Child tool calls (register_metadata): arguments parsed from content (content = args as JSON)
13
+
14
+ Storage format (our simplified format):
15
+ {"role": "user", "content": "..."}
16
+ {"role": "assistant", "content": "..."}
17
+ {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}} # optional
18
+
19
+ Pydantic-ai format (what the LLM expects):
20
+ ModelRequest(parts=[UserPromptPart(content="...")])
21
+ ModelResponse(parts=[TextPart(content="..."), ToolCallPart(...)]) # Call
22
+ ModelRequest(parts=[ToolReturnPart(...)]) # Result
23
+
24
+ Example usage:
25
+ from rem.services.session.pydantic_messages import session_to_pydantic_messages
26
+
27
+ # Load session history
28
+ session_history = await store.load_session_messages(session_id)
29
+
30
+ # Convert to pydantic-ai format
31
+ message_history = session_to_pydantic_messages(session_history)
32
+
33
+ # Use with agent.run()
34
+ result = await agent.run(user_prompt, message_history=message_history)
35
+ """
36
+
37
+ import json
38
+ import re
39
+ from typing import Any
40
+
41
+ from loguru import logger
42
+ from pydantic_ai.messages import (
43
+ ModelMessage,
44
+ ModelRequest,
45
+ ModelResponse,
46
+ SystemPromptPart,
47
+ TextPart,
48
+ ToolCallPart,
49
+ ToolReturnPart,
50
+ UserPromptPart,
51
+ )
52
+
53
+
54
+ def _sanitize_tool_name(tool_name: str) -> str:
55
+ """Sanitize tool name for OpenAI API compatibility.
56
+
57
+ OpenAI requires tool names to match pattern: ^[a-zA-Z0-9_-]+$
58
+ This replaces invalid characters (like colons) with underscores.
59
+ """
60
+ return re.sub(r'[^a-zA-Z0-9_-]', '_', tool_name)
61
+
62
+
63
+ def session_to_pydantic_messages(
64
+ session_history: list[dict[str, Any]],
65
+ system_prompt: str | None = None,
66
+ ) -> list[ModelMessage]:
67
+ """Convert stored session messages to pydantic-ai ModelMessage format.
68
+
69
+ Handles the conversion of our simplified storage format to pydantic-ai's
70
+ native message types, including synthesizing ToolCallPart for tool results.
71
+
72
+ IMPORTANT: pydantic-ai only auto-adds system prompts when message_history is empty.
73
+ When passing message_history to agent.run(), you MUST include the system prompt
74
+ via the system_prompt parameter here.
75
+
76
+ Args:
77
+ session_history: List of message dicts from SessionMessageStore.load_session_messages()
78
+ Each dict has: role, content, and optionally tool_name, tool_call_id, tool_arguments
79
+ system_prompt: The agent's system prompt (from schema description). This is REQUIRED
80
+ for proper agent behavior on subsequent turns, as pydantic-ai won't add it
81
+ automatically when message_history is provided.
82
+
83
+ Returns:
84
+ List of ModelMessage (ModelRequest | ModelResponse) ready for agent.run(message_history=...)
85
+
86
+ Note:
87
+ - System prompts ARE included as SystemPromptPart when system_prompt is provided
88
+ - Tool results require synthesized ToolCallPart to satisfy LLM API requirements
89
+ - The first message in session_history should be "user" role (from context builder)
90
+ """
91
+ messages: list[ModelMessage] = []
92
+
93
+ # CRITICAL: Prepend agent's system prompt if provided
94
+ # This ensures the agent's instructions are present on every turn
95
+ # pydantic-ai only auto-adds system prompts when message_history is empty
96
+ if system_prompt:
97
+ messages.append(ModelRequest(parts=[SystemPromptPart(content=system_prompt)]))
98
+ logger.debug(f"Prepended agent system prompt ({len(system_prompt)} chars) to message history")
99
+
100
+ # Track pending tool results to batch them with assistant responses
101
+ # When we see a tool message, we need to:
102
+ # 1. Add a ModelResponse with ToolCallPart (synthesized)
103
+ # 2. Add a ModelRequest with ToolReturnPart (actual result)
104
+
105
+ i = 0
106
+ while i < len(session_history):
107
+ msg = session_history[i]
108
+ role = msg.get("role", "")
109
+ content = msg.get("content", "")
110
+
111
+ if role == "user":
112
+ # User messages become ModelRequest with UserPromptPart
113
+ messages.append(ModelRequest(parts=[UserPromptPart(content=content)]))
114
+
115
+ elif role == "assistant":
116
+ # Assistant text becomes ModelResponse with TextPart
117
+ # Check if there are following tool messages that should be grouped
118
+ tool_calls = []
119
+ tool_returns = []
120
+
121
+ # Look ahead for tool messages that follow this assistant message
122
+ j = i + 1
123
+ while j < len(session_history) and session_history[j].get("role") == "tool":
124
+ tool_msg = session_history[j]
125
+ tool_name = tool_msg.get("tool_name", "unknown_tool")
126
+ tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
127
+ tool_content = tool_msg.get("content", "{}")
128
+
129
+ # tool_arguments: prefer explicit field, fallback to parsing content
130
+ tool_arguments = tool_msg.get("tool_arguments")
131
+ if tool_arguments is None and isinstance(tool_content, str) and tool_content:
132
+ try:
133
+ tool_arguments = json.loads(tool_content)
134
+ except json.JSONDecodeError:
135
+ tool_arguments = {}
136
+
137
+ # Parse tool content if it's a JSON string
138
+ if isinstance(tool_content, str):
139
+ try:
140
+ tool_result = json.loads(tool_content)
141
+ except json.JSONDecodeError:
142
+ tool_result = {"raw": tool_content}
143
+ else:
144
+ tool_result = tool_content
145
+
146
+ # Sanitize tool name for OpenAI API compatibility
147
+ safe_tool_name = _sanitize_tool_name(tool_name)
148
+
149
+ # Synthesize ToolCallPart (what the model "called")
150
+ tool_calls.append(ToolCallPart(
151
+ tool_name=safe_tool_name,
152
+ args=tool_arguments if tool_arguments else {},
153
+ tool_call_id=tool_call_id,
154
+ ))
155
+
156
+ # Create ToolReturnPart (the actual result)
157
+ tool_returns.append(ToolReturnPart(
158
+ tool_name=safe_tool_name,
159
+ content=tool_result,
160
+ tool_call_id=tool_call_id,
161
+ ))
162
+
163
+ j += 1
164
+
165
+ # Build the assistant's ModelResponse
166
+ response_parts = []
167
+
168
+ # Add tool calls first (if any)
169
+ response_parts.extend(tool_calls)
170
+
171
+ # Add text content (if any)
172
+ if content:
173
+ response_parts.append(TextPart(content=content))
174
+
175
+ # Only add ModelResponse if we have parts
176
+ if response_parts:
177
+ messages.append(ModelResponse(
178
+ parts=response_parts,
179
+ model_name="recovered", # We don't store model name
180
+ ))
181
+
182
+ # Add tool returns as ModelRequest (required by LLM API)
183
+ if tool_returns:
184
+ messages.append(ModelRequest(parts=tool_returns))
185
+
186
+ # Skip the tool messages we just processed
187
+ i = j - 1
188
+
189
+ elif role == "tool":
190
+ # Orphan tool message (no preceding assistant) - synthesize both parts
191
+ tool_name = msg.get("tool_name", "unknown_tool")
192
+ tool_call_id = msg.get("tool_call_id", f"call_{i}")
193
+ tool_content = msg.get("content", "{}")
194
+
195
+ # tool_arguments: prefer explicit field, fallback to parsing content
196
+ tool_arguments = msg.get("tool_arguments")
197
+ if tool_arguments is None and isinstance(tool_content, str) and tool_content:
198
+ try:
199
+ tool_arguments = json.loads(tool_content)
200
+ except json.JSONDecodeError:
201
+ tool_arguments = {}
202
+
203
+ # Parse tool content
204
+ if isinstance(tool_content, str):
205
+ try:
206
+ tool_result = json.loads(tool_content)
207
+ except json.JSONDecodeError:
208
+ tool_result = {"raw": tool_content}
209
+ else:
210
+ tool_result = tool_content
211
+
212
+ # Sanitize tool name for OpenAI API compatibility
213
+ safe_tool_name = _sanitize_tool_name(tool_name)
214
+
215
+ # Synthesize the tool call (ModelResponse with ToolCallPart)
216
+ messages.append(ModelResponse(
217
+ parts=[ToolCallPart(
218
+ tool_name=safe_tool_name,
219
+ args=tool_arguments if tool_arguments else {},
220
+ tool_call_id=tool_call_id,
221
+ )],
222
+ model_name="recovered",
223
+ ))
224
+
225
+ # Add the tool return (ModelRequest with ToolReturnPart)
226
+ messages.append(ModelRequest(
227
+ parts=[ToolReturnPart(
228
+ tool_name=safe_tool_name,
229
+ content=tool_result,
230
+ tool_call_id=tool_call_id,
231
+ )]
232
+ ))
233
+
234
+ elif role == "system":
235
+ # Skip system messages - pydantic-ai handles these via Agent.system_prompt
236
+ logger.debug("Skipping system message in session history (handled by Agent)")
237
+
238
+ else:
239
+ logger.warning(f"Unknown message role in session history: {role}")
240
+
241
+ i += 1
242
+
243
+ logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
244
+ return messages
245
+
246
+
247
+ def audit_session_history(
248
+ session_id: str,
249
+ agent_name: str,
250
+ prompt: str,
251
+ raw_session_history: list[dict[str, Any]],
252
+ pydantic_messages_count: int,
253
+ ) -> None:
254
+ """
255
+ Dump session history to a YAML file for debugging.
256
+
257
+ Only runs when DEBUG__AUDIT_SESSION=true. Writes to DEBUG__AUDIT_DIR (default /tmp).
258
+ Appends to the same file for a session, so all agent invocations are in one place.
259
+
260
+ Args:
261
+ session_id: The session identifier
262
+ agent_name: Name of the agent being invoked
263
+ prompt: The prompt being sent to the agent
264
+ raw_session_history: The raw session messages from the database
265
+ pydantic_messages_count: Count of converted pydantic-ai messages
266
+ """
267
+ from ...settings import settings
268
+
269
+ if not settings.debug.audit_session:
270
+ return
271
+
272
+ try:
273
+ import yaml
274
+ from pathlib import Path
275
+ from ...utils.date_utils import utc_now, to_iso
276
+
277
+ audit_dir = Path(settings.debug.audit_dir)
278
+ audit_dir.mkdir(parents=True, exist_ok=True)
279
+ audit_file = audit_dir / f"{session_id}.yaml"
280
+
281
+ # Create entry for this agent invocation
282
+ entry = {
283
+ "timestamp": to_iso(utc_now()),
284
+ "agent_name": agent_name,
285
+ "prompt": prompt,
286
+ "raw_history_count": len(raw_session_history),
287
+ "pydantic_messages_count": pydantic_messages_count,
288
+ "raw_session_history": raw_session_history,
289
+ }
290
+
291
+ # Load existing data or create new
292
+ existing_data: dict[str, Any] = {"session_id": session_id, "invocations": []}
293
+ if audit_file.exists():
294
+ with open(audit_file) as f:
295
+ loaded = yaml.safe_load(f)
296
+ if loaded:
297
+ # Ensure session_id is always present (backfill if missing)
298
+ existing_data = {
299
+ "session_id": loaded.get("session_id", session_id),
300
+ "invocations": loaded.get("invocations", []),
301
+ }
302
+
303
+ # Append this invocation
304
+ existing_data["invocations"].append(entry)
305
+
306
+ with open(audit_file, "w") as f:
307
+ yaml.dump(existing_data, f, default_flow_style=False, allow_unicode=True)
308
+ logger.info(f"DEBUG: Session audit updated: {audit_file}")
309
+ except Exception as e:
310
+ logger.warning(f"DEBUG: Failed to dump session audit: {e}")
@@ -0,0 +1,85 @@
1
+ """Session reloading logic for conversation history restoration.
2
+
3
+ This module implements session history loading from the database,
4
+ allowing conversations to be resumed across multiple API calls.
5
+
6
+ Design Pattern:
7
+ - Session identified by session_id from X-Session-Id header
8
+ - All messages for session loaded in chronological order
9
+ - Long assistant messages compressed on load with REM LOOKUP hints
10
+ - Tool messages (register_metadata, etc.) are NEVER compressed
11
+ - Gracefully handles missing database (returns empty history)
12
+
13
+ Message Types on Reload:
14
+ - user: Returned as-is
15
+ - tool: Returned with metadata (tool_call_id, tool_name). tool_arguments may be in
16
+ metadata (parent calls) or parsed from content (child calls) by pydantic_messages.py
17
+ - assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
18
+ """
19
+
20
+ from loguru import logger
21
+
22
+ from rem.services.session.compression import SessionMessageStore
23
+ from rem.settings import settings
24
+
25
+
26
+ async def reload_session(
27
+ session_id: str,
28
+ user_id: str,
29
+ compress_on_load: bool = True,
30
+ ) -> list[dict]:
31
+ """
32
+ Reload all messages for a session from the database.
33
+
34
+ Args:
35
+ session_id: Session/conversation identifier
36
+ user_id: User identifier for data isolation
37
+ compress_on_load: Whether to compress long assistant messages (default: True)
38
+ Tool messages are NEVER compressed.
39
+
40
+ Returns:
41
+ List of message dicts in chronological order (oldest first)
42
+
43
+ Example:
44
+ ```python
45
+ # In completions endpoint
46
+ context = AgentContext.from_headers(dict(request.headers))
47
+
48
+ # Reload previous conversation history
49
+ history = await reload_session(
50
+ session_id=context.session_id,
51
+ user_id=context.user_id,
52
+ compress_on_load=True, # Compress long assistant messages
53
+ )
54
+
55
+ # Combine with new user message
56
+ messages = history + [{"role": "user", "content": prompt}]
57
+ ```
58
+ """
59
+ if not settings.postgres.enabled:
60
+ logger.debug("Postgres disabled, returning empty session history")
61
+ return []
62
+
63
+ if not session_id:
64
+ logger.debug("No session_id provided, returning empty history")
65
+ return []
66
+
67
+ try:
68
+ # Create message store for this session
69
+ store = SessionMessageStore(user_id=user_id)
70
+
71
+ # Load messages (assistant messages compressed on load, tool messages never compressed)
72
+ messages = await store.load_session_messages(
73
+ session_id=session_id, user_id=user_id, compress_on_load=compress_on_load
74
+ )
75
+
76
+ logger.debug(
77
+ f"Reloaded {len(messages)} messages for session {session_id} "
78
+ f"(compress_on_load={compress_on_load})"
79
+ )
80
+
81
+ return messages
82
+
83
+ except Exception as e:
84
+ logger.error(f"Failed to reload session {session_id}: {e}")
85
+ return []
@@ -0,0 +1,130 @@
1
+ """
2
+ User Service - User account management.
3
+
4
+ Handles user creation, profile updates, and session linking.
5
+ """
6
+
7
+ from rem.utils.date_utils import utc_now
8
+ from rem.utils.user_id import email_to_user_id
9
+ from typing import Optional
10
+
11
+ from loguru import logger
12
+
13
+ from ..models.entities.user import User, UserTier
14
+ from .postgres.repository import Repository
15
+ from .postgres.service import PostgresService
16
+
17
+
18
+ class UserService:
19
+ """
20
+ Service for managing user accounts and sessions.
21
+ """
22
+
23
+ def __init__(self, db: PostgresService):
24
+ self.db = db
25
+ self.repo = Repository(User, "users", db=db)
26
+
27
+ async def get_or_create_user(
28
+ self,
29
+ email: str,
30
+ tenant_id: str = "default",
31
+ name: str = "New User",
32
+ avatar_url: Optional[str] = None,
33
+ ) -> User:
34
+ """
35
+ Get existing user by email or create a new one.
36
+ """
37
+ users = await self.repo.find(filters={"email": email}, limit=1)
38
+
39
+ if users:
40
+ user = users[0]
41
+ # Update profile if needed (e.g., name/avatar from OAuth)
42
+ updated = False
43
+ if name and user.name == "New User": # Only update if placeholder
44
+ user.name = name
45
+ updated = True
46
+
47
+ # Store avatar in metadata if provided
48
+ if avatar_url:
49
+ user.metadata = user.metadata or {}
50
+ if user.metadata.get("avatar_url") != avatar_url:
51
+ user.metadata["avatar_url"] = avatar_url
52
+ updated = True
53
+
54
+ if updated:
55
+ user.updated_at = utc_now()
56
+ await self.repo.upsert(user)
57
+
58
+ return user
59
+
60
+ # Create new user
61
+ # id and user_id = UUID5 hash of email (deterministic bijection)
62
+ # name = email (entity_key for LOOKUP by email in KV store)
63
+ hashed_id = email_to_user_id(email)
64
+ user = User(
65
+ id=hashed_id, # Database id = hash of email
66
+ tenant_id=tenant_id,
67
+ user_id=hashed_id, # user_id = hash of email (same as id)
68
+ name=email, # Email as entity_key for REM LOOKUP
69
+ email=email,
70
+ tier=UserTier.FREE,
71
+ created_at=utc_now(),
72
+ updated_at=utc_now(),
73
+ metadata={"avatar_url": avatar_url} if avatar_url else {},
74
+ )
75
+ await self.repo.upsert(user)
76
+ logger.info(f"Created new user: {email}")
77
+ return user
78
+
79
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
80
+ """
81
+ Get a user by their UUID.
82
+
83
+ Args:
84
+ user_id: The user's UUID
85
+
86
+ Returns:
87
+ User if found, None otherwise
88
+ """
89
+ try:
90
+ return await self.repo.get_by_id(user_id)
91
+ except Exception as e:
92
+ logger.warning(f"Could not find user by id {user_id}: {e}")
93
+ return None
94
+
95
+ async def get_user_by_email(self, email: str) -> Optional[User]:
96
+ """
97
+ Get a user by their email address.
98
+
99
+ Args:
100
+ email: The user's email
101
+
102
+ Returns:
103
+ User if found, None otherwise
104
+ """
105
+ users = await self.repo.find(filters={"email": email}, limit=1)
106
+ return users[0] if users else None
107
+
108
+ async def link_anonymous_session(self, user: User, anon_id: str) -> None:
109
+ """
110
+ Link an anonymous session ID to a user account.
111
+
112
+ This allows merging history from the anonymous session into the user's profile.
113
+ """
114
+ if not anon_id:
115
+ return
116
+
117
+ # Check if already linked
118
+ if anon_id in user.anonymous_ids:
119
+ return
120
+
121
+ # Add to list
122
+ user.anonymous_ids.append(anon_id)
123
+ user.updated_at = utc_now()
124
+
125
+ # Save
126
+ await self.repo.upsert(user)
127
+ logger.info(f"Linked anonymous session {anon_id} to user {user.email}")
128
+
129
+ # TODO: Migrate/Merge actual data (rate limit counts, history) if needed.
130
+ # For now, we just link the IDs so future queries can include data from this anon_id.