remdb 0.3.163__py3-none-any.whl → 0.3.200__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 (48) hide show
  1. rem/agentic/agents/agent_manager.py +2 -1
  2. rem/agentic/context.py +101 -0
  3. rem/agentic/context_builder.py +30 -8
  4. rem/agentic/mcp/tool_wrapper.py +43 -14
  5. rem/agentic/providers/pydantic_ai.py +76 -34
  6. rem/agentic/schema.py +4 -3
  7. rem/agentic/tools/rem_tools.py +11 -0
  8. rem/api/main.py +1 -1
  9. rem/api/mcp_router/resources.py +75 -14
  10. rem/api/mcp_router/server.py +31 -24
  11. rem/api/mcp_router/tools.py +476 -155
  12. rem/api/routers/auth.py +11 -6
  13. rem/api/routers/chat/completions.py +52 -10
  14. rem/api/routers/chat/sse_events.py +2 -2
  15. rem/api/routers/chat/streaming.py +162 -19
  16. rem/api/routers/messages.py +96 -23
  17. rem/auth/middleware.py +59 -42
  18. rem/cli/README.md +62 -0
  19. rem/cli/commands/ask.py +1 -1
  20. rem/cli/commands/db.py +148 -70
  21. rem/cli/commands/process.py +171 -43
  22. rem/models/entities/ontology.py +93 -101
  23. rem/schemas/agents/core/agent-builder.yaml +143 -42
  24. rem/services/content/service.py +18 -5
  25. rem/services/email/service.py +17 -6
  26. rem/services/embeddings/worker.py +26 -12
  27. rem/services/postgres/__init__.py +28 -3
  28. rem/services/postgres/diff_service.py +57 -5
  29. rem/services/postgres/programmable_diff_service.py +635 -0
  30. rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
  31. rem/services/postgres/register_type.py +12 -11
  32. rem/services/postgres/repository.py +32 -21
  33. rem/services/postgres/schema_generator.py +5 -5
  34. rem/services/postgres/sql_builder.py +6 -5
  35. rem/services/session/__init__.py +7 -1
  36. rem/services/session/pydantic_messages.py +210 -0
  37. rem/services/user_service.py +12 -9
  38. rem/settings.py +7 -1
  39. rem/sql/background_indexes.sql +5 -0
  40. rem/sql/migrations/001_install.sql +148 -11
  41. rem/sql/migrations/002_install_models.sql +162 -132
  42. rem/sql/migrations/004_cache_system.sql +7 -275
  43. rem/utils/model_helpers.py +101 -0
  44. rem/utils/schema_loader.py +51 -13
  45. {remdb-0.3.163.dist-info → remdb-0.3.200.dist-info}/METADATA +1 -1
  46. {remdb-0.3.163.dist-info → remdb-0.3.200.dist-info}/RECORD +48 -46
  47. {remdb-0.3.163.dist-info → remdb-0.3.200.dist-info}/WHEEL +0 -0
  48. {remdb-0.3.163.dist-info → remdb-0.3.200.dist-info}/entry_points.txt +0 -0
