mtrx-cli 0.1.25 → 0.1.26

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.
@@ -23,16 +23,74 @@ import httpx
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
- # Cursor AI RPC paths (Connect protocol). RunSSE = main chat, StreamCpp = code completion.
26
+ # Cursor AI RPC paths (Connect protocol).
27
+ # Cursor uses aiserver.v1.AiServerService for all AI endpoints.
27
28
  _AI_PATH_PATTERNS = (
29
+ r"AiServerService",
30
+ r"AiService",
31
+ r"ChatService",
32
+ r"StreamUnifiedChat",
33
+ r"StreamDiff",
34
+ r"GetCompletion",
28
35
  r"RunSSE",
29
36
  r"StreamCpp",
30
37
  r"BidiAppend",
31
38
  r"AgentService",
39
+ r"/v1/messages", # Anthropic Messages API
40
+ r"/v1/chat/completions", # OpenAI Chat Completions
41
+ )
42
+ _REROUTABLE_AI_PATH_PATTERNS = (
43
+ r"AiService/.+(Stream|Run|Chat|Completion)",
44
+ r"StreamUnifiedChat",
45
+ r"RunSSE",
46
+ r"AgentService",
47
+ r"ChatService",
48
+ r"CppService/.+(Complete|Stream)",
49
+ r"/v1/messages",
50
+ r"/v1/chat/completions",
51
+ )
52
+ _AI_SERVICE_CANDIDATE_PATTERNS = (
53
+ r"AiServerService",
32
54
  r"AiService",
55
+ r"AgentService",
56
+ r"ChatService",
57
+ r"CppService",
58
+ r"composer",
33
59
  )
34
60
 
35
61
 
62
+ def is_ai_candidate_request(method: str, path: str, headers: dict[str, str] | None = None) -> bool:
63
+ """Return True when the request looks like Cursor/model traffic worth inspecting."""
64
+ if method.upper() != "POST" or not path:
65
+ return False
66
+ if is_ai_path(path):
67
+ return True
68
+ content_type = ((headers or {}).get("content-type") or "").lower()
69
+ return any(re.search(p, path, re.IGNORECASE) for p in _AI_SERVICE_CANDIDATE_PATTERNS) and (
70
+ "connect+proto" in content_type
71
+ or "application/proto" in content_type
72
+ or "grpc" in content_type
73
+ or "application/json" in content_type
74
+ )
75
+
76
+
77
+ def is_reroutable_ai_path(path: str) -> bool:
78
+ """Return True if the path is a supported AI endpoint for active reroute."""
79
+ if not path:
80
+ return False
81
+ return any(re.search(p, path, re.IGNORECASE) for p in _REROUTABLE_AI_PATH_PATTERNS)
82
+
83
+
84
+ def classify_ai_request(method: str, path: str, headers: dict[str, str] | None = None) -> dict[str, bool]:
85
+ """Classify Cursor requests for logging and reroute decisions."""
86
+ candidate = is_ai_candidate_request(method, path, headers)
87
+ reroutable = candidate and is_reroutable_ai_path(path)
88
+ return {
89
+ "candidate": candidate,
90
+ "reroutable": reroutable,
91
+ }
92
+
93
+
36
94
  def is_ai_path(path: str) -> bool:
37
95
  """Return True if this path is an AI/LLM endpoint we should reroute to MTRX."""
38
96
  if not path:
@@ -40,6 +98,122 @@ def is_ai_path(path: str) -> bool:
40
98
  return any(re.search(p, path, re.IGNORECASE) for p in _AI_PATH_PATTERNS)
41
99
 
42
100
 
