remdb 0.3.202__py3-none-any.whl → 0.3.245__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 (44) hide show
  1. rem/agentic/README.md +36 -2
  2. rem/agentic/context.py +86 -3
  3. rem/agentic/context_builder.py +39 -33
  4. rem/agentic/mcp/tool_wrapper.py +2 -2
  5. rem/agentic/providers/pydantic_ai.py +68 -51
  6. rem/agentic/schema.py +2 -2
  7. rem/api/mcp_router/resources.py +223 -0
  8. rem/api/mcp_router/tools.py +170 -18
  9. rem/api/routers/admin.py +30 -4
  10. rem/api/routers/auth.py +175 -18
  11. rem/api/routers/chat/child_streaming.py +394 -0
  12. rem/api/routers/chat/completions.py +24 -29
  13. rem/api/routers/chat/sse_events.py +5 -1
  14. rem/api/routers/chat/streaming.py +242 -272
  15. rem/api/routers/chat/streaming_utils.py +327 -0
  16. rem/api/routers/common.py +18 -0
  17. rem/api/routers/dev.py +7 -1
  18. rem/api/routers/feedback.py +9 -1
  19. rem/api/routers/messages.py +80 -15
  20. rem/api/routers/models.py +9 -1
  21. rem/api/routers/query.py +17 -15
  22. rem/api/routers/shared_sessions.py +16 -0
  23. rem/cli/commands/ask.py +205 -114
  24. rem/cli/commands/process.py +12 -4
  25. rem/cli/commands/query.py +109 -0
  26. rem/cli/commands/session.py +117 -0
  27. rem/cli/main.py +2 -0
  28. rem/models/entities/session.py +1 -0
  29. rem/schemas/agents/rem.yaml +1 -1
  30. rem/services/postgres/repository.py +7 -7
  31. rem/services/rem/service.py +47 -0
  32. rem/services/session/__init__.py +2 -1
  33. rem/services/session/compression.py +14 -12
  34. rem/services/session/pydantic_messages.py +111 -11
  35. rem/services/session/reload.py +2 -1
  36. rem/settings.py +71 -0
  37. rem/sql/migrations/001_install.sql +4 -4
  38. rem/sql/migrations/004_cache_system.sql +3 -1
  39. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  40. rem/utils/schema_loader.py +139 -111
  41. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/METADATA +2 -2
  42. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/RECORD +44 -39
  43. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/entry_points.txt +0 -0
