mtrx-cli 0.1.24 → 0.1.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrx-cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -1 +1 @@
1
- __version__ = "0.1.24"
1
+ __version__ = "0.1.25"
@@ -33,7 +33,7 @@ import httpx
33
33
  from matrx.cli.cursor_ca import CertCache, load_ca
34
34
 
35
35
  try:
36
- from matrx.cli.cursor_reroute import is_ai_path, try_reroute_to_matrx
36
+ from matrx.cli.cursor_reroute import is_ai_path, try_inject_context, try_reroute_to_matrx
37
37
  except ImportError:
38
38
  # Stubs when cursor_reroute not available (e.g. npm package omit).
39
39
  def is_ai_path(path: str) -> bool:
@@ -42,6 +42,9 @@ except ImportError:
42
42
  async def try_reroute_to_matrx(*, path: str, method: str, **kwargs: Any) -> None:
43
43
  return None
44
44
 
45
+ async def try_inject_context(**kwargs: Any) -> None:
46
+ return None
47
+
45
48
  logger = logging.getLogger(__name__)
46
49
 
47
50
  DEFAULT_PORT = 8842
@@ -287,8 +290,10 @@ class MITMProxy:
287
290
  path = parts[1] if len(parts) > 1 else "/"
288
291
 
289
292
  req_body_size = 0
293
+ _is_ai_req = method == "POST" and is_ai_path(path)
294
+ _req_session_id = str(uuid.uuid4()) if _is_ai_req else ""
290
295
  # For AI paths: buffer request and try rerouting through MTRX (live injection)
291
- if method == "POST" and is_ai_path(path):
296
+ if _is_ai_req:
292
297
  req_headers, req_cl, req_chunked = await self._read_headers_only(
293
298
  client_reader
294
299
  )
@@ -303,7 +308,7 @@ class MITMProxy:
303
308
  req_body=req_body,
304
309
  matrx_base_url=self.matrx_base_url,
305
310
  matrx_key=self.matrx_key,
306
- session_id=str(uuid.uuid4()),
311
+ session_id=_req_session_id,
307
312
  )
308
313
  if result is not None:
309
314
  success, resp_headers, resp_body, is_streaming = result
@@ -327,10 +332,20 @@ class MITMProxy:
327
332
  )
328
333
  continue
329
334
  # Reroute returned but failed — fall through to forward
330
- # Reroute not implemented or failed forward to upstream
335
+ # Inject MTRX memory context into request before forwarding
336
+ injected_body = await try_inject_context(
337
+ req_body=req_body,
338
+ req_headers=req_headers,
339
+ matrx_base_url=self.matrx_base_url,
340
+ matrx_key=self.matrx_key,
341
+ session_id=_req_session_id,
342
+ )
343
+ body_to_forward = injected_body if injected_body is not None else req_body
344
+ fwd_headers = dict(req_headers)
345
+ fwd_headers["content-length"] = str(len(body_to_forward))
331
346
  up_writer.write(req_line)
332
- await self._write_headers(up_writer, req_headers)
333
- up_writer.write(req_body)
347
+ await self._write_headers(up_writer, fwd_headers)
348
+ up_writer.write(body_to_forward)
334
349
  await up_writer.drain()
335
350
  else:
336
351
  up_writer.write(req_line)
@@ -369,9 +384,20 @@ class MITMProxy:
369
384
  for t in ("text/event-stream", "grpc", "proto", "connect")
370
385
  )
371
386
 
372
- resp_body_size = await self._forward_body(
373
- up_reader, client_writer, resp_cl, resp_chunked
374
- )
387
+ if _is_ai_req:
388
+ resp_body_size, resp_captured = await self._forward_body_with_capture(
389
+ up_reader, client_writer, resp_cl, resp_chunked
390
+ )
391
+ if resp_captured:
392
+ asyncio.create_task(
393
+ self._extract_ai_response(
394
+ resp_captured, _req_session_id, hostname
395
+ )
396
+ )
397
+ else:
398
+ resp_body_size = await self._forward_body(
399
+ up_reader, client_writer, resp_cl, resp_chunked
400
+ )
375
401
 