101
+ def _detect_provider_from_model(model: str) -> str:
102
+ normalized = (model or "").strip().lower()
103
+ if normalized.startswith("claude-"):
104
+ return "anthropic"
105
+ if normalized.startswith("gemini"):
106
+ return "google"
107
+ return "openai"
108
+
109
+
110
+ def _extract_request_prompt(*, req_proto: Any, extracted: dict[str, Any]) -> str:
111
+ parts: list[str] = []
112
+
113
+ summary = (extracted.get("conversation_summary") or "").strip()
114
+ if summary:
115
+ parts.append(summary)
116
+
117
+ debug_info = (getattr(getattr(req_proto, "cmd_k_debug_info", None), "debug_info", "") or "").strip()
118
+ if debug_info:
119
+ parts.append(debug_info)
120
+
121
+ rules = [
122
+ (getattr(rule, "rule_definition", "") or "").strip()
123
+ for rule in list(getattr(req_proto, "rules", []) or [])[:3]
124
+ if (getattr(rule, "rule_definition", "") or "").strip()
125
+ ]
126
+ if rules:
127
+ parts.append("Project rules:\n" + "\n".join(f"- {rule}" for rule in rules))
128
+
129
+ doc_ids = [
130
+ value.strip()
131
+ for value in list(getattr(getattr(req_proto, "legacy_context", None), "documentation_identifiers", []) or [])[:5]
132
+ if isinstance(value, str) and value.strip()
133
+ ]
134
+ if doc_ids:
135
+ parts.append("Relevant docs:\n" + "\n".join(f"- {value}" for value in doc_ids))
136
+
137
+ files = [
138
+ entry.get("path", "").strip()
139
+ for entry in extracted.get("files", [])[:8]
140
+ if entry.get("path")
141
+ ]
142
+ if files:
143
+ parts.append("Active files:\n" + "\n".join(f"- {path}" for path in files))
144
+
145
+ edits = [
146
+ entry.get("path", "").strip()
147
+ for entry in extracted.get("edits", [])[:5]
148
+ if entry.get("path")
149
+ ]
150
+ if edits:
151
+ parts.append("Recent edits:\n" + "\n".join(f"- {path}" for path in edits))
152
+
153
+ return "\n\n".join(part for part in parts if part).strip()
154
+
155
+
156
+ def _build_matrx_upstream_request(
157
+ *,
158
+ req_proto: Any,
159
+ extracted: dict[str, Any],
160
+ ) -> tuple[str, dict[str, str], dict[str, Any]] | None:
161
+ model = (extracted.get("model") or "").strip()
162
+ prompt = _extract_request_prompt(req_proto=req_proto, extracted=extracted)
163
+ if not model or not prompt:
164
+ return None
165
+
166
+ provider = _detect_provider_from_model(model)
167
+ if provider == "anthropic":
168
+ return (
169
+ "/v1/messages",
170
+ {"x-api-key": ""},
171
+ {
172
+ "model": model,
173
+ "max_tokens": 2048,
174
+ "messages": [{"role": "user", "content": prompt}],
175
+ },
176
+ )
177
+
178
+ return (
179
+ "/v1/chat/completions",
180
+ {"authorization": ""},
181
+ {
182
+ "model": model,
183
+ "messages": [{"role": "user", "content": prompt}],
184
+ "temperature": 0.2,
185
+ },
186
+ )
187
+
188
+
189
+ def _build_cursor_response_bytes(*, text: str, usage: dict[str, Any] | None = None) -> bytes | None:
190
+ try:
191
+ from matrx.cli.cursor_connect import build_connect_frame
192
+ from matrx.cli.cursor_proto import _PROTOS_AVAILABLE, server_chat_pb2 # type: ignore[import]
193
+ except Exception:
194
+ return None
195
+
196
+ if not _PROTOS_AVAILABLE:
197
+ return None
198
+
199
+ frames: list[bytes] = []
200
+ text = (text or "").strip()
201
+ if text:
202
+ content_resp = server_chat_pb2.StreamUnifiedChatWithToolsResponse()
203
+ content_resp.content.text = text
204
+ frames.append(build_connect_frame(0x00, content_resp.SerializeToString()))
205
+
206
+ if usage:
207
+ usage_resp = server_chat_pb2.StreamUnifiedChatWithToolsResponse()
208
+ usage_resp.usage.input_tokens = int(usage.get("input_tokens", 0) or 0)
209
+ usage_resp.usage.output_tokens = int(usage.get("output_tokens", 0) or 0)
210
+ if usage_resp.usage.input_tokens or usage_resp.usage.output_tokens:
211
+ frames.append(build_connect_frame(0x00, usage_resp.SerializeToString()))
212
+
213
+ frames.append(build_connect_frame(0x02, b"{}"))
214
+ return b"".join(frames)
215
+
216
+
43
217
  def _cursor_model_to_openai(cursor_model: str) -> str:
44
218
  """Map Cursor model names to OpenAI-style names MTRX expects."""
45
219
  # Cursor uses names like "claude-sonnet-4" or "gpt-4o" - usually compatible
@@ -54,7 +228,12 @@ def _build_search_query(extracted: dict[str, Any]) -> str:
54
228
  query_parts = [f.get("path", "").strip() for f in files[:3] if f.get("path")]
55
229
  if query_parts:
56
230
  return " ".join(query_parts)
57
- return (extracted.get("conversation_summary") or "").strip()
231
+ summary = extracted.get("conversation_summary") or ""
232
+ if isinstance(summary, str):
233
+ return summary.strip()
234
+ if isinstance(summary, dict):
235
+ return str(summary.get("summary") or "").strip()
236
+ return str(summary).strip()
58
237
 