@@ -331,6 +331,123 @@ async def _show_async(
331
331
  raise
332
332
 
333
333
 
334
+ @session.command("clone")
335
+ @click.argument("session_id")
336
+ @click.option("--to-turn", "-t", type=int, help="Clone up to turn N (counting user messages only)")
337
+ @click.option("--name", "-n", help="Name/description for the cloned session")
338
+ def clone(session_id: str, to_turn: int | None, name: str | None):
339
+ """
340
+ Clone a session for exploring alternate conversation paths.
341
+
342
+ SESSION_ID: The session ID to clone.
343
+
344
+ Examples:
345
+
346
+ # Clone entire session
347
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0
348
+
349
+ # Clone up to turn 3 (first 3 user messages and their responses)
350
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 --to-turn 3
351
+
352
+ # Clone with a descriptive name
353
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 -n "Alternate anxiety path"
354
+ """
355
+ asyncio.run(_clone_async(session_id, to_turn, name))
356
+
357
+
358
+ async def _clone_async(
359
+ session_id: str,
360
+ to_turn: int | None,
361
+ name: str | None,
362
+ ):
363
+ """Async implementation of clone command."""
364
+ from uuid import uuid4
365
+ from ...models.entities.session import Session, SessionMode
366
+
367
+ pg = get_postgres_service()
368
+ if not pg:
369
+ logger.error("PostgreSQL not available")
370
+ return
371
+
372
+ await pg.connect()
373
+
374
+ try:
375
+ # Load original session messages
376
+ message_repo = Repository(Message, "messages", db=pg)
377
+ messages = await message_repo.find(
378
+ filters={"session_id": session_id},
379
+ order_by="created_at ASC",
380
+ limit=1000,
381
+ )
382
+
383
+ if not messages:
384
+ logger.error(f"No messages found for session {session_id}")
385
+ return
386
+
387
+ # If --to-turn specified, filter messages up to that turn (user messages)
388
+ if to_turn is not None:
389
+ user_count = 0
390
+ cutoff_idx = len(messages)
391
+ for idx, msg in enumerate(messages):
392
+ if msg.message_type == "user":
393
+ user_count += 1
394
+ if user_count > to_turn:
395
+ cutoff_idx = idx
396
+ break
397
+ messages = messages[:cutoff_idx]
398
+ logger.info(f"Cloning {len(messages)} messages (up to turn {to_turn})")
399
+ else:
400
+ logger.info(f"Cloning all {len(messages)} messages")
401
+
402
+ # Generate new session ID
403
+ new_session_id = str(uuid4())
404
+
405
+ # Get user_id and tenant_id from first message
406
+ first_msg = messages[0]
407
+ user_id = first_msg.user_id
408
+ tenant_id = first_msg.tenant_id or "default"
409
+
410
+ # Create Session record with CLONE mode and lineage
411
+ session_repo = Repository(Session, "sessions", db=pg)
412
+ new_session = Session(
413
+ id=uuid4(),
414
+ name=name or f"Clone of {session_id[:8]}",
415
+ mode=SessionMode.CLONE,
416
+ original_trace_id=session_id,
417
+ description=f"Cloned from session {session_id}" + (f" at turn {to_turn}" if to_turn else ""),
418
+ user_id=user_id,
419
+ tenant_id=tenant_id,
420
+ message_count=len(messages),
421
+ )
422
+ await session_repo.upsert(new_session)
423
+ logger.info(f"Created session record: {new_session.id}")
424
+
425
+ # Copy messages with new session_id
426
+ for msg in messages:
427
+ new_msg = Message(
428
+ id=uuid4(),
429
+ user_id=msg.user_id,
430
+ tenant_id=msg.tenant_id,
431
+ session_id=str(new_session.id),
432
+ content=msg.content,
433
+ message_type=msg.message_type,
434
+ metadata=msg.metadata,
435
+ )
436
+ await message_repo.upsert(new_msg)
437
+
438
+ click.echo(f"\n✅ Cloned session successfully!")
439
+ click.echo(f" Original: {session_id}")
440
+ click.echo(f" New: {new_session.id}")
441
+ click.echo(f" Messages: {len(messages)}")
442
+ if to_turn:
443
+ click.echo(f" Turns: {to_turn}")
444
+ click.echo(f"\nContinue this session with:")
445
+ click.echo(f" rem ask <agent> \"your message\" --session-id {new_session.id}")
446
+
447
+ finally:
448
+ await pg.disconnect()
449
+
450
+
334
451
  def register_command(cli_group):
335
452
  """Register the session command group."""
336
453
  cli_group.add_command(session)
rem/cli/main.py CHANGED
@@ -97,6 +97,7 @@ from .commands.mcp import register_command as register_mcp_command
97
97
  from .commands.scaffold import scaffold as scaffold_command
98
98
  from .commands.cluster import register_commands as register_cluster_commands
99
99
  from .commands.session import register_command as register_session_command
100
+ from .commands.query import register_command as register_query_command
100
101
 
101
102
  register_schema_commands(schema)
102
103
  register_db_commands(db)
@@ -107,6 +108,7 @@ register_ask_command(cli)
107
108
  register_configure_command(cli)
108
109
  register_serve_command(cli)
109
110
  register_mcp_command(cli)
111
+ register_query_command(cli)
110
112
  cli.add_command(experiments_group)
111
113
  cli.add_command(scaffold_command)
112
114
  register_session_command(cli)
@@ -21,6 +21,7 @@ class SessionMode(str, Enum):
21
21
 
22
22
  NORMAL = "normal"
23
23
  EVALUATION = "evaluation"
24
+ CLONE = "clone"
24
25
 
25
26
 
26
27
  class Session(CoreModel):
@@ -124,7 +124,7 @@ json_schema_extra:
124
124
 
125
125
  # Explicit resource declarations for reference data
126
126
  resources:
127
- - uri: rem://schemas
127
+ - uri: rem://agents
128
128
  name: Agent Schemas List
129
129
  description: List all available agent schemas in the system
130
130
  - uri: rem://status
@@ -33,15 +33,15 @@ if TYPE_CHECKING:
33
33
 
34
34
  def get_postgres_service() -> "PostgresService | None":
35
35
  """
36
- Get PostgresService instance with connection string from settings.
36
+ Get PostgresService singleton from parent module.
37
37
 
38
- Returns None if Postgres is disabled.
38
+ Uses late import to avoid circular import issues.
39
+ Previously had a separate _postgres_instance here which caused
40
+ "pool not connected" errors due to duplicate connection pools.
39
41
  """
40
- if not settings.postgres.enabled:
41
- return None
42
-
43
- from .service import PostgresService
44
- return PostgresService()
42
+ # Late import to avoid circular import (repository.py imported by __init__.py)
43
+ from rem.services.postgres import get_postgres_service as _get_singleton
44
+ return _get_singleton()
45
45
 
46
46
  T = TypeVar("T", bound=BaseModel)
47
47
 
@@ -478,6 +478,53 @@ class RemService:
478
478
  parser = RemQueryParser()
479
479
  return parser.parse(query_string)
480
480
 
481
+ async def execute_query_string(
482
+ self, query_string: str, user_id: str | None = None
483
+ ) -> dict[str, Any]:
484
+ """
485
+ Execute a REM dialect query string directly.
486
+
487
+ This is the unified entry point for executing REM queries from both
488
+ the CLI and API. It handles parsing the query string, creating the
489
+ RemQuery model, and executing it.
490
+
491
+ Args:
492
+ query_string: REM dialect query (e.g., 'LOOKUP "Sarah Chen"',
493
+ 'SEARCH resources "API design"', 'SELECT * FROM users')
494
+ user_id: Optional user ID for query isolation
495
+
496
+ Returns:
497
+ Dict with query results and metadata:
498
+ - query_type: The type of query executed
499
+ - results: List of result rows
500
+ - count: Number of results
501
+ - Additional fields depending on query type
502
+
503
+ Raises:
504
+ ValueError: If the query string is invalid
505
+ QueryExecutionError: If query execution fails
506
+
507
+ Example:
508
+ >>> result = await rem_service.execute_query_string(
509
+ ... 'LOOKUP "Sarah Chen"',
510
+ ... user_id="user-123"
511
+ ... )
512
+ >>> print(result["count"])
513
+ 1
514
+ """
515
+ # Parse the query string into type and parameters
516
+ query_type, parameters = self._parse_query_string(query_string)
517
+
518
+ # Create and validate the RemQuery model
519
+ rem_query = RemQuery.model_validate({
520
+ "query_type": query_type,
521
+ "parameters": parameters,
522
+ "user_id": user_id,
523
+ })
524
+
525
+ # Execute and return results
526
+ return await self.execute_query(rem_query)
527
+
481
528
  async def ask_rem(
482
529
  self, natural_query: str, tenant_id: str, llm_model: str | None = None, plan_mode: bool = False
483
530
  ) -> dict[str, Any]:
@@ -1,12 +1,13 @@
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
+ from .pydantic_messages import audit_session_history, session_to_pydantic_messages
5
5
  from .reload import reload_session
6
6
 
7
7
  __all__ = [
8
8
  "MessageCompressor",
9
9
  "SessionMessageStore",
10
+ "audit_session_history",
10
11
  "reload_session",
11
12
  "session_to_pydantic_messages",
12
13
  ]
@@ -96,7 +96,7 @@ class MessageCompressor:
96
96
  Returns:
97
97
  Compressed message dict
98
98
  """
99
- content = message.get("content", "")
99
+ content = message.get("content") or ""
100
100
 
101
101
  # Don't compress short messages or system messages
102
102
  if (
@@ -188,21 +188,19 @@ class SessionMessageStore:
188
188
  Ensure session exists, creating it if necessary.
189
189
 
190
190
  Args:
191
- session_id: Session identifier (maps to Session.name)
191
+ session_id: Session UUID from X-Session-Id header
192
192
  user_id: Optional user identifier
193
193
  """
194
194
  try:
195
- # Check if session already exists by name
196
- existing = await self._session_repo.find(
197
- filters={"name": session_id},
198
- limit=1,
199
- )
195
+ # Check if session already exists by UUID
196
+ existing = await self._session_repo.get_by_id(session_id)
200
197
  if existing:
201
198
  return # Session already exists
202
199
 
203
- # Create new session
200
+ # Create new session with the provided UUID as id
204
201
  session = Session(
205
- name=session_id,
202
+ id=session_id, # Use the provided UUID as session id
203
+ name=session_id, # Default name to UUID, can be updated later
206
204
  user_id=user_id or self.user_id,
207
205
  tenant_id=self.user_id, # tenant_id set to user_id for scoping
208
206
  )
@@ -244,7 +242,7 @@ class SessionMessageStore:
244
242
  # Use pre-generated id from message dict if available (for frontend feedback)
245
243
  msg = Message(
246
244
  id=message.get("id"), # Use pre-generated ID if provided
247
- content=message.get("content", ""),
245
+ content=message.get("content") or "",
248
246
  message_type=message.get("role", "assistant"),
249
247
  session_id=session_id,
250
248
  tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
@@ -321,7 +319,7 @@ class SessionMessageStore:
321
319
  Ensures session exists before storing messages.
322
320
 
323
321
  Args:
324
- session_id: Session identifier (maps to Session.name)
322
+ session_id: Session UUID
325
323
  messages: List of messages to store
326
324
  user_id: Optional user identifier
327
325
  compress: Whether to compress messages (default: True)
@@ -339,7 +337,7 @@ class SessionMessageStore:
339
337
  compressed_messages = []
340
338
 
341
339
  for idx, message in enumerate(messages):
342
- content = message.get("content", "")
340
+ content = message.get("content") or ""
343
341
 
344
342
  # Only store and compress long assistant responses
345
343
  if (
@@ -370,6 +368,8 @@ class SessionMessageStore:
370
368
  }
371
369
 
372
370
  # For tool messages, include tool call details in metadata
371
+ # Note: tool_arguments is stored only when provided (parent tool calls)
372
+ # For child tool calls (e.g., register_metadata), args are in content as JSON
373
373
  if message.get("role") == "tool":
374
374
  if message.get("tool_call_id"):
375
375
  msg_metadata["tool_call_id"] = message.get("tool_call_id")
@@ -438,6 +438,8 @@ class SessionMessageStore:
438
438
  }
439
439
 
440
440
  # For tool messages, reconstruct tool call metadata
441
+ # Note: tool_arguments may be in metadata (parent calls) or parsed from
442
+ # content (child calls like register_metadata) by pydantic_messages.py
441
443
  if role == "tool" and msg.metadata:
442
444
  if msg.metadata.get("tool_call_id"):
443
445
  msg_dict["tool_call_id"] = msg.metadata["tool_call_id"]
@@ -5,12 +5,16 @@ storage format into pydantic-ai's native ModelRequest/ModelResponse types.
5
5
 
6
6
  Key insight: When we store tool results, we only store the result (ToolReturnPart).
7
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).
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)
9
13
 