376
402
  elapsed_ms = int((time.monotonic() - started) * 1000)
377
403
  self._request_count += 1
@@ -397,6 +423,114 @@ class MITMProxy:
397
423
  if "close" in conn_h:
398
424
  break
399
425
 
426
+ async def _forward_body_with_capture(
427
+ self,
428
+ reader: asyncio.StreamReader,
429
+ writer: asyncio.StreamWriter,
430
+ content_length: int,
431
+ chunked: bool,
432
+ ) -> tuple[int, bytes]:
433
+ """Forward body like ``_forward_body`` while also capturing a copy.
434
+
435
+ Returns ``(bytes_forwarded, captured_bytes)``. The capture enables
436
+ background response extraction without blocking the forward path.
437
+ """
438
+ parts: list[bytes] = []
439
+
440
+ if content_length > 0:
441
+ total = 0
442
+ remaining = content_length
443
+ while remaining > 0:
444
+ chunk = await reader.read(min(remaining, 65536))
445
+ if not chunk:
446
+ break
447
+ writer.write(chunk)
448
+ await writer.drain()
449
+ parts.append(chunk)
450
+ total += len(chunk)
451
+ remaining -= len(chunk)
452
+ return total, b"".join(parts)
453
+
454
+ if chunked:
455
+ total = 0
456
+ while True:
457
+ size_line = await reader.readline()
458
+ if not size_line:
459
+ break
460
+ writer.write(size_line)
461
+ await writer.drain()
462
+ size_str = size_line.decode("utf-8", errors="replace").strip()
463
+ try:
464
+ chunk_size = int(size_str.split(";")[0], 16)
465
+ except ValueError:
466
+ break
467
+ if chunk_size == 0:
468
+ trailer = await reader.readline()
469
+ writer.write(trailer)
470
+ await writer.drain()
471
+ break
472
+ remaining = chunk_size
473
+ chunk_parts: list[bytes] = []
474
+ while remaining > 0:
475
+ data = await reader.read(min(remaining, 65536))
476
+ if not data:
477
+ return total, b"".join(parts)
478
+ writer.write(data)
479
+ await writer.drain()
480
+ chunk_parts.append(data)
481
+ total += len(data)
482
+ remaining -= len(data)
483
+ chunk_data = b"".join(chunk_parts)
484
+ parts.append(chunk_data)
485
+ crlf = await reader.readline()
486
+ writer.write(crlf)
487
+ await writer.drain()
488
+ return total, b"".join(parts)
489
+
490
+ return 0, b""
491
+
492
+ async def _extract_ai_response(
493
+ self,
494
+ resp_bytes: bytes,
495
+ session_id: str,
496
+ hostname: str,
497
+ ) -> None:
498
+ """Parse Connect frames from *resp_bytes* and ship response telemetry.
499
+
500
+ Fire-and-forget — never raises, never blocks the forward path.
501
+ """
502
+ try:
503
+ from matrx.cli.cursor_connect import parse_all_frames
504
+ from matrx.cli.cursor_extraction import (
505
+ extract_from_response_frame,
506
+ parse_response_proto,
507
+ ship_ai_telemetry,
508
+ )
509
+
510
+ frames = parse_all_frames(resp_bytes)
511
+ accumulated: dict = {
512
+ "session_id": session_id,
513
+ "response_text": "",
514
+ "tool_calls": [],
515
+ "usage": None,
516
+ }
517
+ for flags, payload in frames:
518
+ if flags == 0x02: # end-of-stream trailer — stop
519
+ break
520
+ resp_proto = parse_response_proto(payload)
521
+ frame_data = extract_from_response_frame(resp_proto)
522
+ if frame_data:
523
+ accumulated["response_text"] = (
524
+ accumulated.get("response_text", "") + frame_data.get("text", "")
525
+ )
526
+ accumulated["tool_calls"].extend(frame_data.get("tool_calls", []))
527
+ if frame_data.get("usage"):
528
+ accumulated["usage"] = frame_data["usage"]
529
+
530
+ await ship_ai_telemetry(accumulated, self.matrx_base_url, self.matrx_key)
531
+ except Exception:
532
+ logger.debug("proxy: _extract_ai_response failed", exc_info=True)
533
+
400
534
  async def _read_headers_only(
401
535
  self, reader: asyncio.StreamReader
402
536
  ) -> tuple[dict[str, str], int, bool]:
