m5-engine 1.0.0__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.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: m5-engine
3
+ Version: 1.0.0
4
+ Summary: Intelligent, zero-overhead AST code context and dependency graph engine for AI coding agents,saving LLM token costs.
5
+ Author-email: Aman <lazyserp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/m5-context-engine/m5
8
+ Project-URL: Documentation, https://github.com/m5-context-engine/m5#readme
9
+ Project-URL: Repository, https://github.com/m5-context-engine/m5.git
10
+ Project-URL: Issues, https://github.com/m5-context-engine/m5/issues
11
+ Keywords: mcp,model-context-protocol,ast,code-graph,developer-tools,ai-agents,cursor,claude-code,copilot
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Code Generators
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: tree-sitter>=0.20.0
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Requires-Dist: python-dotenv>=1.0.0
27
+ Provides-Extra: enterprise
28
+ Requires-Dist: fastapi>=0.100.0; extra == "enterprise"
29
+ Requires-Dist: uvicorn>=0.22.0; extra == "enterprise"
30
+ Requires-Dist: qdrant-client>=1.6.0; extra == "enterprise"
31
+ Requires-Dist: fastembed>=0.2.0; extra == "enterprise"
32
+
33
+ # M5 — AST Code Knowledge Graph for AI Coding Agents
34
+
35
+ When AI agents work on your codebase, they spend most of their time (and your tokens) doing clumsy discovery: grepping files one by one, wandering through directory listings, and trying to reconstruct call hierarchies in their head.
36
+
37
+ **M5 fixes that.** It parses your codebase with Tree-sitter into an embedded, zero-overhead SQLite knowledge graph (`.m5/local_graph.db`). When your agent asks a question, M5 returns the exact code, upstream callers, downstream dependencies, and blast radius in **one single call**.
38
+
39
+ - **100% Local**: No Docker, no heavy background daemons, no cloud dependency.
40
+ - **Sub-50ms Incremental Sync**: Watches your files and updates only what changed when you save.
41
+ - **Zero-Friction MCP Integration**: Works with Claude Code, Cursor, VS Code / Copilot, Gemini CLI, Antigravity, and Codex.
42
+ - **Interactive Browser UI**: Visualize your architecture and dependency chains at `http://127.0.0.1:5555`.
43
+
44
+ ---
45
+
46
+ ## 🚀 Quickstart
47
+
48
+ ### 1. Install M5
49
+ Install globally using `pip` or `pipx`:
50
+
51
+ ```bash
52
+ pip install m5-context
53
+ # or with pipx:
54
+ pipx install m5-context
55
+ ```
56
+
57
+ ### 2. Connect Your AI Agent
58
+ Run the interactive setup wizard to get clean, copy-pasteable MCP config snippets for your editor:
59
+
60
+ ```bash
61
+ m5 setup
62
+ ```
63
+ *(Or specify your tool directly: `m5 setup claude`, `m5 setup cursor`, `m5 setup vscode`)*
64
+
65
+ Paste the provided JSON block into your editor's MCP configuration. We don't silently rewrite your system files behind your back — you stay in full control.
66
+
67
+ ### 3. Build Your Project Index
68
+ Navigate to any project repository and build the graph:
69
+
70
+ ```bash
71
+ cd your-project
72
+ m5 build
73
+ ```
74
+
75
+ This scans your workspace and indexes all AST symbols and call edges into `.m5/local_graph.db` in under a second.
76
+
77
+ ### 4. Keep It Fresh While You Code
78
+ Start the background watcher:
79
+
80
+ ```bash
81
+ m5 live
82
+ ```
83
+
84
+ Whenever you or your AI agent edits a file, M5 catches the change and updates the graph in $<50\text{ms}$.
85
+
86
+ ---
87
+
88
+ ## 🔍 CLI Commands
89
+
90
+ | Command | What it does | Example |
91
+ |---|---|---|
92
+ | `m5 setup` | Interactive manual setup wizard with exact MCP configs for your IDE | `m5 setup` or `m5 setup cursor` |
93
+ | `m5 build` | Scans workspace and builds AST knowledge graph into `.m5/` | `m5 build` |
94
+ | `m5 live` | Starts real-time file watcher (<50ms incremental sync on save) | `m5 live` |
95
+ | `m5 stats` | Shows index summary (files, AST symbols, call edges, DB size) | `m5 stats` |
96
+ | `m5 trace` | 1-shot surgical context: verbatim code + call flow + blast radius | `m5 trace "auth middleware token validation"` |
97
+ | `m5 peek` | View symbol definition & callers, or view line-numbered file | `m5 peek UserService` or `m5 peek src/auth.py` |
98
+ | `m5 find` | Search AST symbols by name, type, or pattern (FTS5 + B-tree) | `m5 find parse_jwt` |
99
+ | `m5 callers` | Find all functions and files calling a symbol | `m5 callers handle_request` |
100
+ | `m5 callees` | Find all functions called by a symbol | `m5 callees handle_request` |
101
+ | `m5 blast` | Multi-hop blast radius & affected files analysis before refactoring | `m5 blast DatabasePool --depth 2` |
102
+ | `m5 diff-tests` | Find test suites affected by modified files | `git diff --name-only \| m5 diff-tests --stdin` |
103
+ | `m5 view` | Open local browser visualizer at `http://127.0.0.1:5555` | `m5 view` |
104
+ | `m5 serve` | Start the MCP server over stdio (invoked by IDEs) | `m5 serve` |
105
+ | `m5 purge` | Cleanly remove `.m5/` index from project | `m5 purge` |
106
+ | `m5 scan` | Force full re-index of the repository | `m5 scan` |
107
+ | `m5 dump` | Export index bundle for CI / team sharing | `m5 dump` |
108
+ | `m5 pull` | Pull pre-computed team index from CI cache | `m5 pull https://ci.company.com/index.tar.gz` |
109
+
110
+ ---
111
+
112
+ ## 🖥️ Visual Graph Browser (`m5 view`)
113
+
114
+ Want to see what your AI agent sees? Run:
115
+
116
+ ```bash
117
+ m5 view
118
+ ```
119
+
120
+ Opens a fast, dark-mode browser interface at `http://127.0.0.1:5555`:
121
+ - **Search & Filter**: Find any function, method, or class across your repo with instant fuzzy search.
122
+ - **Live Code Inspection**: Read exact symbol bodies with line numbers and AST metadata.
123
+ - **Dependency Panels**: See direct callers, outgoing calls, and affected files on the side.
124
+ - **Zero External Server**: Powered by Python's built-in HTTP server, so it starts instantly without npm or heavy node packages.
125
+
126
+ ---
127
+
128
+ ## 🤖 Agent Instructions (CLAUDE.md / AGENTS.md / GEMINI.md)
129
+
130
+ To help subagents and command-line agents make the most of M5, paste this snippet into your project's `CLAUDE.md`, `AGENTS.md`, or `GEMINI.md`:
131
+
132
+ ```markdown
133
+ <!-- M5 CONTEXT ENGINE START -->
134
+ ## M5 Code Context & AST Knowledge Graph
135
+ This project uses M5 for instant AST code intelligence and dependency navigation.
136
+ Instead of repeatedly reading entire files or running multiple grep commands:
137
+ - Run `m5 trace "<query>"` to retrieve relevant symbol definitions, call hierarchies, and blast radius in 1 step.
138
+ - Run `m5 peek <symbol>` to view the exact implementation and callers of any function or class.
139
+ - Run `m5 callers <symbol>` or `m5 callees <symbol>` to navigate the call graph.
140
+ - Run `m5 diff-tests` to see tests affected by modified files.
141
+ <!-- M5 CONTEXT ENGINE END -->
142
+ ```
143
+
144
+ ---
145
+
146
+ ## 🌐 Supported Languages
147
+
148
+ M5 extracts full AST symbol trees and resolves cross-file call edges across:
149
+
150
+ - **Web & Backend**: Python, TypeScript, JavaScript, Go, Rust, Java, C++, C, C#, Ruby, PHP
151
+ - **Mobile & Modern**: Swift, Kotlin, Dart, Scala
152
+
153
+ ---
154
+
155
+ ## 📄 License
156
+
157
+ MIT License. Free and open source for developers and teams.
@@ -0,0 +1,36 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/auth.py,sha256=59katXRH3FsQjAoJj1QGPGCWbhClORjAXkwah1NL1hU,7391
3
+ src/config.py,sha256=pkNCk9yGk42B87Sl0VivLjHJYYNuCkzWfM8FYuMFTV0,844
4
+ src/logger.py,sha256=ewwBOhp3qhM6Y150-DA49iJXri4wPJxixYs9jdTv_WA,2268
5
+ src/main.py,sha256=-R8KagNARKMhcMDRv8UVJQhVibZwob3ip5UdEmHLbJk,2751
6
+ src/mcp_server.py,sha256=2dfPEBUjXOmbCZ08jJWsJcNhjB2zgF_q7PJtGrpHOOI,25322
7
+ src/server.py,sha256=Wjfyfd55mIYvU7BiVmz0VHWu7rSwDaJEg4lhO4-ywgg,18985
8
+ src/api/webhooks.py,sha256=gHAs80QE_l15Wr59lVa9lo4hIbzg4k-_HkBGtYcq7v0,4744
9
+ src/audit/__init__.py,sha256=_1QPreoiNS9ujffQPZ9ULeWWODiDWRADIagdnCnKsfY,124
10
+ src/audit/telemetry.py,sha256=isMuDqhtaRl-KsSwNpVk5lNkQSNDbP3p9VB_Hpfs0ZM,11848
11
+ src/cli/__init__.py,sha256=qAnfxWT0K1-tfjTLbW30yg-gDQsScywzJrqpmg1ku7M,17
12
+ src/cli/installer.py,sha256=laP3lwCRhoaQzr8yDJlWWPon3jgAAIZSiR76-XjA3w0,214
13
+ src/cli/main.py,sha256=EmYKGOVdpidoFP-eBrJ9RZ7zSv6X4l0mkM-b79T40N0,18181
14
+ src/cli/setup_guide.py,sha256=qDjSNMULtAUO-rrtb7n9ufMix92w6y0IF8qA57DvHUk,10090
15
+ src/cli/sync.py,sha256=AVKzguzkD2prC5nSuZgDOCjt76Qed3WKgGnqkcAVSow,2970
16
+ src/cli/visual_server.py,sha256=D2eY8tkfRZOs_MGn-iFr68rfkpZHCt1NXO4cSEJ0QBE,22076
17
+ src/context/__init__.py,sha256=tOA1t3gOpel__Zzx172B3T92oByUceCQo-lo7M-lylA,18
18
+ src/context/context_engine.py,sha256=-KHCkRHjumC8sEZ2AhAA7N9knrdWT53v9zxrYT9z_ns,16314
19
+ src/indexer/file_watcher.py,sha256=jk9RDTwI47rAq_b6oXUV7s2ailuS9MM5HB2O4rXet1w,4179
20
+ src/indexer/git_manager.py,sha256=C3R3zjK782dFobDUVEd07po6zZBxhbagtuq41Mj7lcQ,4065
21
+ src/indexer/progressive_indexer.py,sha256=x647jHKrkQ-2SZanliBAnafZb6LPgFcX-dwq7xOYqIY,12670
22
+ src/parser/ast_parser.py,sha256=nbIlHjHotuM0AmKDZbh7FNfX16Ptw5jSCkeYA8ks8a0,9957
23
+ src/parser/customizations.py,sha256=wtiiMvljfszz93iVHW-XivANYc2fpv4qrGnct1mnt5k,5119
24
+ src/storage/local_db.py,sha256=03shCoXKJ15Cxy7Q8tpT-ROSZ4rlkuHvLBH_5nJo72w,14725
25
+ src/tools/__init__.py,sha256=81Vc7PrVgekkmz_HoxztxSQ09CG4lf-MSkyQW31twOM,293
26
+ src/tools/dependency_graph.py,sha256=g1yqtt3wNvE0t7Qzjaw955OdZ4iuB6dPHfNWgRa3_BU,16081
27
+ src/tools/hybrid_search.py,sha256=xo0nfvS8kFOq9SWnZdVITYTl-_5G7biFZPTzs84Y9-A,13526
28
+ src/tools/line_reader.py,sha256=_57gVr9plXXMQKAUD6s_kVoR5okF4huynNzivBp_WU4,2903
29
+ src/tools/multi_repo_graph.py,sha256=IiH24FN3k04uZPrWz7dtDbcZuCi6QuMvl-rY-0hhwzA,3127
30
+ src/tools/test_impact.py,sha256=d2867fnJKPMGorXXrB1LSJQtGOLkwWYXA1IQhSgvBEo,3447
31
+ src/tools/vector_search.py,sha256=u9W0n3q4py4FyIdizxXoazor7SvAIEgmCFfmJDRGYS8,11544
32
+ m5_engine-1.0.0.dist-info/METADATA,sha256=oEIn4QbR_414_KXx1UANTEFORzXrvETSkW1mL5nfMyg,7182
33
+ m5_engine-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
+ m5_engine-1.0.0.dist-info/entry_points.txt,sha256=bvv5MNM9dt6GoNZmdXXc5Di10f821Lm6Kz2fg4q6sls,51
35
+ m5_engine-1.0.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
36
+ m5_engine-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ m5 = src.cli.main:cli_entrypoint
@@ -0,0 +1 @@
1
+ src
src/__init__.py ADDED
File without changes
src/api/webhooks.py ADDED
@@ -0,0 +1,124 @@
1
+ import os
2
+ import hmac
3
+ import hashlib
4
+ from typing import Dict, Any, List
5
+ from fastapi import APIRouter, Request, Header, HTTPException, BackgroundTasks, status
6
+ from pydantic import BaseModel, Field
7
+ from src.logger import setup_m5_logger
8
+ from src.indexer.progressive_indexer import progressive_indexer
9
+
10
+ logger = setup_m5_logger("m5.webhook")
11
+ webhook_router = APIRouter(prefix="/api/webhooks", tags=["Webhooks"])
12
+ WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
13
+
14
+ class WebhookSyncResponse(BaseModel):
15
+ status: str
16
+ event: str
17
+ repository: str
18
+ commits_processed: int
19
+ message: str
20
+
21
+ def verify_github_signature(payload_bytes: bytes, signature_header: str | None) -> bool:
22
+ """
23
+ Verifies HMAC SHA-256 signature from GitHub.
24
+
25
+ Fail-closed design:
26
+ - If GITHUB_WEBHOOK_SECRET is set (production): signature MUST be valid.
27
+ - If GITHUB_WEBHOOK_SECRET is NOT set:
28
+ - Dev override M5_ALLOW_UNSIGNED_WEBHOOKS=true → allow (development only).
29
+ - Otherwise → REJECT. Prevents silent security hole on misconfigured deployments.
30
+ """
31
+ import os as _os
32
+ if not WEBHOOK_SECRET:
33
+ allow_unsigned = _os.getenv("M5_ALLOW_UNSIGNED_WEBHOOKS", "false").lower() == "true"
34
+ if allow_unsigned:
35
+ logger.warning(
36
+ "Accepting unsigned webhook (M5_ALLOW_UNSIGNED_WEBHOOKS=true). "
37
+ "Never use this in production."
38
+ )
39
+ return True
40
+ logger.error(
41
+ "Webhook rejected: GITHUB_WEBHOOK_SECRET is not configured. "
42
+ "Set it in .env or use M5_ALLOW_UNSIGNED_WEBHOOKS=true for local dev only."
43
+ )
44
+ return False
45
+ if not signature_header or not signature_header.startswith("sha256="):
46
+ return False
47
+
48
+ expected_sig = signature_header.split("sha256=")[1]
49
+ computed_sig = hmac.new(
50
+ WEBHOOK_SECRET.encode("utf-8"),
51
+ payload_bytes,
52
+ hashlib.sha256
53
+ ).hexdigest()
54
+ return hmac.compare_digest(expected_sig, computed_sig)
55
+
56
+ @webhook_router.post("/github", status_code=status.HTTP_202_ACCEPTED, response_model=WebhookSyncResponse)
57
+ async def github_webhook_endpoint(
58
+ request: Request,
59
+ background_tasks: BackgroundTasks,
60
+ x_github_event: str = Header(default="push"),
61
+ x_hub_signature_256: str | None = Header(default=None)
62
+ ):
63
+ payload_bytes = await request.body()
64
+
65
+ # 1. Verify Webhook Signature
66
+ if not verify_github_signature(payload_bytes, x_hub_signature_256):
67
+ logger.error("GitHub Webhook rejected: Invalid HMAC SHA-256 signature")
68
+ raise HTTPException(status_code=403, detail="Invalid HMAC webhook signature.")
69
+
70
+ # 2. Ignore non-push events (e.g. ping, star)
71
+ if x_github_event != "push":
72
+ logger.info(f"Received GitHub webhook event '{x_github_event}' (ignored)")
73
+ return WebhookSyncResponse(
74
+ status="ignored",
75
+ event=x_github_event,
76
+ repository="unknown",
77
+ commits_processed=0,
78
+ message=f"Event '{x_github_event}' does not require codebase delta re-indexing."
79
+ )
80
+
81
+ payload = await request.json()
82
+ repo_data = payload.get("repository", {})
83
+ repo_name = repo_data.get("name", "default_repo")
84
+ org_name = repo_data.get("owner", {}).get("name") or repo_data.get("owner", {}).get("login") or "default_org"
85
+
86
+ # 3. Extract Added, Modified, and Removed Files from commits
87
+ added_files: List[str] = []
88
+ modified_files: List[str] = []
89
+ removed_files: List[str] = []
90
+
91
+ for commit in payload.get("commits", []):
92
+ added_files.extend(commit.get("added", []))
93
+ modified_files.extend(commit.get("modified", []))
94
+ removed_files.extend(commit.get("removed", []))
95
+
96
+ added_clean = list(set(added_files))
97
+ modified_clean = list(set(modified_files))
98
+ removed_clean = list(set(removed_files))
99
+
100
+ logger.info(
101
+ f"GitHub Push Webhook received for '{org_name}/{repo_name}': "
102
+ f"{len(added_clean)} added, {len(modified_clean)} modified, {len(removed_clean)} removed files. Queueing delta sync..."
103
+ )
104
+
105
+ # 4. Offload Delta Synchronization to Background Task
106
+ background_tasks.add_task(
107
+ progressive_indexer.process_git_delta,
108
+ added=added_clean,
109
+ modified=modified_clean,
110
+ removed=removed_clean,
111
+ workspace_root=".",
112
+ org_id=org_name,
113
+ dept_id="engineering",
114
+ repo_id=repo_name
115
+ )
116
+
117
+ return WebhookSyncResponse(
118
+ status="accepted",
119
+ event=x_github_event,
120
+ repository=f"{org_name}/{repo_name}",
121
+ commits_processed=len(payload.get("commits", [])),
122
+ message=f"Queued delta sync for {len(added_clean)} added, {len(modified_clean)} modified, and {len(removed_clean)} removed files."
123
+ )
124
+
src/audit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .telemetry import log_retrieval_trace, get_telemetry_client
2
+
3
+ __all__ = ["log_retrieval_trace", "get_telemetry_client"]
src/audit/telemetry.py ADDED
@@ -0,0 +1,338 @@
1
+ """
2
+ telemetry.py — Langfuse AI Observability & Tracing for M5 v2
3
+
4
+ Provides fail-safe, non-blocking telemetry for M5 context requests and MCP tool executions.
5
+ Adheres to Langfuse best practices:
6
+ - Observation Types (retriever, tool, generation, span)
7
+ - User Attribution (identifies developer/client name)
8
+ - Usage & Token Details (input, output, total tokens tracked via generation observations)
9
+ - Attribute Propagation (user_id, session_id, tags, metadata)
10
+ - Automated Flushing
11
+ - Fail-Safe: Graceful no-op when credentials are absent or network is unreachable.
12
+ """
13
+
14
+ import os
15
+ import time
16
+ import logging
17
+ from typing import Optional, Dict, Any, List
18
+
19
+ logger = logging.getLogger("m5.telemetry")
20
+
21
+ _LANGFUSE_CLIENT = None
22
+ _INITIALIZED = False
23
+
24
+
25
+ def _clean_env_val(val: Optional[str]) -> str:
26
+ if not val:
27
+ return ""
28
+ return val.strip().strip("\"'")
29
+
30
+
31
+ def get_telemetry_client():
32
+ """Initializes and returns the singleton Langfuse client if configured."""
33
+ global _LANGFUSE_CLIENT, _INITIALIZED
34
+
35
+ if _INITIALIZED:
36
+ return _LANGFUSE_CLIENT
37
+
38
+ _INITIALIZED = True
39
+
40
+ try:
41
+ from dotenv import load_dotenv
42
+ load_dotenv(override=False)
43
+ except Exception:
44
+ pass
45
+
46
+ public_key = _clean_env_val(os.getenv("LANGFUSE_PUBLIC_KEY"))
47
+ secret_key = _clean_env_val(os.getenv("LANGFUSE_SECRET_KEY"))
48
+ host = _clean_env_val(os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") or "https://cloud.langfuse.com")
49
+
50
+ if not public_key or not secret_key:
51
+ logger.debug("Langfuse telemetry credentials not set. Tracing is disabled.")
52
+ return None
53
+
54
+ # Set host environment variable for consistency
55
+ os.environ["LANGFUSE_HOST"] = host
56
+ os.environ["LANGFUSE_BASE_URL"] = host
57
+
58
+ try:
59
+ from langfuse import Langfuse
60
+ _LANGFUSE_CLIENT = Langfuse(
61
+ public_key=public_key,
62
+ secret_key=secret_key,
63
+ host=host
64
+ )
65
+ logger.info(f"Langfuse telemetry connected (Host: {host})")
66
+ except Exception as e:
67
+ logger.warning(f"Langfuse telemetry disabled: {e}")
68
+ _LANGFUSE_CLIENT = None
69
+
70
+ return _LANGFUSE_CLIENT
71
+
72
+
73
+ def flush_telemetry() -> None:
74
+ """Flushes any buffered events to Langfuse."""
75
+ client = get_telemetry_client()
76
+ if client is not None:
77
+ try:
78
+ client.flush()
79
+ except Exception as e:
80
+ logger.debug(f"Telemetry flush error: {e}")
81
+
82
+
83
+ def _resolve_user_id(requesting_user: Optional[str], caller_identity: Optional[str]) -> str:
84
+ """Resolves human/system user identifier for Langfuse attribution."""
85
+ if requesting_user and requesting_user.strip() and requesting_user.strip() not in ("unknown", "mcp/client"):
86
+ return requesting_user.strip()
87
+ if caller_identity and caller_identity.strip() and caller_identity.strip() not in ("unknown", "mcp/client"):
88
+ return caller_identity.strip()
89
+ system_user = os.getenv("USERNAME") or os.getenv("USER") or "developer"
90
+ return system_user
91
+
92
+
93
+ def log_retrieval_trace(
94
+ query: str,
95
+ org_id: str,
96
+ dept_id: str,
97
+ repo_id: str,
98
+ requesting_user: Optional[str],
99
+ caller_identity: Optional[str],
100
+ top_k: int,
101
+ expand_dependencies: bool,
102
+ duration_ms: float,
103
+ result_bundle: Optional[Dict[str, Any]] = None,
104
+ error: Optional[str] = None,
105
+ transport: str = "mcp"
106
+ ) -> None:
107
+ """
108
+ Logs a flagship context retrieval event to Langfuse with user attribution and token usage.
109
+ Fail-safe: never raises exceptions or blocks execution.
110
+ """
111
+ client = get_telemetry_client()
112
+ if client is None:
113
+ return
114
+
115
+ try:
116
+ from langfuse import propagate_attributes
117
+
118
+ user_identifier = _resolve_user_id(requesting_user, caller_identity)
119
+ session_identifier = f"{org_id}:{dept_id}:{repo_id}"
120
+ chunks: List[Dict[str, Any]] = (result_bundle or {}).get("chunks", [])
121
+ dep_edges: List[Dict[str, Any]] = (result_bundle or {}).get("dependency_edges", [])
122
+ related_tests: List[str] = (result_bundle or {}).get("related_tests", [])
123
+ citations = [
124
+ f"{c.get('file_path')}:{c.get('start_line')}-{c.get('end_line')} ({c.get('symbol_name')})"
125
+ for c in chunks
126
+ ]
127
+ estimated_tokens = (result_bundle or {}).get("estimated_tokens", 0)
128
+ request_id = (result_bundle or {}).get("request_id")
129
+
130
+ # Estimate input, output, and total token usage for dashboard analytics
131
+ input_tokens = max(1, len(str(query)) // 4)
132
+ output_tokens = max(1, estimated_tokens or (sum(len(c.get("content", "")) for c in chunks) // 4))
133
+ total_tokens = input_tokens + output_tokens
134
+ usage_details = {
135
+ "input": input_tokens,
136
+ "output": output_tokens,
137
+ "total": total_tokens
138
+ }
139
+
140
+ tags = [
141
+ f"org:{org_id}",
142
+ f"repo:{repo_id}",
143
+ f"transport:{transport}",
144
+ f"user:{user_identifier}",
145
+ "tool:m5_get_context"
146
+ ]
147
+
148
+ metadata = {
149
+ "org_id": org_id,
150
+ "dept_id": dept_id,
151
+ "repo_id": repo_id,
152
+ "user_id": user_identifier,
153
+ "caller_identity": caller_identity or "unknown",
154
+ "duration_ms": duration_ms,
155
+ "chunks_count": len(chunks),
156
+ "dependency_edges_count": len(dep_edges),
157
+ "related_tests_count": len(related_tests),
158
+ "total_tokens": total_tokens,
159
+ "truncated": (result_bundle or {}).get("truncated", False),
160
+ }
161
+ if request_id:
162
+ metadata["request_id"] = request_id
163
+
164
+ input_payload = {
165
+ "query": query,
166
+ "top_k": top_k,
167
+ "expand_dependencies": expand_dependencies
168
+ }
169
+
170
+ output_payload = {
171
+ "total_chunks": len(chunks),
172
+ "estimated_tokens": total_tokens,
173
+ "citations": citations[:15],
174
+ "related_tests": related_tests,
175
+ "omissions": (result_bundle or {}).get("omissions", []),
176
+ "warnings": (result_bundle or {}).get("warnings", []),
177
+ "error": error
178
+ }
179
+
180
+ with propagate_attributes(
181
+ user_id=user_identifier,
182
+ session_id=session_identifier,
183
+ tags=tags,
184
+ metadata=metadata
185
+ ):
186
+ with client.start_as_current_observation(
187
+ name="m5_get_context",
188
+ as_type="retriever",
189
+ input=input_payload,
190
+ output=output_payload
191
+ ) as root_obs:
192
+ # Generation observation to populate token counts and cost dashboards in Langfuse
193
+ with client.start_as_current_observation(
194
+ name="context_retrieval_usage",
195
+ as_type="generation",
196
+ model="m5-context-engine",
197
+ input={"query": query},
198
+ output={"chunks_retrieved": len(chunks), "citations": citations[:5]},
199
+ usage_details=usage_details
200
+ ):
201
+ pass
202
+
203
+ # Sub-observation: Hybrid Search
204
+ if chunks:
205
+ top_chunk = chunks[0]
206
+ with client.start_as_current_observation(
207
+ name="hybrid_search",
208
+ as_type="retriever",
209
+ input={"query": query, "top_k": top_k},
210
+ output={
211
+ "top_symbol": top_chunk.get("symbol_name"),
212
+ "top_file": top_chunk.get("file_path"),
213
+ "top_score": top_chunk.get("relevance_score"),
214
+ "match_type": top_chunk.get("match_type"),
215
+ "confidence": top_chunk.get("confidence")
216
+ }
217
+ ):
218
+ pass
219
+
220
+ # Sub-observation: Dependency Expansion
221
+ if dep_edges:
222
+ with client.start_as_current_observation(
223
+ name="expand_dependencies",
224
+ as_type="tool",
225
+ input={"expand_dependencies": expand_dependencies},
226
+ output={"edges_count": len(dep_edges), "edges": dep_edges[:10]}
227
+ ):
228
+ pass
229
+
230
+ # Sub-observation: Companion Test Discovery
231
+ if related_tests:
232
+ with client.start_as_current_observation(
233
+ name="companion_test_discovery",
234
+ as_type="tool",
235
+ input={"targets": [c.get("file_path") for c in chunks[:5]]},
236
+ output={"companion_tests": related_tests}
237
+ ):
238
+ pass
239
+
240
+ flush_telemetry()
241
+
242
+ except Exception as ex:
243
+ logger.debug(f"Non-critical telemetry retrieval logging error: {ex}")
244
+
245
+
246
+ def log_mcp_tool_trace(
247
+ tool_name: str,
248
+ args: Dict[str, Any],
249
+ output: Any,
250
+ duration_ms: float,
251
+ caller_identity: Optional[str] = None,
252
+ org_id: str = "default_org",
253
+ dept_id: str = "default_dept",
254
+ repo_id: str = "default_repo",
255
+ transport: str = "mcp_stdio",
256
+ error: Optional[str] = None
257
+ ) -> None:
258
+ """
259
+ Logs any MCP tool call to Langfuse with user attribution and token usage.
260
+ """
261
+ client = get_telemetry_client()
262
+ if client is None:
263
+ return
264
+
265
+ # m5_get_context is already traced in detail by log_retrieval_trace
266
+ if tool_name == "m5_get_context":
267
+ return
268
+
269
+ try:
270
+ from langfuse import propagate_attributes
271
+
272
+ user_identifier = _resolve_user_id(None, caller_identity)
273
+ session_identifier = f"{org_id}:{dept_id}:{repo_id}"
274
+
275
+ as_type = "tool"
276
+ if tool_name in ("m5_search_code", "m5_find_symbol_references"):
277
+ as_type = "retriever"
278
+
279
+ # Estimate tokens for the tool call
280
+ input_tokens = max(1, len(str(args)) // 4)
281
+ output_tokens = max(1, len(str(output)) // 4)
282
+ total_tokens = input_tokens + output_tokens
283
+ usage_details = {
284
+ "input": input_tokens,
285
+ "output": output_tokens,
286
+ "total": total_tokens
287
+ }
288
+
289
+ tags = [
290
+ f"org:{org_id}",
291
+ f"repo:{repo_id}",
292
+ f"transport:{transport}",
293
+ f"user:{user_identifier}",
294
+ f"tool:{tool_name}"
295
+ ]
296
+
297
+ metadata = {
298
+ "org_id": org_id,
299
+ "dept_id": dept_id,
300
+ "repo_id": repo_id,
301
+ "user_id": user_identifier,
302
+ "caller_identity": caller_identity or "unknown",
303
+ "duration_ms": duration_ms,
304
+ "total_tokens": total_tokens,
305
+ "error": error
306
+ }
307
+
308
+ # Sanitize output representation for readable trace view
309
+ out_summary = output
310
+ if isinstance(output, str) and len(output) > 2000:
311
+ out_summary = output[:2000] + "\n... [truncated for display]"
312
+
313
+ with propagate_attributes(
314
+ user_id=user_identifier,
315
+ session_id=session_identifier,
316
+ tags=tags,
317
+ metadata=metadata
318
+ ):
319
+ with client.start_as_current_observation(
320
+ name=tool_name,
321
+ as_type=as_type,
322
+ input=args,
323
+ output={"result": out_summary, "error": error}
324
+ ):
325
+ with client.start_as_current_observation(
326
+ name=f"{tool_name}_usage",
327
+ as_type="generation",
328
+ model="m5-context-engine",
329
+ input=args,
330
+ output={"result": "completed"},
331
+ usage_details=usage_details
332
+ ):
333
+ pass
334
+
335
+ flush_telemetry()
336
+
337
+ except Exception as ex:
338
+ logger.debug(f"Non-critical MCP tool telemetry error: {ex}")