10
14
  Storage format (our simplified format):
11
15
  {"role": "user", "content": "..."}
12
16
  {"role": "assistant", "content": "..."}
13
- {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
17
+ {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}} # optional
14
18
 
15
19
  Pydantic-ai format (what the LLM expects):
16
20
  ModelRequest(parts=[UserPromptPart(content="...")])
@@ -31,6 +35,7 @@ Example usage:
31
35
  """
32
36
 
33
37
  import json
38
+ import re
34
39
  from typing import Any
35
40
 
36
41
  from loguru import logger
@@ -46,6 +51,15 @@ from pydantic_ai.messages import (
46
51
  )
47
52
 
48
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
+
49
63
  def session_to_pydantic_messages(
50
64
  session_history: list[dict[str, Any]],
51
65
  system_prompt: str | None = None,
@@ -92,7 +106,7 @@ def session_to_pydantic_messages(
92
106
  while i < len(session_history):
93
107
  msg = session_history[i]
94
108
  role = msg.get("role", "")
95
- content = msg.get("content", "")
109
+ content = msg.get("content") or ""
96
110
 
97
111
  if role == "user":
98
112
  # User messages become ModelRequest with UserPromptPart
@@ -110,8 +124,15 @@ def session_to_pydantic_messages(
110
124
  tool_msg = session_history[j]
111
125
  tool_name = tool_msg.get("tool_name", "unknown_tool")
112
126
  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", "{}")
127
+ tool_content = tool_msg.get("content") or "{}"
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 = {}
115
136
 
116
137
  # Parse tool content if it's a JSON string
117
138
  if isinstance(tool_content, str):
@@ -122,16 +143,19 @@ def session_to_pydantic_messages(
122
143
  else:
123
144
  tool_result = tool_content
124
145
 
146
+ # Sanitize tool name for OpenAI API compatibility
147
+ safe_tool_name = _sanitize_tool_name(tool_name)
148
+
125
149
  # Synthesize ToolCallPart (what the model "called")
126
150
  tool_calls.append(ToolCallPart(
127
- tool_name=tool_name,
151
+ tool_name=safe_tool_name,
128
152
  args=tool_arguments if tool_arguments else {},
129
153
  tool_call_id=tool_call_id,
130
154
  ))
131
155
 
132
156
  # Create ToolReturnPart (the actual result)
133
157
  tool_returns.append(ToolReturnPart(
134
- tool_name=tool_name,
158
+ tool_name=safe_tool_name,
135
159
  content=tool_result,
136
160
  tool_call_id=tool_call_id,
137
161
  ))
@@ -166,8 +190,15 @@ def session_to_pydantic_messages(
166
190
  # Orphan tool message (no preceding assistant) - synthesize both parts
167
191
  tool_name = msg.get("tool_name", "unknown_tool")
168
192
  tool_call_id = msg.get("tool_call_id", f"call_{i}")
169
- tool_arguments = msg.get("tool_arguments", {})
170
- tool_content = msg.get("content", "{}")
193
+ tool_content = msg.get("content") or "{}"
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 = {}
171
202
 
172
203
  # Parse tool content
173
204
  if isinstance(tool_content, str):
@@ -178,10 +209,13 @@ def session_to_pydantic_messages(
178
209
  else:
179
210
  tool_result = tool_content
180
211
 
212
+ # Sanitize tool name for OpenAI API compatibility
213
+ safe_tool_name = _sanitize_tool_name(tool_name)
214
+
181
215
  # Synthesize the tool call (ModelResponse with ToolCallPart)
182
216
  messages.append(ModelResponse(
183
217
  parts=[ToolCallPart(
184
- tool_name=tool_name,
218
+ tool_name=safe_tool_name,
185
219
  args=tool_arguments if tool_arguments else {},
186
220
  tool_call_id=tool_call_id,
187
221
  )],
@@ -191,7 +225,7 @@ def session_to_pydantic_messages(
191
225
  # Add the tool return (ModelRequest with ToolReturnPart)
192
226
  messages.append(ModelRequest(
193
227
  parts=[ToolReturnPart(
194
- tool_name=tool_name,
228
+ tool_name=safe_tool_name,
195
229
  content=tool_result,
196
230
  tool_call_id=tool_call_id,
197
231
  )]
@@ -208,3 +242,69 @@ def session_to_pydantic_messages(
208
242
 
209
243
  logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
210
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}")
@@ -12,7 +12,8 @@ Design Pattern:
12
12
 
13
13
  Message Types on Reload:
14
14
  - user: Returned as-is
15
- - tool: Returned as-is with metadata (tool_call_id, tool_name, tool_arguments)
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
16
17
  - assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
17
18
  """