@@ -13,11 +13,14 @@ Refs: cursor-tap (https://github.com/burpheart/cursor-tap), everestmz/cursor-rpc
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import asyncio
16
17
  import json
17
18
  import logging
18
19
  import re
19
20
  from typing import Any
20
21
 
22
+ import httpx
23
+
21
24
  logger = logging.getLogger(__name__)
22
25
 
23
26
  # Cursor AI RPC paths (Connect protocol). RunSSE = main chat, StreamCpp = code completion.
@@ -46,6 +49,67 @@ def _cursor_model_to_openai(cursor_model: str) -> str:
46
49
  return cursor_model
47
50
 
48
51
 
52
+ def _build_search_query(extracted: dict[str, Any]) -> str:
53
+ files = extracted.get("files", [])
54
+ query_parts = [f.get("path", "").strip() for f in files[:3] if f.get("path")]
55
+ if query_parts:
56
+ return " ".join(query_parts)
57
+ return (extracted.get("conversation_summary") or "").strip()
58
+
59
+
60
+ def _prepend_context_items(context_items: Any, injected_items: list[Any]) -> None:
61
+ for item in reversed(injected_items):
62
+ try:
63
+ context_items.insert(0, item)
64
+ except Exception:
65
+ context_items.append(item)
66
+
67
+
68
+ def _inject_memory_context_items(
69
+ *,
70
+ req_proto: Any,
71
+ memory_results: list[dict[str, Any]],
72
+ server_chat_pb2: Any,
73
+ existing_files: list[dict[str, Any]] | None = None,
74
+ limit: int = 5,
75
+ ) -> int:
76
+ existing_contents = {
77
+ (entry.get("content") or "").strip() for entry in (existing_files or []) if entry.get("content")
78
+ }
79
+ injected_contents: set[str] = set()
80
+ injected_items: list[Any] = []
81
+
82
+ for mem in memory_results:
83
+ if len(injected_items) >= limit:
84
+ break
85
+ content = (mem.get("content") or "").strip()
86
+ if not content or content in existing_contents or content in injected_contents:
87
+ continue
88
+
89
+ cached_item = server_chat_pb2.PotentiallyCachedContextItem()
90
+ ctx_item = server_chat_pb2.ContextItem()
91
+ file_chunk = server_chat_pb2.FileChunk()
92
+ file_chunk.file_path = f"[matrx:{(mem.get('id') or '')[:8]}]"
93
+ file_chunk.content = content[:4096]
94
+ if hasattr(getattr(ctx_item, "file_chunk", None), "CopyFrom"):
95
+ ctx_item.file_chunk.CopyFrom(file_chunk)
96
+ else:
97
+ ctx_item.file_chunk = file_chunk
98
+ if hasattr(getattr(cached_item, "context_item", None), "CopyFrom"):
99
+ cached_item.context_item.CopyFrom(ctx_item)
100
+ else:
101
+ cached_item.context_item = ctx_item
102
+
103
+ injected_items.append(cached_item)
104
+ injected_contents.add(content)
105
+
106
+ if not injected_items:
107
+ return 0
108
+
109
+ _prepend_context_items(req_proto.context_items, injected_items)
110
+ return len(injected_items)
111
+
112
+
49
113
  async def try_reroute_to_matrx(
50
114
  *,
51
115
  path: str,
@@ -74,3 +138,111 @@ async def try_reroute_to_matrx(
74
138
  # convert response back to Cursor's gRPC format.
75
139
  logger.debug("cursor_reroute: path=%s would reroute (protobuf conversion not yet implemented)", path)
76
140
  return None
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Context injection
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ async def _query_memory(
149
+ *,
150
+ query: str,
151
+ matrx_base_url: str,
152
+ matrx_key: str,
153
+ limit: int = 5,
154
+ ) -> list[dict]:
155
+ """Query MTRX memory search API. Returns list of memory entry dicts."""
156
+ try:
157
+ async with httpx.AsyncClient(timeout=0.1) as client: # 100 ms budget
158
+ resp = await client.get(
159
+ f"{matrx_base_url.rstrip('/')}/v1/memory/search",
160
+ params={"q": query, "limit": limit},
161
+ headers={"X-Matrx-Key": matrx_key},
162
+ )
163
+ if resp.status_code == 200:
164
+ return resp.json().get("entries", [])
165
+ except Exception:
166
+ logger.debug("cursor_reroute: memory query failed", exc_info=True)
167
+ return []
168
+
169
+
170
+ async def try_inject_context(
171
+ *,
172
+ req_body: bytes,
173
+ req_headers: dict[str, str],
174
+ matrx_base_url: str,
175
+ matrx_key: str,
176
+ session_id: str,
177
+ ) -> bytes | None:
178
+ """Parse the Connect+proto request, extract data, inject MTRX memory context items.
179
+
180
+ Returns modified request bytes with injected context items prepended, or
181
+ ``None`` to signal that the original request should be forwarded unchanged.
182
+ Wraps everything in try/except — never raises.
183
+ """
184
+ try:
185
+ from matrx.cli.cursor_connect import (
186
+ build_connect_frame,
187
+ is_connect_proto_request,
188
+ parse_connect_frame,
189
+ )
190
+ from matrx.cli.cursor_extraction import (
191
+ _PROTOS_AVAILABLE,
192
+ extract_from_request,
193
+ parse_request_proto,
194
+ ship_ai_telemetry,
195
+ )
196
+
197
+ if not is_connect_proto_request(req_headers):
198
+ return None
199
+
200
+ # Parse Connect envelope → raw proto bytes
201
+ flags, proto_bytes = parse_connect_frame(req_body)
202
+
203
+ # Deserialize proto
204
+ req_proto = parse_request_proto(proto_bytes)
205
+
206
+ # Extract structured data and ship telemetry fire-and-forget
207
+ extracted = extract_from_request(req_proto)
208
+ extracted["session_id"] = extracted.get("session_id") or session_id
209
+ asyncio.create_task(ship_ai_telemetry(extracted, matrx_base_url, matrx_key))
210
+
211
+ # Cannot inject without compiled protos or a successfully parsed proto
212
+ if not _PROTOS_AVAILABLE or req_proto is None:
213
+ return None
214
+
215
+ # Build search query from the open file paths
216
+ search_query = _build_search_query(extracted)
217
+ if not search_query:
218
+ return None
219
+
220
+ # Query MTRX memory (100 ms budget — never blocks the forward path)
221
+ memory_results = await _query_memory(
222
+ query=search_query,
223
+ matrx_base_url=matrx_base_url,
224
+ matrx_key=matrx_key,
225
+ limit=5,
226
+ )
227
+ if not memory_results:
228
+ return None
229
+
230
+ # Inject memory results as PotentiallyCachedContextItem entries
231
+ from matrx.cli.cursor_proto import server_chat_pb2 # type: ignore[import]
232
+
233
+ inserted = _inject_memory_context_items(
234
+ req_proto=req_proto,
235
+ memory_results=memory_results,
236
+ server_chat_pb2=server_chat_pb2,
237
+ existing_files=extracted.get("files", []),
238
+ )
239
+ if inserted == 0:
240
+ return None
241
+
242
+ # Serialize modified proto and re-wrap in Connect frame
243
+ new_proto_bytes = req_proto.SerializeToString()
244
+ return build_connect_frame(flags, new_proto_bytes)
245
+
246
+ except Exception:
247
+ logger.warning("cursor_reroute: try_inject_context failed", exc_info=True)
248
+ return None