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.
- package/package.json +1 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_ca.py +184 -34
- package/src/matrx/cli/cursor_config.py +9 -0
- package/src/matrx/cli/cursor_launcher.py +3 -1
- package/src/matrx/cli/cursor_proxy.py +218 -150
- package/src/matrx/cli/cursor_reroute.py +323 -13
- package/src/matrx/cli/launcher.py +4 -0
- package/src/matrx/cli/main.py +384 -59
- package/src/matrx/cli/state.py +11 -0
|
@@ -23,16 +23,74 @@ import httpx
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger(__name__)
|
|
25
25
|
|
|
26
|
-
# Cursor AI RPC paths (Connect protocol).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
201
|
-
|
|
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()
|