@@ -94,14 +94,14 @@ def generate_table_schema(
94
94
  # Always add id as primary key
95
95
  columns.append("id UUID PRIMARY KEY DEFAULT uuid_generate_v4()")
96
96
 
97
- # Add tenant_id if tenant scoped
97
+ # Add tenant_id if tenant scoped (nullable - NULL means public/shared)
98
98
  if tenant_scoped:
99
- columns.append("tenant_id VARCHAR(100) NOT NULL")
100
- indexes.append(f"CREATE INDEX idx_{table_name}_tenant ON {table_name} (tenant_id);")
99
+ columns.append("tenant_id VARCHAR(100)")
100
+ indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tenant ON {table_name} (tenant_id);")
101
101
 
102
102
  # Add user_id (owner field)
103
103
  columns.append("user_id VARCHAR(256)")
104
- indexes.append(f"CREATE INDEX idx_{table_name}_user ON {table_name} (user_id);")
104
+ indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_user ON {table_name} (user_id);")
105
105
 
106
106
  # Process Pydantic fields (skip system fields)
107
107
  for field_name, field_info in model.model_fields.items():
@@ -125,19 +125,19 @@ def generate_table_schema(
125
125
  # Add graph_edges JSONB field
126
126
  columns.append("graph_edges JSONB DEFAULT '[]'::jsonb")
127
127
  indexes.append(
128
- f"CREATE INDEX idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
128
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
129
129
  )
130
130
 
131
131
  # Add metadata JSONB field
132
132
  columns.append("metadata JSONB DEFAULT '{}'::jsonb")
133
133
  indexes.append(
134
- f"CREATE INDEX idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
134
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
135
135
  )
136
136
 
137
137
  # Add tags field (TEXT[] for list[str])
138
138
  columns.append("tags TEXT[] DEFAULT ARRAY[]::TEXT[]")
139
139
  indexes.append(
140
- f"CREATE INDEX idx_{table_name}_tags ON {table_name} USING GIN (tags);"
140
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tags ON {table_name} USING GIN (tags);"
141
141
  )
142
142
 
143
143
  # Generate CREATE TABLE statement
@@ -202,10 +202,10 @@ CREATE TABLE IF NOT EXISTS {embeddings_table} (
202
202
  );
203
203
 
204
204
  -- Index for entity lookup (get all embeddings for entity)
205
- CREATE INDEX idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
205
+ CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
206
206
 
207
207
  -- Index for field + provider lookup
208
- CREATE INDEX idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
208
+ CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
209
209
 
210
210
  -- HNSW index for vector similarity search (created in background)
211
211
  -- Note: This will be created by background thread after data load
@@ -258,6 +258,7 @@ BEGIN
258
258
  RETURN OLD;
259
259
  ELSIF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
260
260
  -- Upsert to KV_STORE (O(1) lookup by entity_key)
261
+ -- tenant_id can be NULL (meaning public/shared data)
261
262
  INSERT INTO kv_store (
262
263
  entity_key,
263
264
  entity_type,
@@ -268,7 +269,7 @@ BEGIN
268
269
  graph_edges,
269
270
  updated_at
270
271
  ) VALUES (
271
- NEW.{entity_key_field}::VARCHAR,
272
+ normalize_key(NEW.{entity_key_field}::VARCHAR),
272
273
  '{table_name}',
273
274
  NEW.id,
274
275
  NEW.tenant_id,
@@ -277,7 +278,7 @@ BEGIN
277
278
  COALESCE(NEW.graph_edges, '[]'::jsonb),
278
279
  CURRENT_TIMESTAMP
279
280
  )
280
- ON CONFLICT (tenant_id, entity_key)
281
+ ON CONFLICT (COALESCE(tenant_id, ''), entity_key)
281
282
  DO UPDATE SET
282
283
  entity_id = EXCLUDED.entity_id,
283
284
  user_id = EXCLUDED.user_id,
@@ -74,7 +74,7 @@ class Repository(Generic[T]):
74
74
  self,
75
75
  records: T | list[T],
76
76
  embeddable_fields: list[str] | None = None,
77
- generate_embeddings: bool = False,
77
+ generate_embeddings: bool = True,
78
78
  ) -> T | list[T]:
79
79
  """
80
80
  Upsert single record or list of records (create or update on ID conflict).
@@ -84,8 +84,9 @@ class Repository(Generic[T]):
84
84
 
85
85
  Args:
86
86
  records: Single model instance or list of model instances
87
- embeddable_fields: Optional list of fields to generate embeddings for
88
- generate_embeddings: Whether to queue embedding generation tasks
87
+ embeddable_fields: Optional list of fields to generate embeddings for.
88
+ If None, auto-detects 'content' field if present.
89
+ generate_embeddings: Whether to queue embedding generation tasks (default: True)
89
90
 
90
91
  Returns:
91
92
  Single record or list of records with generated IDs (matches input type)
@@ -118,25 +119,35 @@ class Repository(Generic[T]):
118
119
  record.id = row["id"] # type: ignore[attr-defined]
119
120
 
120
121
  # Queue embedding generation if requested and worker is available
121
- if generate_embeddings and embeddable_fields and self.db.embedding_worker:
122
+ if generate_embeddings and self.db.embedding_worker:
122
123
  from rem.services.embeddings import EmbeddingTask
123
-
124
- for record in records_list:
125
- for field_name in embeddable_fields:
126
- content = getattr(record, field_name, None)
127
- if content and isinstance(content, str):
128
- task = EmbeddingTask(
129
- task_id=f"{record.id}-{field_name}", # type: ignore[attr-defined]
130
- entity_id=str(record.id), # type: ignore[attr-defined]
131
- table_name=self.table_name,
132
- field_name=field_name,
133
- content=content,
134
- provider="openai", # Default provider
135
- model="text-embedding-3-small", # Default model
136
- )
137
- await self.db.embedding_worker.queue_task(task)
138
-
139
- logger.debug(f"Queued {len(records_list) * len(embeddable_fields)} embedding tasks")
124
+ from .register_type import should_embed_field
125
+
126
+ # Auto-detect embeddable fields if not specified
127
+ if embeddable_fields is None:
128
+ embeddable_fields = [
129
+ field_name
130
+ for field_name, field_info in self.model_class.model_fields.items()
131
+ if should_embed_field(field_name, field_info)
132
+ ]
133
+
134
+ if embeddable_fields:
135
+ for record in records_list:
136
+ for field_name in embeddable_fields:
137
+ content = getattr(record, field_name, None)
138
+ if content and isinstance(content, str):
139
+ task = EmbeddingTask(
140
+ task_id=f"{record.id}-{field_name}", # type: ignore[attr-defined]
141
+ entity_id=str(record.id), # type: ignore[attr-defined]
142
+ table_name=self.table_name,
143
+ field_name=field_name,
144
+ content=content,
145
+ provider="openai", # Default provider
146
+ model="text-embedding-3-small", # Default model
147
+ )
148
+ await self.db.embedding_worker.queue_task(task)
149
+
150
+ logger.debug(f"Queued {len(records_list) * len(embeddable_fields)} embedding tasks")
140
151
 
141
152
  # Return single item or list to match input type
142
153
  return records_list[0] if is_single else records_list
@@ -351,10 +351,10 @@ class SchemaGenerator:
351
351
 
352
352
  Priority:
353
353
  1. Field with json_schema_extra={\"entity_key\": True}
354
- 2. Field named \"name\"
354
+ 2. Field named \"name\" (human-readable identifier)
355
355
  3. Field named \"key\"
356
- 4. Field named \"label\"
357
- 5. First string field
356
+ 4. Field named \"uri\"
357
+ 5. Field named \"id\" (fallback)
358
358
 
359
359
  Args:
360
360
  model: Pydantic model class
@@ -369,9 +369,9 @@ class SchemaGenerator:
369
369
  if json_extra.get("entity_key"):
370
370
  return field_name
371
371
 
372
- # Check for key fields in priority order: id -> uri -> key -> name
372
+ # Check for key fields in priority order: name -> key -> uri -> id
373
373
  # (matching sql_builder.get_entity_key convention)
374
- for candidate in ["id", "uri", "key", "name"]:
374
+ for candidate in ["name", "key", "uri", "id"]:
375
375
  if candidate in model.model_fields:
376
376
  return candidate
377
377
 
@@ -35,10 +35,11 @@ def get_natural_key(model: BaseModel) -> str | None:
35
35
 
36
36
  def get_entity_key(model: BaseModel) -> str:
37
37
  """
38
- Get entity key for KV store following precedence: id -> uri -> key -> name.
38
+ Get entity key for KV store following precedence: name -> key -> uri -> id.
39
39
 
40
- For KV store lookups, we prefer globally unique identifiers first (id),
41
- then natural keys (uri/key/name). Always returns a value (id as fallback).
40
+ For KV store lookups, we prefer human-readable identifiers first (name/key),
41
+ then URIs, with id as the fallback. This allows users to lookup entities
42
+ by their natural names like "panic-disorder" instead of UUIDs.
42
43
 
43
44
  Args:
44
45
  model: Pydantic model instance
@@ -46,13 +47,13 @@ def get_entity_key(model: BaseModel) -> str:
46
47
  Returns:
47
48
  Entity key string (guaranteed to exist)
48
49
  """
49
- for field in ["id", "uri", "key", "name"]:
50
+ for field in ["name", "key", "uri", "id"]:
50
51
  if hasattr(model, field):
51
52
  value = getattr(model, field)
52
53
  if value:
53
54
  return str(value)
54
55
  # Should never reach here since id always exists in CoreModel
55
- raise ValueError(f"Model {type(model)} has no id, uri, key, or name field")
56
+ raise ValueError(f"Model {type(model)} has no name, key, uri, or id field")
56
57
 
57
58
 
58
59
  def generate_deterministic_id(user_id: str | None, entity_key: str) -> uuid.UUID:
@@ -1,6 +1,12 @@
1
1
  """Session management services for conversation persistence and compression."""
2
2
 
3
3
  from .compression import MessageCompressor, SessionMessageStore
4
+ from .pydantic_messages import session_to_pydantic_messages
4
5
  from .reload import reload_session
5
6
 
6
- __all__ = ["MessageCompressor", "SessionMessageStore", "reload_session"]
7
+ __all__ = [
8
+ "MessageCompressor",
9
+ "SessionMessageStore",
10
+ "reload_session",
11
+ "session_to_pydantic_messages",
12
+ ]
@@ -0,0 +1,210 @@
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, tool_arguments).
9
+
10
+ Storage format (our simplified format):
11
+ {"role": "user", "content": "..."}
12
+ {"role": "assistant", "content": "..."}
13
+ {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
14
+
15
+ Pydantic-ai format (what the LLM expects):
16
+ ModelRequest(parts=[UserPromptPart(content="...")])
17
+ ModelResponse(parts=[TextPart(content="..."), ToolCallPart(...)]) # Call
18
+ ModelRequest(parts=[ToolReturnPart(...)]) # Result
19
+
20
+ Example usage:
21
+ from rem.services.session.pydantic_messages import session_to_pydantic_messages
22
+
23
+ # Load session history
24
+ session_history = await store.load_session_messages(session_id)
25
+
26
+ # Convert to pydantic-ai format
27
+ message_history = session_to_pydantic_messages(session_history)
28
+
29
+ # Use with agent.run()
30
+ result = await agent.run(user_prompt, message_history=message_history)
31
+ """
32
+
33
+ import json
34
+ from typing import Any
35
+
36
+ from loguru import logger
37
+ from pydantic_ai.messages import (
38
+ ModelMessage,
39
+ ModelRequest,
40
+ ModelResponse,
41
+ SystemPromptPart,
42
+ TextPart,
43
+ ToolCallPart,
44
+ ToolReturnPart,
45
+ UserPromptPart,
46
+ )
47
+
48
+
49
+ def session_to_pydantic_messages(
50
+ session_history: list[dict[str, Any]],
51
+ system_prompt: str | None = None,
52
+ ) -> list[ModelMessage]:
53
+ """Convert stored session messages to pydantic-ai ModelMessage format.
54
+
55
+ Handles the conversion of our simplified storage format to pydantic-ai's
56
+ native message types, including synthesizing ToolCallPart for tool results.
57
+
58
+ IMPORTANT: pydantic-ai only auto-adds system prompts when message_history is empty.
59
+ When passing message_history to agent.run(), you MUST include the system prompt
60
+ via the system_prompt parameter here.
61
+
62
+ Args:
63
+ session_history: List of message dicts from SessionMessageStore.load_session_messages()
64
+ Each dict has: role, content, and optionally tool_name, tool_call_id, tool_arguments
65
+ system_prompt: The agent's system prompt (from schema description). This is REQUIRED
66
+ for proper agent behavior on subsequent turns, as pydantic-ai won't add it
67
+ automatically when message_history is provided.
68
+
69
+ Returns:
70
+ List of ModelMessage (ModelRequest | ModelResponse) ready for agent.run(message_history=...)
71
+
72
+ Note:
73
+ - System prompts ARE included as SystemPromptPart when system_prompt is provided
74
+ - Tool results require synthesized ToolCallPart to satisfy LLM API requirements
75
+ - The first message in session_history should be "user" role (from context builder)
76
+ """
77
+ messages: list[ModelMessage] = []
78
+
79
+ # CRITICAL: Prepend agent's system prompt if provided
80
+ # This ensures the agent's instructions are present on every turn
81
+ # pydantic-ai only auto-adds system prompts when message_history is empty
82
+ if system_prompt:
83
+ messages.append(ModelRequest(parts=[SystemPromptPart(content=system_prompt)]))
84
+ logger.debug(f"Prepended agent system prompt ({len(system_prompt)} chars) to message history")
85
+
86
+ # Track pending tool results to batch them with assistant responses
87
+ # When we see a tool message, we need to:
88
+ # 1. Add a ModelResponse with ToolCallPart (synthesized)
89
+ # 2. Add a ModelRequest with ToolReturnPart (actual result)
90
+
91
+ i = 0
92
+ while i < len(session_history):
93
+ msg = session_history[i]
94
+ role = msg.get("role", "")
95
+ content = msg.get("content", "")
96
+
97
+ if role == "user":
98
+ # User messages become ModelRequest with UserPromptPart
99
+ messages.append(ModelRequest(parts=[UserPromptPart(content=content)]))
100
+
101
+ elif role == "assistant":
102
+ # Assistant text becomes ModelResponse with TextPart
103
+ # Check if there are following tool messages that should be grouped
104
+ tool_calls = []
105
+ tool_returns = []
106
+
107
+ # Look ahead for tool messages that follow this assistant message
108
+ j = i + 1
109
+ while j < len(session_history) and session_history[j].get("role") == "tool":
110
+ tool_msg = session_history[j]
111
+ tool_name = tool_msg.get("tool_name", "unknown_tool")
112
+ tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
113
+ tool_arguments = tool_msg.get("tool_arguments", {})
114
+ tool_content = tool_msg.get("content", "{}")
115
+
116
+ # Parse tool content if it's a JSON string
117
+ if isinstance(tool_content, str):
118
+ try:
119
+ tool_result = json.loads(tool_content)
120
+ except json.JSONDecodeError:
121
+ tool_result = {"raw": tool_content}
122
+ else:
123
+ tool_result = tool_content
124
+
125
+ # Synthesize ToolCallPart (what the model "called")
126
+ tool_calls.append(ToolCallPart(
127
+ tool_name=tool_name,
128
+ args=tool_arguments if tool_arguments else {},
129
+ tool_call_id=tool_call_id,
130
+ ))
131
+
132
+ # Create ToolReturnPart (the actual result)
133
+ tool_returns.append(ToolReturnPart(
134
+ tool_name=tool_name,
135
+ content=tool_result,
136
+ tool_call_id=tool_call_id,
137
+ ))
138
+
139
+ j += 1
140
+
141
+ # Build the assistant's ModelResponse
142
+ response_parts = []
143
+
144
+ # Add tool calls first (if any)
145
+ response_parts.extend(tool_calls)
146
+
147
+ # Add text content (if any)
148
+ if content:
149
+ response_parts.append(TextPart(content=content))
150
+
151
+ # Only add ModelResponse if we have parts
152
+ if response_parts:
153
+ messages.append(ModelResponse(
154
+ parts=response_parts,
155
+ model_name="recovered", # We don't store model name
156
+ ))
157
+
158
+ # Add tool returns as ModelRequest (required by LLM API)
159
+ if tool_returns:
160
+ messages.append(ModelRequest(parts=tool_returns))
161
+
162
+ # Skip the tool messages we just processed
163
+ i = j - 1
164
+
165
+ elif role == "tool":
166
+ # Orphan tool message (no preceding assistant) - synthesize both parts
167
+ tool_name = msg.get("tool_name", "unknown_tool")
168
+ tool_call_id = msg.get("tool_call_id", f"call_{i}")
169
+ tool_arguments = msg.get("tool_arguments", {})
170
+ tool_content = msg.get("content", "{}")
171
+
172
+ # Parse tool content
173
+ if isinstance(tool_content, str):
174
+ try:
175
+ tool_result = json.loads(tool_content)
176
+ except json.JSONDecodeError:
177
+ tool_result = {"raw": tool_content}
178
+ else:
179
+ tool_result = tool_content
180
+
181
+ # Synthesize the tool call (ModelResponse with ToolCallPart)
182
+ messages.append(ModelResponse(
183
+ parts=[ToolCallPart(
184
+ tool_name=tool_name,
185
+ args=tool_arguments if tool_arguments else {},
186
+ tool_call_id=tool_call_id,
187
+ )],
188
+ model_name="recovered",
189
+ ))
190
+
191
+ # Add the tool return (ModelRequest with ToolReturnPart)
192
+ messages.append(ModelRequest(
193
+ parts=[ToolReturnPart(
194
+ tool_name=tool_name,
195
+ content=tool_result,
196
+ tool_call_id=tool_call_id,
197
+ )]
198
+ ))
199
+
200
+ elif role == "system":
201
+ # Skip system messages - pydantic-ai handles these via Agent.system_prompt
202
+ logger.debug("Skipping system message in session history (handled by Agent)")
203
+
204
+ else:
205
+ logger.warning(f"Unknown message role in session history: {role}")
206
+
207
+ i += 1
208
+
209
+ logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
210
+ return messages
@@ -4,7 +4,8 @@ User Service - User account management.
4
4
  Handles user creation, profile updates, and session linking.
5
5
  """
6
6
 
7
- from datetime import datetime
7
+ from rem.utils.date_utils import utc_now
8
+ from rem.utils.user_id import email_to_user_id
8
9
  from typing import Optional
9
10
 
10
11
  from loguru import logger
@@ -51,22 +52,24 @@ class UserService:
51
52
  updated = True
52
53
 
53
54
  if updated:
54
- user.updated_at = datetime.utcnow()
55
+ user.updated_at = utc_now()
55
56
  await self.repo.upsert(user)
56
57
 
57
58
  return user
58
59
 
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)
60
64
  user = User(
65
+ id=hashed_id, # Database id = hash of email
61
66
  tenant_id=tenant_id,
62
- user_id=email, # Use email as user_id for now? Or UUID?
63
- # The User model has 'user_id' field but also 'id' UUID.
64
- # Usually user_id is the external ID or email.
65
- name=name,
67
+ user_id=hashed_id, # user_id = hash of email (same as id)
68
+ name=email, # Email as entity_key for REM LOOKUP
66
69
  email=email,
67
70
  tier=UserTier.FREE,
68
- created_at=datetime.utcnow(),
69
- updated_at=datetime.utcnow(),
71
+ created_at=utc_now(),
72
+ updated_at=utc_now(),
70
73
  metadata={"avatar_url": avatar_url} if avatar_url else {},
71
74
  )
72
75
  await self.repo.upsert(user)
@@ -117,7 +120,7 @@ class UserService:
117
120
 
118
121
  # Add to list
119
122
  user.anonymous_ids.append(anon_id)
120
- user.updated_at = datetime.utcnow()
123
+ user.updated_at = utc_now()
121
124
 
122
125
  # Save
123
126
  await self.repo.upsert(user)
rem/settings.py CHANGED
@@ -77,6 +77,7 @@ class LLMSettings(BaseSettings):
77
77
  LLM__ANTHROPIC_API_KEY or ANTHROPIC_API_KEY - Anthropic API key
78
78
  LLM__EMBEDDING_PROVIDER or EMBEDDING_PROVIDER - Default embedding provider (openai)
79
79
  LLM__EMBEDDING_MODEL or EMBEDDING_MODEL - Default embedding model name
80
+ LLM__DEFAULT_STRUCTURED_OUTPUT - Default structured output mode (False = streaming text)
80
81
  """
81
82
 
82
83
  model_config = SettingsConfigDict(
@@ -138,6 +139,11 @@ class LLMSettings(BaseSettings):
138
139
  description="Default embedding model (provider-specific model name)",
139
140
  )
140
141
 
142
+ default_structured_output: bool = Field(
143
+ default=False,
144
+ description="Default structured output mode for agents. False = streaming text (easier), True = JSON schema validation",
145
+ )
146
+
141
147
  @field_validator("openai_api_key", mode="before")
142
148
  @classmethod
143
149
  def validate_openai_api_key(cls, v):
@@ -1028,7 +1034,7 @@ class ChatSettings(BaseSettings):
1028
1034
  - Prevents context window bloat while maintaining conversation continuity
1029
1035
 
1030
1036
  User Context (on-demand by default):
1031
- - Agent system prompt includes: "User ID: {user_id}. To load user profile: Use REM LOOKUP users/{user_id}"
1037
+ - Agent system prompt includes: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
1032
1038
  - Agent decides whether to load profile based on query
1033
1039
  - More efficient for queries that don't need personalization
1034
1040
 
@@ -21,6 +21,11 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_embeddings_moments_vector_hnsw
21
21
  ON embeddings_moments
22
22
  USING hnsw (embedding vector_cosine_ops);
23
23
 
24
+ -- HNSW vector index for embeddings_ontologies
25
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_embeddings_ontologies_vector_hnsw
26
+ ON embeddings_ontologies
27
+ USING hnsw (embedding vector_cosine_ops);
28
+
24
29
  -- HNSW vector index for embeddings_ontology_configs
25
30
  CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_embeddings_ontology_configs_vector_hnsw
26
31
  ON embeddings_ontology_configs