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
@@ -18,6 +18,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
18
18
  from loguru import logger
19
19
  from pydantic import BaseModel, Field
20
20
 
21
+ from .common import ErrorResponse
22
+
21
23
  from ..deps import get_current_user, require_auth
22
24
  from ...models.entities import (
23
25
  Message,
@@ -83,6 +85,10 @@ class ShareSessionResponse(BaseModel):
83
85
  response_model=ShareSessionResponse,
84
86
  status_code=201,
85
87
  tags=["sessions"],
88
+ responses={
89
+ 400: {"model": ErrorResponse, "description": "Session already shared with this user"},
90
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
91
+ },
86
92
  )
87
93
  async def share_session(
88
94
  request: Request,
@@ -175,6 +181,10 @@ async def share_session(
175
181
  "/sessions/{session_id}/share/{shared_with_user_id}",
176
182
  status_code=200,
177
183
  tags=["sessions"],
184
+ responses={
185
+ 404: {"model": ErrorResponse, "description": "Share not found"},
186
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
187
+ },
178
188
  )
179
189
  async def remove_session_share(
180
190
  request: Request,
@@ -250,6 +260,9 @@ async def remove_session_share(
250
260
  "/sessions/shared-with-me",
251
261
  response_model=SharedWithMeResponse,
252
262
  tags=["sessions"],
263
+ responses={
264
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
265
+ },
253
266
  )
254
267
  async def get_shared_with_me(
255
268
  request: Request,
@@ -328,6 +341,9 @@ async def get_shared_with_me(
328
341
  "/sessions/shared-with-me/{owner_user_id}/messages",
329
342
  response_model=SharedMessagesResponse,
330
343
  tags=["sessions"],
344
+ responses={
345
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
346
+ },
331
347
  )
332
348
  async def get_shared_messages(
333
349
  request: Request,
rem/cli/commands/ask.py CHANGED
@@ -71,16 +71,18 @@ async def run_agent_streaming(
71
71
  max_turns: int = 10,
72
72
  context: AgentContext | None = None,
73
73
  max_iterations: int | None = None,
74
+ user_message: str | None = None,
74
75
  ) -> None:
75
76
  """
76
- Run agent in streaming mode using agent.iter() with usage limits.
77
+ Run agent in streaming mode using the SAME code path as the API.
77
78
 
78
- Design Pattern:
79
- - Use agent.iter() for complete execution with tool call visibility
80
- - run_stream() stops after first output, missing tool calls
81
- - Stream tool call markers: [Calling: tool_name]
82
- - Stream text content deltas as they arrive
83
- - Show final structured result
79
+ This uses stream_openai_response_with_save from the API to ensure:
80
+ 1. Tool calls are saved as separate "tool" messages (not embedded in content)
81
+ 2. Assistant response is clean text only (no [Calling: ...] markers)
82
+ 3. CLI testing is equivalent to API testing
83
+
84
+ The CLI displays tool calls as [Calling: tool_name] for visibility,
85
+ but these are NOT saved to the database.
84
86
 
85
87
  Args:
86
88
  agent: Pydantic AI agent
@@ -88,88 +90,66 @@ async def run_agent_streaming(
88
90
  max_turns: Maximum turns for agent execution (not used in current API)
89
91
  context: Optional AgentContext for session persistence
90
92
  max_iterations: Maximum iterations/requests (from agent schema or settings)
93
+ user_message: The user's original message (for database storage)
91
94
  """
92
- from pydantic_ai import UsageLimits
93
- from rem.utils.date_utils import to_iso_with_z, utc_now
95
+ import json
96
+ from rem.api.routers.chat.streaming import stream_openai_response_with_save, save_user_message
94
97
 
95
98
  logger.info("Running agent in streaming mode...")
96
99
 
97
100
  try:
98
- # Import event types for streaming
99
- from pydantic_ai import Agent as PydanticAgent
100
- from pydantic_ai.messages import PartStartEvent, PartDeltaEvent, TextPartDelta, ToolCallPart
101
-
102
- # Accumulate assistant response for session persistence
103
- assistant_response_parts = []
104
-
105
- # Use agent.iter() to get complete execution with tool calls
106
- usage_limits = UsageLimits(request_limit=max_iterations) if max_iterations else None
107
- async with agent.iter(prompt, usage_limits=usage_limits) as agent_run:
108
- async for node in agent_run:
109
- # Check if this is a model request node (includes tool calls and text)
110
- if PydanticAgent.is_model_request_node(node):
111
- # Stream events from model request
112
- request_stream: Any
113
- async with node.stream(agent_run.ctx) as request_stream:
114
- async for event in request_stream:
115
- # Tool call start event
116
- if isinstance(event, PartStartEvent) and isinstance(
117
- event.part, ToolCallPart
118
- ):
119
- tool_marker = f"\n[Calling: {event.part.tool_name}]"
120
- print(tool_marker, flush=True)
121
- assistant_response_parts.append(tool_marker)
122
-
123
- # Text content delta
124
- elif isinstance(event, PartDeltaEvent) and isinstance(
125
- event.delta, TextPartDelta
126
- ):
127
- print(event.delta.content_delta, end="", flush=True)
128
- assistant_response_parts.append(event.delta.content_delta)
129
-
130
- print("\n") # Final newline after streaming
131
-
132
- # Get final result from agent_run
133
- result = agent_run.result
134
- if hasattr(result, "output"):
135
- logger.info("Final structured result:")
136
- output = result.output
137
- from rem.agentic.serialization import serialize_agent_result
138
- output_json = json.dumps(serialize_agent_result(output), indent=2)
139
- print(output_json)
140
- assistant_response_parts.append(f"\n{output_json}")
141
-
142
- # Save session messages (if session_id provided and postgres enabled)
143
- if context and context.session_id and settings.postgres.enabled:
144
- from ...services.session.compression import SessionMessageStore
145
-
146
- # Extract just the user query from prompt
147
- # Prompt format from ContextBuilder: system + history + user message
148
- # We need to extract the last user message
149
- user_message_content = prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt
150
-
151
- user_message = {
152
- "role": "user",
153
- "content": user_message_content,
154
- "timestamp": to_iso_with_z(utc_now()),
155
- }
156
-
157
- assistant_message = {
158
- "role": "assistant",
159
- "content": "".join(assistant_response_parts),
160
- "timestamp": to_iso_with_z(utc_now()),
161
- }
162
-
163
- # Store messages with compression
164
- store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
165
- await store.store_session_messages(
101
+ # Save user message BEFORE streaming (same as API, using shared utility)
102
+ if context and context.session_id and user_message:
103
+ await save_user_message(
166
104
  session_id=context.session_id,
167
- messages=[user_message, assistant_message],
168
105
  user_id=context.user_id,
169
- compress=True,
106
+ content=user_message,
170
107
  )
171
108
 
172
- logger.debug(f"Saved conversation to session {context.session_id}")
109
+ # Use the API streaming code path for consistency
110
+ # This properly handles tool calls and message persistence
111
+ model_name = getattr(agent, 'model', 'unknown')
112
+ if hasattr(model_name, 'model_name'):
113
+ model_name = model_name.model_name
114
+ elif hasattr(model_name, 'name'):
115
+ model_name = model_name.name
116
+ else:
117
+ model_name = str(model_name)
118
+
119
+ async for chunk in stream_openai_response_with_save(
120
+ agent=agent.agent if hasattr(agent, 'agent') else agent,
121
+ prompt=prompt,
122
+ model=model_name,
123
+ session_id=context.session_id if context else None,
124
+ user_id=context.user_id if context else None,
125
+ agent_context=context,
126
+ ):
127
+ # Parse SSE chunks for CLI display
128
+ if chunk.startswith("event: tool_call"):
129
+ # Extract tool call info from next data line
130
+ continue
131
+ elif chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
132
+ try:
133
+ data_str = chunk[6:].strip()
134
+ if data_str:
135
+ data = json.loads(data_str)
136
+ # Check for tool_call event
137
+ if data.get("type") == "tool_call":
138
+ tool_name = data.get("tool_name", "tool")
139
+ status = data.get("status", "")
140
+ if status == "started":
141
+ print(f"\n[Calling: {tool_name}]", flush=True)
142
+ # Check for text content (OpenAI format)
143
+ elif "choices" in data and data["choices"]:
144
+ delta = data["choices"][0].get("delta", {})
145
+ content = delta.get("content")
146
+ if content:
147
+ print(content, end="", flush=True)
148
+ except (json.JSONDecodeError, KeyError, IndexError):
149
+ pass
150
+
151
+ print("\n") # Final newline after streaming
152
+ logger.info("Final structured result:")
173
153
 
174
154
  except Exception as e:
175
155
  logger.error(f"Agent execution failed: {e}")
@@ -184,9 +164,13 @@ async def run_agent_non_streaming(
184
164
  context: AgentContext | None = None,
185
165
  plan: bool = False,
186
166
  max_iterations: int | None = None,
167
+ user_message: str | None = None,
187
168
  ) -> dict[str, Any] | None:
188
169
  """
189
- Run agent in non-streaming mode using agent.run() with usage limits.
170
+ Run agent in non-streaming mode using agent.iter() to capture tool calls.
171
+
172
+ This mirrors the streaming code path to ensure tool messages are properly
173
+ persisted to the database for state tracking across turns.
190
174
 
191
175
  Args:
192
176
  agent: Pydantic AI agent
@@ -196,77 +180,183 @@ async def run_agent_non_streaming(
196
180
  context: Optional AgentContext for session persistence
197
181
  plan: If True, output only the generated query (for query-agent)
198
182
  max_iterations: Maximum iterations/requests (from agent schema or settings)
183
+ user_message: The user's original message (for database storage)
199
184
 
200
185
  Returns:
201
186
  Output data if successful, None otherwise
202
187
  """
203
188
  from pydantic_ai import UsageLimits
189
+ from pydantic_ai.agent import Agent
190
+ from pydantic_ai.messages import (
191
+ FunctionToolResultEvent,
192
+ PartStartEvent,
193
+ PartEndEvent,
194
+ TextPart,
195
+ ToolCallPart,
196
+ )
204
197
  from rem.utils.date_utils import to_iso_with_z, utc_now
205
198
 
206
199
  logger.info("Running agent in non-streaming mode...")
207
200
 
208
201
  try:
209
- # Run agent and get complete result with usage limits
210
- usage_limits = UsageLimits(request_limit=max_iterations) if max_iterations else None
211
- result = await agent.run(prompt, usage_limits=usage_limits)
202
+ # Track tool calls for persistence (same as streaming code path)
203
+ tool_calls: list = []
204
+ pending_tool_data: dict = {}
205
+ pending_tool_completions: list = []
206
+ accumulated_content: list = []
207
+
208
+ # Get the underlying pydantic-ai agent
209
+ pydantic_agent = agent.agent if hasattr(agent, 'agent') else agent
210
+
211
+ # Use agent.iter() to capture tool calls (same as streaming)
212
+ async with pydantic_agent.iter(prompt) as agent_run:
213
+ async for node in agent_run:
214
+ # Handle model request nodes (text + tool call starts)
215
+ if Agent.is_model_request_node(node):
216
+ async with node.stream(agent_run.ctx) as request_stream:
217
+ async for event in request_stream:
218
+ # Capture text content
219
+ if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
220
+ if event.part.content:
221
+ accumulated_content.append(event.part.content)
222
+
223
+ # Capture tool call starts
224
+ elif isinstance(event, PartStartEvent) and isinstance(event.part, ToolCallPart):
225
+ tool_name = event.part.tool_name
226
+ if tool_name == "final_result":
227
+ continue
228
+
229
+ import uuid
230
+ tool_id = f"call_{uuid.uuid4().hex[:8]}"
231
+ pending_tool_completions.append((tool_name, tool_id))
232
+
233
+ # Extract arguments
234
+ args_dict = {}
235
+ if hasattr(event.part, 'args'):
236
+ args = event.part.args
237
+ if isinstance(args, str):
238
+ try:
239
+ args_dict = json.loads(args)
240
+ except json.JSONDecodeError:
241
+ args_dict = {"raw": args}
242
+ elif isinstance(args, dict):
243
+ args_dict = args
244
+
245
+ pending_tool_data[tool_id] = {
246
+ "tool_name": tool_name,
247
+ "tool_id": tool_id,
248
+ "arguments": args_dict,
249
+ }
250
+
251
+ # Print tool call for CLI visibility
252
+ print(f"\n[Calling: {tool_name}]", flush=True)
253
+
254
+ # Capture tool call end (update arguments if changed)
255
+ elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
256
+ pass # Arguments already captured at start
257
+
258
+ # Handle tool execution nodes (results)
259
+ elif Agent.is_call_tools_node(node):
260
+ async with node.stream(agent_run.ctx) as tools_stream:
261
+ async for event in tools_stream:
262
+ if isinstance(event, FunctionToolResultEvent):
263
+ # Get tool info from pending queue
264
+ if pending_tool_completions:
265
+ tool_name, tool_id = pending_tool_completions.pop(0)
266
+ else:
267
+ import uuid
268
+ tool_name = "tool"
269
+ tool_id = f"call_{uuid.uuid4().hex[:8]}"
270
+
271
+ result_content = event.result.content if hasattr(event.result, 'content') else event.result
272
+
273
+ # Capture tool call for persistence
274
+ if tool_id in pending_tool_data:
275
+ tool_data = pending_tool_data[tool_id]
276
+ tool_data["result"] = result_content
277
+ tool_calls.append(tool_data)
278
+ del pending_tool_data[tool_id]
279
+
280
+ # Get final result
281
+ result = agent_run.result
212
282
 
213
283
  # Extract output data
214
284
  output_data = None
215
285
  assistant_content = None
216
- if hasattr(result, "output"):
286
+ if result is not None and hasattr(result, "output"):
217
287
  output = result.output
218
288
  from rem.agentic.serialization import serialize_agent_result
219
289
  output_data = serialize_agent_result(output)
220
290
 
221
291
  if plan and isinstance(output_data, dict) and "query" in output_data:
222
- # Plan mode: Output only the query
223
- # Use sql formatting if possible or just raw string
224
292
  assistant_content = output_data["query"]
225
293
  print(assistant_content)
226
294
  else:
227
- # Normal mode
228
- assistant_content = json.dumps(output_data, indent=2)
295
+ # For string output, use it directly
296
+ if isinstance(output_data, str):
297
+ assistant_content = output_data
298
+ else:
299
+ assistant_content = json.dumps(output_data, indent=2)
229
300
  print(assistant_content)
230
301
  else:
231
- # Fallback for text-only results
232
- assistant_content = str(result)
233
- print(assistant_content)
302
+ assistant_content = str(result) if result else ""
303
+ if assistant_content:
304
+ print(assistant_content)
234
305
 
235
306
  # Save to file if requested
236
307
  if output_file and output_data:
237
308
  await _save_output_file(output_file, output_data)
238
309
 
239
- # Save session messages (if session_id provided and postgres enabled)
310
+ # Save session messages including tool calls (same as streaming code path)
240
311
  if context and context.session_id and settings.postgres.enabled:
241
312
  from ...services.session.compression import SessionMessageStore
242
313
 
243
- # Extract just the user query from prompt
244
- # Prompt format from ContextBuilder: system + history + user message
245
- # We need to extract the last user message
246
- user_message_content = prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt
314
+ timestamp = to_iso_with_z(utc_now())
315
+ messages_to_store = []
247
316
 
248
- user_message = {
317
+ # Save user message first
318
+ user_message_content = user_message or (prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt)
319
+ messages_to_store.append({
249
320
  "role": "user",
250
321
  "content": user_message_content,
251
- "timestamp": to_iso_with_z(utc_now()),
252
- }
253
-
254
- assistant_message = {
255
- "role": "assistant",
256
- "content": assistant_content,
257
- "timestamp": to_iso_with_z(utc_now()),
258
- }
259
-
260
- # Store messages with compression
322
+ "timestamp": timestamp,
323
+ })
324
+
325
+ # Save tool call messages (message_type: "tool") - CRITICAL for state tracking
326
+ for tool_call in tool_calls:
327
+ if not tool_call:
328
+ continue
329
+ tool_message = {
330
+ "role": "tool",
331
+ "content": json.dumps(tool_call.get("result", {}), default=str),
332
+ "timestamp": timestamp,
333
+ "tool_call_id": tool_call.get("tool_id"),
334
+ "tool_name": tool_call.get("tool_name"),
335
+ "tool_arguments": tool_call.get("arguments"),
336
+ }
337
+ messages_to_store.append(tool_message)
338
+
339
+ # Save assistant message
340
+ if assistant_content:
341
+ messages_to_store.append({
342
+ "role": "assistant",
343
+ "content": assistant_content,
344
+ "timestamp": timestamp,
345
+ })
346
+
347
+ # Store all messages
261
348
  store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
262
349
  await store.store_session_messages(
263
350
  session_id=context.session_id,
264
- messages=[user_message, assistant_message],
351
+ messages=messages_to_store,
265
352
  user_id=context.user_id,
266
- compress=True,
353
+ compress=False, # Store uncompressed; compression happens on reload
267
354
  )
268
355
 
269
- logger.debug(f"Saved conversation to session {context.session_id}")
356
+ logger.debug(
357
+ f"Saved {len(tool_calls)} tool calls + user/assistant messages "
358
+ f"to session {context.session_id}"
359
+ )
270
360
 
271
361
  return output_data
272
362
 
@@ -352,8 +442,8 @@ async def _save_output_file(file_path: Path, data: dict[str, Any]) -> None:
352
442
  )
353
443
  @click.option(
354
444
  "--stream/--no-stream",
355
- default=False,
356
- help="Enable streaming mode (default: disabled)",
445
+ default=True,
446
+ help="Enable streaming mode (default: enabled)",
357
447
  )
358
448
  @click.option(
359
449
  "--user-id",
@@ -549,7 +639,7 @@ async def _ask_async(
549
639
 
550
640
  # Run agent with session persistence
551
641
  if stream:
552
- await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context)
642
+ await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context, user_message=query)
553
643
  else:
554
644
  await run_agent_non_streaming(
555
645
  agent,
@@ -558,6 +648,7 @@ async def _ask_async(
558
648
  output_file=output_file,
559
649
  context=context,
560
650
  plan=plan,
651
+ user_message=query,
561
652
  )
562
653
 
563
654
  # Log session ID for reuse
@@ -193,7 +193,15 @@ def process_ingest(
193
193
  try:
194
194
  # Read file content
195
195
  content = file_path.read_text(encoding="utf-8")
196
- entity_key = file_path.stem # filename without extension
196
+
197
+ # Generate entity key from filename
198
+ # Special case: README files use parent directory as section name
199
+ if file_path.stem.lower() == "readme":
200
+ # Use parent directory name, e.g., "drugs" for drugs/README.md
201
+ # For nested paths like disorders/anxiety/README.md -> "anxiety"
202
+ entity_key = file_path.parent.name
203
+ else:
204
+ entity_key = file_path.stem # filename without extension
197
205
 
198
206
  # Build entity based on table
199
207
  entity_data = {
@@ -206,9 +214,9 @@ def process_ingest(
206
214
  if category:
207
215
  entity_data["category"] = category
208
216
 
209
- # Scoping: user_id for private data, None for public/shared
210
- # tenant_id=None and user_id=None means PUBLIC data (visible to all)
211
- entity_data["tenant_id"] = user_id # None = public/shared
217
+ # Scoping: user_id for private data, "public" for shared
218
+ # tenant_id="public" is the default for shared knowledge bases
219
+ entity_data["tenant_id"] = user_id or "public"
212
220
  entity_data["user_id"] = user_id # None = public/shared
213
221
 
214
222
  # For ontologies, add URI
@@ -0,0 +1,109 @@
1
+ """
2
+ REM query command.
3
+
4
+ Usage:
5
+ rem query --sql 'LOOKUP "Sarah Chen"'
6
+ rem query --sql 'SEARCH resources "API design" LIMIT 10'
7
+ rem query --sql "SELECT * FROM resources LIMIT 5"
8
+ rem query --file queries/my_query.sql
9
+
10
+ This tool connects to the configured PostgreSQL instance and executes the
11
+ provided REM dialect query, printing results as JSON (default) or plain dicts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ from pathlib import Path
19
+ from typing import List
20
+
21
+ import click
22
+ from loguru import logger
23
+
24
+ from ...services.rem import QueryExecutionError
25
+ from ...services.rem.service import RemService
26
+
27
+
28
+ @click.command("query")
29
+ @click.option("--sql", "-s", default=None, help="REM query string (LOOKUP, SEARCH, FUZZY, TRAVERSE, or SQL)")
30
+ @click.option(
31
+ "--file",
32
+ "-f",
33
+ "sql_file",
34
+ type=click.Path(exists=True, path_type=Path),
35
+ default=None,
36
+ help="Path to file containing REM query",
37
+ )
38
+ @click.option("--no-json", is_flag=True, default=False, help="Print rows as Python dicts instead of JSON")
39
+ @click.option("--user-id", "-u", default=None, help="Scope query to a specific user")
40
+ def query_command(sql: str | None, sql_file: Path | None, no_json: bool, user_id: str | None):
41
+ """
42
+ Execute a REM query against the database.
43
+
44
+ Supports REM dialect queries (LOOKUP, SEARCH, FUZZY, TRAVERSE) and raw SQL.
45
+ Either --sql or --file must be provided.
46
+ """
47
+ if not sql and not sql_file:
48
+ click.secho("Error: either --sql or --file is required", fg="red")
49
+ raise click.Abort()
50
+
51
+ # Read query from file if provided
52
+ if sql_file:
53
+ query_text = sql_file.read_text(encoding="utf-8")
54
+ else:
55
+ query_text = sql # type: ignore[assignment]
56
+
57
+ try:
58
+ asyncio.run(_run_query_async(query_text, not no_json, user_id))
59
+ except Exception as exc: # pragma: no cover - CLI error path
60
+ logger.exception("Query failed")
61
+ click.secho(f"✗ Query failed: {exc}", fg="red")
62
+ raise click.Abort()
63
+
64
+
65
+ async def _run_query_async(query_text: str, as_json: bool, user_id: str | None) -> None:
66
+ """
67
+ Execute the query using RemService.execute_query_string().
68
+ """
69
+ from ...services.postgres import get_postgres_service
70
+
71
+ db = get_postgres_service()
72
+ if not db:
73
+ click.secho("✗ PostgreSQL is disabled in settings. Enable with POSTGRES__ENABLED=true", fg="red")
74
+ raise click.Abort()
75
+
76
+ if db.pool is None:
77
+ await db.connect()
78
+
79
+ rem_service = RemService(db)
80
+
81
+ try:
82
+ # Use the unified execute_query_string method
83
+ result = await rem_service.execute_query_string(query_text, user_id=user_id)
84
+ output_rows = result.get("results", [])
85
+ except QueryExecutionError as qe:
86
+ logger.exception("Query execution failed")
87
+ click.secho(f"✗ Query execution failed: {qe}. Please check the query you provided and try again.", fg="red")
88
+ raise click.Abort()
89
+ except ValueError as ve:
90
+ # Parse errors from the query parser
91
+ click.secho(f"✗ Invalid query: {ve}", fg="red")
92
+ raise click.Abort()
93
+ except Exception as exc: # pragma: no cover - CLI error path
94
+ logger.exception("Unexpected error during query execution")
95
+ click.secho("✗ An unexpected error occurred while executing the query. Please check the query you provided and try again.", fg="red")
96
+ raise click.Abort()
97
+
98
+ if as_json:
99
+ click.echo(json.dumps(output_rows, default=str, indent=2))
100
+ else:
101
+ for r in output_rows:
102
+ click.echo(str(r))
103
+
104
+
105
+ def register_command(cli_group):
106
+ """Register the query command on the given CLI group (top-level)."""
107
+ cli_group.add_command(query_command)
108
+
109
+