18
19
 
rem/settings.py CHANGED
@@ -424,6 +424,49 @@ class AuthSettings(BaseSettings):
424
424
  google: GoogleOAuthSettings = Field(default_factory=GoogleOAuthSettings)
425
425
  microsoft: MicrosoftOAuthSettings = Field(default_factory=MicrosoftOAuthSettings)
426
426
 
427
+ # Pre-approved login codes (bypass email verification)
428
+ # Format: comma-separated codes with prefix A=admin, B=normal user
429
+ # Example: "A12345,A67890,B11111,B22222"
430
+ preapproved_codes: str = Field(
431
+ default="",
432
+ description=(
433
+ "Comma-separated list of pre-approved login codes. "
434
+ "Prefix A = admin user, B = normal user. "
435
+ "Example: 'A12345,A67890,B11111'. "
436
+ "Users can login with these codes without email verification."
437
+ ),
438
+ )
439
+
440
+ def check_preapproved_code(self, code: str) -> dict | None:
441
+ """
442
+ Check if a code is in the pre-approved list.
443
+
444
+ Args:
445
+ code: The code to check (including prefix)
446
+
447
+ Returns:
448
+ Dict with 'role' key if valid, None if not found.
449
+ - A prefix -> role='admin'
450
+ - B prefix -> role='user'
451
+ """
452
+ if not self.preapproved_codes:
453
+ return None
454
+
455
+ codes = [c.strip().upper() for c in self.preapproved_codes.split(",") if c.strip()]
456
+ code_upper = code.strip().upper()
457
+
458
+ if code_upper not in codes:
459
+ return None
460
+
461
+ # Parse prefix to determine role
462
+ if code_upper.startswith("A"):
463
+ return {"role": "admin", "code": code_upper}
464
+ elif code_upper.startswith("B"):
465
+ return {"role": "user", "code": code_upper}
466
+ else:
467
+ # Unknown prefix, treat as user
468
+ return {"role": "user", "code": code_upper}
469
+
427
470
  @field_validator("session_secret", mode="before")