59
238
 
60
239
  def _prepend_context_items(context_items: Any, injected_items: list[Any]) -> None:
@@ -129,15 +308,118 @@ async def try_reroute_to_matrx(
129
308
  (success, response_headers, response_body, is_streaming) if handled,
130
309
  None to fall back to normal forward.
131
310
  """
132
- if method != "POST" or not is_ai_path(path):
311
+ classification = classify_ai_request(method, path, req_headers)
312
+ if not classification["candidate"]:
313
+ return None
314
+ if not classification["reroutable"]:
315
+ logger.info("cursor_reroute: candidate AI path not yet reroutable: %s", path)
133
316
  return None
134
317
 
135
- # TODO: Full protobuf parsing. Cursor uses Connect/gRPC with binary frames.
136
- # For now we don't have the proto conversion - fall back to forward.
137
- # When implemented: parse req_body, extract messages+model, call MTRX,
138
- # convert response back to Cursor's gRPC format.
139
- logger.debug("cursor_reroute: path=%s would reroute (protobuf conversion not yet implemented)", path)
140
- return None
318
+ try:
319
+ from matrx.cli.cursor_connect import is_connect_proto_request, parse_connect_frame
320
+ from matrx.cli.cursor_extraction import (
321
+ _PROTOS_AVAILABLE,
322
+ extract_from_anthropic_sse_response,
323
+ extract_from_openai_sse_response,
324
+ extract_from_request,
325
+ parse_request_proto,
326
+ ship_ai_telemetry,
327
+ )
328
+ except Exception:
329
+ return None
330
+
331
+ if not _PROTOS_AVAILABLE or not is_connect_proto_request(req_headers):
332
+ logger.info("cursor_reroute: reroutable path lacks compiled proto support: %s", path)
333
+ return None
334
+
335
+ import gzip as _gzip
336
+
337
+ body = req_body
338
+ ce = req_headers.get("content-encoding", "").lower()
339
+ if ce == "gzip" or (len(body) >= 2 and body[:2] == b"\x1f\x8b"):
340
+ try:
341
+ body = _gzip.decompress(body)
342
+ except Exception:
343
+ return None
344
+
345
+ try:
346
+ _, proto_bytes = parse_connect_frame(body)
347
+ except ValueError:
348
+ proto_bytes = body
349
+
350
+ req_proto = parse_request_proto(proto_bytes)
351
+ if req_proto is None:
352
+ logger.info("cursor_reroute: parse_request_proto failed for %s", path)
353
+ return None
354
+
355
+ extracted = extract_from_request(req_proto)
356
+ extracted["session_id"] = extracted.get("session_id") or session_id or ""
357
+ asyncio.create_task(ship_ai_telemetry(extracted, matrx_base_url, matrx_key))
358
+
359
+ upstream_request = _build_matrx_upstream_request(req_proto=req_proto, extracted=extracted)
360
+ if upstream_request is None:
361
+ logger.info("cursor_reroute: insufficient prompt/model data for %s", path)
362
+ return None
363
+
364
+ upstream_path, auth_headers, payload = upstream_request
365
+ headers = {
366
+ "X-Matrx-Key": matrx_key,
367
+ "Content-Type": "application/json",
368
+ }
369
+ if "x-api-key" in auth_headers:
370
+ headers["x-api-key"] = matrx_key
371
+ if "authorization" in auth_headers:
372
+ headers["Authorization"] = f"Bearer {matrx_key}"
373
+
374
+ url = f"{matrx_base_url.rstrip('/')}{upstream_path}"
375
+ try:
376
+ async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=90.0, connect=5.0)) as client:
377
+ resp = await client.post(url, json={**payload, "stream": True}, headers=headers)
378
+ except Exception:
379
+ logger.warning("cursor_reroute: upstream request failed for %s", path, exc_info=True)
380
+ return None
381
+
382
+ if resp.status_code >= 400:
383
+ logger.info(
384
+ "cursor_reroute: upstream returned %s for %s; forwarding unchanged",
385
+ resp.status_code,
386
+ path,
387
+ )
388
+ return None
389
+
390
+ provider = _detect_provider_from_model(str(payload.get("model", "")))
391
+ if provider == "anthropic":
392
+ frame_data = extract_from_anthropic_sse_response(resp.content)
393
+ else:
394
+ frame_data = extract_from_openai_sse_response(resp.content)
395
+
396
+ text = frame_data.get("text", "")
397
+ usage = frame_data.get("usage")
398
+ response_body = _build_cursor_response_bytes(text=text, usage=usage)
399
+ if response_body is None:
400
+ return None
401
+
402
+ response_telemetry = {
403
+ "session_id": extracted.get("session_id") or session_id or "",
404
+ "conversation_id": extracted.get("conversation_id") or "",
405
+ "model": extracted.get("model") or "",
406
+ "files": extracted.get("files", []),
407
+ "edits": extracted.get("edits", []),
408
+ "response_text": text,
409
+ "tool_calls": [],
410
+ "usage": usage,
411
+ }
412
+ asyncio.create_task(ship_ai_telemetry(response_telemetry, matrx_base_url, matrx_key))
413
+
414
+ return (
415
+ True,
416
+ {
417
+ "content-type": req_headers.get("content-type", "application/connect+proto"),
418
+ "connect-protocol-version": "1",
419
+ },
420
+ response_body,
421
+ True,
422
+ )
141
423
 
142
424
 
143
425
  # ---------------------------------------------------------------------------
@@ -189,22 +471,50 @@ async def try_inject_context(
189
471
  )
190
472
  from matrx.cli.cursor_extraction import (
191
473
  _PROTOS_AVAILABLE,
474
+ _raw_extract_request,
475
+ extract_from_json_request,
192
476
  extract_from_request,
193
477
  parse_request_proto,
194
478
  ship_ai_telemetry,
195
479
  )
196
480
 
481
+ # JSON API path (Anthropic Messages API, OpenAI Chat Completions)
482
+ content_type = req_headers.get("content-type", "").lower()
483
+ if "application/json" in content_type:
484
+ extracted = extract_from_json_request(req_body)
485
+ extracted["session_id"] = extracted.get("session_id") or session_id
486
+ asyncio.create_task(ship_ai_telemetry(extracted, matrx_base_url, matrx_key))
487
+ return None
488
+
197
489
  if not is_connect_proto_request(req_headers):
198
490
  return None
199
491
 
200
- # Parse Connect envelope → raw proto bytes
201
- flags, proto_bytes = parse_connect_frame(req_body)
492
+ # Decompress body if gzip-encoded
493
+ import gzip as _gzip
494
+ body = req_body
495
+ ce = req_headers.get("content-encoding", "").lower()
496
+ if ce == "gzip" or (len(body) >= 2 and body[:2] == b"\x1f\x8b"):
497
+ try:
498
+ body = _gzip.decompress(body)
499
+ except Exception:
500
+ return None
501
+
502
+ # Parse Connect envelope → raw proto bytes.
503
+ # Fall back to treating the body as raw protobuf if Connect framing is absent
504
+ # (some Cursor versions send raw proto without the 5-byte envelope).
505
+ try:
506
+ flags, proto_bytes = parse_connect_frame(body)
507
+ except ValueError:
508
+ flags, proto_bytes = 0, body
202
509
 
203
- # Deserialize proto
510
+ # Deserialize proto (compiled path); fall back to raw wire parsing
204
511
  req_proto = parse_request_proto(proto_bytes)
512
+ if req_proto is not None:
513
+ extracted = extract_from_request(req_proto)
514
+ else:
515
+ extracted = _raw_extract_request(proto_bytes)
205
516
 
206
517
  # Extract structured data and ship telemetry fire-and-forget
207
- extracted = extract_from_request(req_proto)
208
518
  extracted["session_id"] = extracted.get("session_id") or session_id
209
519
  asyncio.create_task(ship_ai_telemetry(extracted, matrx_base_url, matrx_key))
210
520
 
@@ -663,6 +663,8 @@ def _build_gemini_env(
663
663
  ctx_params: list[str] = []
664
664
  if project_id:
665
665
  ctx_params.append(f"mtrx_project={project_id}")
666
+ if group_id:
667
+ ctx_params.append(f"mtrx_group={group_id}")
666
668
  if session_id:
667
669
  ctx_params.append(f"mtrx_session={session_id}")
668
670
  if runtime_agent_id:
@@ -673,6 +675,8 @@ def _build_gemini_env(
673
675
  ctx_params.append(f"mtrx_branch={git_branch}")
674
676
  if git_commit:
675
677
  ctx_params.append(f"mtrx_commit={git_commit}")
678
+ if git_repo_url:
679
+ ctx_params.append(f"mtrx_repo_url={git_repo_url}")
676
680
 
677
681
  query_suffix = f"?{'&'.join(ctx_params)}" if ctx_params else ""
678
682
  env_snap = _capture_env_snapshot()