428
471
  @classmethod
429
472
  def generate_dev_secret(cls, v: str | None, info: ValidationInfo) -> str:
@@ -1651,6 +1694,33 @@ class EmailSettings(BaseSettings):
1651
1694
  return kwargs
1652
1695
 
1653
1696
 
1697
+ class DebugSettings(BaseSettings):
1698
+ """
1699
+ Debug settings for development and troubleshooting.
1700
+
1701
+ Environment variables:
1702
+ DEBUG__AUDIT_SESSION - Dump session history to /tmp/{session_id}.yaml
1703
+ DEBUG__AUDIT_DIR - Directory for session audit files (default: /tmp)
1704
+ """
1705
+
1706
+ model_config = SettingsConfigDict(
1707
+ env_prefix="DEBUG__",
1708
+ env_file=".env",
1709
+ env_file_encoding="utf-8",
1710
+ extra="ignore",
1711
+ )
1712
+
1713
+ audit_session: bool = Field(
1714
+ default=False,
1715
+ description="When true, dump full session history to audit files for debugging",
1716
+ )
1717
+
1718
+ audit_dir: str = Field(
1719
+ default="/tmp",
1720
+ description="Directory for session audit files",
1721
+ )
1722
+
1723
+
1654
1724
  class TestSettings(BaseSettings):
1655
1725
  """
1656
1726
  Test environment settings.
@@ -1767,6 +1837,7 @@ class Settings(BaseSettings):
1767
1837
  schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
1768
1838
  email: EmailSettings = Field(default_factory=EmailSettings)
1769
1839
  test: TestSettings = Field(default_factory=TestSettings)
1840
+ debug: DebugSettings = Field(default_factory=DebugSettings)
1770
1841
 
1771
1842
 
1772
1843
  # Auto-load .env file from current directory if it exists
@@ -822,7 +822,7 @@ COMMENT ON FUNCTION fn_get_shared_messages IS
822
822
  -- Function to list sessions with user details (name, email) for admin views
823
823
 
824
824
  -- List sessions with user info, CTE pagination
825
- -- Note: messages.session_id stores the session name (not UUID), so we join on sessions.name
825
+ -- Note: messages.session_id stores the session UUID (sessions.id)
826
826
  CREATE OR REPLACE FUNCTION fn_list_sessions_with_user(
827
827
  p_user_id VARCHAR(256) DEFAULT NULL, -- Filter by user_id (NULL = all users, admin only)
828
828
  p_user_name VARCHAR(256) DEFAULT NULL, -- Filter by user name (partial match, admin only)
@@ -849,9 +849,9 @@ RETURNS TABLE(
849
849
  BEGIN
850
850
  RETURN QUERY
851
851
  WITH session_msg_counts AS (
852
- -- Count messages per session (joining on session name since messages.session_id = sessions.name)
852
+ -- Count messages per session (joining on session UUID)
853
853
  SELECT
854
- m.session_id as session_name,
854
+ m.session_id,
855
855
  COUNT(*)::INTEGER as actual_message_count
856
856
  FROM messages m
857
857
  GROUP BY m.session_id
@@ -872,7 +872,7 @@ BEGIN
872
872
  s.metadata
873
873
  FROM sessions s
874
874
  LEFT JOIN users u ON u.id::text = s.user_id
875
- LEFT JOIN session_msg_counts mc ON mc.session_name = s.name
875
+ LEFT JOIN session_msg_counts mc ON mc.session_id = s.id::text
876
876
  WHERE s.deleted_at IS NULL
877
877
  AND (p_user_id IS NULL OR s.user_id = p_user_id)
878
878
  AND (p_user_name IS NULL OR u.name ILIKE '%' || p_user_name || '%')