contextl 1.2.3 → 1.2.5

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/README.md CHANGED
@@ -86,43 +86,6 @@ Takes the exact dependency graph built by the intelligence engine and physically
86
86
 
87
87
  ---
88
88
 
89
- ## AI System Prompt (Query Optimizer)
90
-
91
- ContextL is a deterministic keyword and graph engine, not a semantic AI. For maximum accuracy, inject this system prompt into your AI agent's rules (e.g., `.cursorrules` or custom instructions). It forces the AI to translate your natural language questions into highly optimized, lexical keyword strings before hitting the `query_repo` tool.
92
-
93
- ```text
94
- System Instruction: You are a translation layer designed to convert human requests into highly optimized queries for the contextl codebase search engine. The contextl engine is NOT a semantic AI; it is an advanced, dependency-aware fuzzy keyword searcher. It relies on exact token matching and import-graph ranking.
95
-
96
- Your goal is to extract the core technical keywords from the user's request and discard all conversational filler or vague concepts.
97
-
98
- Rules for generating the query string:
99
-
100
- 1. Remove Conversational Filler: Drop words like "where is", "how does", "find the", "show me".
101
- 2. Translate Concepts to Code: If the user asks for a concept, translate it into the exact keywords, classes, or library syntax a developer would type.
102
- Example: "routing" ➡️ createRouter Route routeTree
103
- Example: "entry point" ➡️ main SpringBootApplication bootstrap web.xml init
104
- Example: "database schema" ➡️ Entity Column Table model schema
105
- 3. Include Known Framework Terms: If you know the language or framework of the codebase, append its standard terminology.
106
- 4. Target File Types/Structures: If looking for configurations, use terms like config properties yaml xml json.
107
- 5. Keep it space-separated: Output a single string of space-separated keywords without punctuation.
108
-
109
- Examples:
110
-
111
- User: "Where are the main react components and routing defined?"
112
- Optimized Query: createRouter Route routeTree components layout
113
-
114
- User: "Find the application entry point and main method."
115
- Optimized Query: public static void main bootstrap init ApplicationContext configuration
116
-
117
- User: "How is the user authentication handled?"
118
- Optimized Query: login auth authenticate jwt token session passport
119
-
120
- Input: [Insert human request here]
121
- Output: [Return ONLY the optimized keyword string]
122
- ```
123
-
124
- ---
125
-
126
89
  ## How the ranking works
127
90
 
128
91
  1. **Keyword match** — does the filename contain query terms?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -92,19 +92,25 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
92
92
  return list(set(paths))
93
93
 
94
94
 
95
- def _is_external(import_path: str, extension: str) -> bool:
95
+ def _is_external(import_path: str, extension: str, alias_map: dict[str, str] = None) -> bool:
96
96
  """Return True if this is an external package rather than a local file."""
97
97
  if extension in {".py", ".java"}:
98
98
  # For Python and Java, it's hard to tell without resolving,
99
99
  # so assume it's local and let the resolver fail if it's external.
100
100
  return False
101
101
 
102
- # Node.js: Local files start with . (relative) or @ followed by / (alias like @/)
103
- # but NOT @scope/package style from npm — those don't contain our alias prefix
102
+ # Node.js: Local files start with . (relative) or match an alias
104
103
  if import_path.startswith("./") or import_path.startswith("../"):
105
104
  return False
105
+
106
+ if alias_map:
107
+ for alias in alias_map:
108
+ if import_path.startswith(alias):
109
+ return False
110
+
106
111
  if import_path.startswith("@/"):
107
112
  return False
113
+
108
114
  return True # Everything else is an external package
109
115
 
110
116
 
@@ -212,16 +218,18 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
212
218
  Returns a map like {"@/": "frontend/"} or {"@/": ""}
213
219
  """
214
220
  root = Path(scan_result.root)
221
+ alias_map = {}
215
222
 
216
223
  # Look for tsconfig.json to find paths config
217
224
  for tsconfig in root.rglob("tsconfig.json"):
218
225
  try:
219
226
  import json
220
- data = json.loads(tsconfig.read_text())
227
+ # naive regex to strip comments from tsconfig before parsing
228
+ text = tsconfig.read_text()
229
+ text = re.sub(r'//.*?\n|/\*.*?\*/', '', text, flags=re.S)
230
+ data = json.loads(text)
221
231
  paths = data.get("compilerOptions", {}).get("paths", {})
222
- base_url = data.get("compilerOptions", {}).get("baseUrl", ".")
223
232
 
224
- alias_map = {}
225
233
  for alias, targets in paths.items():
226
234
  if not targets:
227
235
  continue
@@ -232,19 +240,19 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
232
240
  tsconfig_dir = tsconfig.parent.relative_to(root)
233
241
  prefix = str(tsconfig_dir / clean_target).lstrip("./")
234
242
  alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
235
-
236
- if alias_map:
237
- return alias_map
238
243
  except Exception:
239
244
  continue
240
245
 
241
- # Heuristic fallback: if all files share a common top-level dir, use that
242
- top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
243
- if len(top_dirs) == 1:
244
- top = list(top_dirs)[0]
245
- return {"@/": top + "/"}
246
+ if not alias_map:
247
+ # Heuristic fallback: if all files share a common top-level dir, use that
248
+ top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
249
+ if len(top_dirs) == 1:
250
+ top = list(top_dirs)[0]
251
+ alias_map["@/"] = top + "/"
252
+ else:
253
+ alias_map["@/"] = ""
246
254
 
247
- return {"@/": ""}
255
+ return alias_map
248
256
 
249
257
 
250
258
  def parse_imports(scan_result: ScanResult) -> ParseResult:
@@ -275,7 +283,7 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
275
283
  raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
276
284
 
277
285
  for raw in raw_imports:
278
- if _is_external(raw, scanned_file.extension):
286
+ if _is_external(raw, scanned_file.extension, alias_map):
279
287
  result.unresolved.append((scanned_file.path, raw))
280
288
  continue
281
289
 
@@ -298,11 +306,8 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
298
306
  candidate = raw.replace(".", "/")
299
307
  else:
300
308
  # Node.js
301
- if raw.startswith("@/"):
302
- resolved_alias = _resolve_alias(raw, alias_map)
303
- if resolved_alias is None:
304
- result.unresolved.append((scanned_file.path, raw))
305
- continue
309
+ resolved_alias = _resolve_alias(raw, alias_map)
310
+ if resolved_alias is not None:
306
311
  candidate = resolved_alias
307
312
  else:
308
313
  candidate = _resolve_relative(raw, scanned_file.path)
@@ -71,9 +71,19 @@ async def list_tools() -> list[types.Tool]:
71
71
  "query": {
72
72
  "type": "string",
73
73
  "description": (
74
- "Natural-language description of the change you want to make. "
75
- "Examples: 'fix the upload error handler', "
76
- "'change the login page logo', 'add dark mode to footer'."
74
+ "SYSTEM INSTRUCTION: You are a translation layer designed to convert human requests into highly optimized queries for the contextl codebase search engine. "
75
+ "The contextl engine is NOT a semantic AI; it is an advanced, dependency-aware fuzzy keyword searcher. It relies on exact token matching and import-graph ranking.\n\n"
76
+ "Your goal is to extract the core technical keywords from the user's request and discard all conversational filler or vague concepts.\n\n"
77
+ "Rules for generating the query string:\n"
78
+ "1. Remove Conversational Filler: Drop words like 'where is', 'how does', 'find the', 'show me'.\n"
79
+ "2. Translate Concepts to Code: If the user asks for a concept, translate it into the exact keywords, classes, or library syntax a developer would type. (e.g., 'routing' -> 'createRouter Route routeTree', 'entry point' -> 'main bootstrap init').\n"
80
+ "3. Include Known Framework Terms: Append standard terminology of the framework.\n"
81
+ "4. Target File Types/Structures: Use terms like 'config', 'yaml', 'json' if looking for configurations.\n"
82
+ "5. Keep it space-separated: Output a single string of space-separated keywords without punctuation.\n\n"
83
+ "Examples:\n"
84
+ "- 'Where are the main react components and routing defined?' -> 'createRouter Route routeTree components layout'\n"
85
+ "- 'Find the application entry point and main method.' -> 'public static void main bootstrap init ApplicationContext configuration'\n"
86
+ "- 'How is the user authentication handled?' -> 'login auth authenticate jwt token session passport'"
77
87
  ),
78
88
  },
79
89
  "top_n": {
@@ -14,6 +14,7 @@ Accepts a natural-language query and ranks files by relevance using:
14
14
  No LLM. No embeddings. Pure text + graph.
15
15
  """
16
16
 
17
+ import math
17
18
  import re
18
19
  from dataclasses import dataclass, field
19
20
 
@@ -30,6 +31,8 @@ STOP_WORDS = {
30
31
  "or", "is", "it", "this", "that", "with", "from", "how", "what",
31
32
  "where", "when", "which", "who", "make", "change", "update", "fix",
32
33
  "add", "remove", "edit", "modify", "the", "my", "our", "i", "we",
34
+ "string", "int", "boolean", "class", "function", "return", "import",
35
+ "public", "private", "logic", "handler", "data", "value"
33
36
  }
34
37
 
35
38
 
@@ -75,7 +78,7 @@ def _path_tokens(path: str) -> list[str]:
75
78
  return [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
76
79
 
77
80
 
78
- def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
81
+ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
79
82
  """
80
83
  Score how well query terms match the file path / name.
81
84
  Filename matches score higher than directory matches.
@@ -88,37 +91,39 @@ def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[
88
91
 
89
92
  for term in query_terms:
90
93
  if term in filename_toks:
91
- score += 1.0 # Strong signal: term in filename
94
+ score += idf_weights.get(term, 1.0) # Strong signal: term in filename
92
95
  matched.append(term)
93
96
  elif term in path_toks:
94
- score += 0.4 # Weaker signal: term in directory path
97
+ score += 0.4 * idf_weights.get(term, 1.0) # Weaker signal: term in directory path
95
98
  if term not in matched:
96
99
  matched.append(term)
97
100
 
98
- # Normalize by number of query terms
99
- if query_terms:
100
- score = score / len(query_terms)
101
+ # Normalize by max possible score (sum of all IDFs)
102
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
103
+ if max_score > 0:
104
+ score = score / max_score
101
105
 
102
106
  return score, matched
103
107
 
104
108
 
105
- def _content_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
109
+ def _content_score(query_terms: list[str], file_content: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
106
110
  """
107
111
  Score how often query terms appear in the file's source code.
108
- Uses term frequency, capped to avoid huge files dominating.
112
+ Uses TF-IDF weighting, capped to avoid huge files dominating.
109
113
  """
110
- try:
111
- content = open(file_path, encoding="utf-8").read().lower()
112
- except Exception:
113
- return 0.0, []
114
-
115
- content_tokens = set(re.findall(r"[a-z0-9]+", content))
114
+ content_tokens = set(re.findall(r"[a-z0-9]+", file_content))
116
115
  matched = [t for t in query_terms if t in content_tokens]
117
116
 
118
117
  if not query_terms:
119
118
  return 0.0, []
120
119
 
121
- score = len(matched) / len(query_terms)
120
+ score = sum(idf_weights.get(t, 1.0) for t in matched)
121
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
122
+ if max_score > 0:
123
+ score = score / max_score
124
+ else:
125
+ score = 0.0
126
+
122
127
  return score, matched
123
128
 
124
129
 
@@ -173,15 +178,38 @@ def query(
173
178
  if not query_terms:
174
179
  return []
175
180
 
181
+ # --- Pre-computation: Global TF-IDF ---
182
+ total_files = len(repo_graph.nodes)
183
+ document_frequency = {term: 0 for term in query_terms}
184
+
185
+ file_contents = {}
186
+ for path, node in repo_graph.nodes.items():
187
+ abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
188
+ try:
189
+ content = open(abs_path, encoding="utf-8").read().lower()
190
+ file_contents[path] = content
191
+ content_tokens = set(re.findall(r"[a-z0-9]+", content))
192
+ path_tokens = set(_path_tokens(path))
193
+ combined_tokens = content_tokens.union(path_tokens)
194
+ for term in query_terms:
195
+ if term in combined_tokens:
196
+ document_frequency[term] += 1
197
+ except Exception:
198
+ file_contents[path] = ""
199
+
200
+ idf_weights = {}
201
+ for term in query_terms:
202
+ df = document_frequency[term]
203
+ idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
204
+
176
205
  # --- Pass 1: keyword + content scores per file ---
177
206
  keyword_scores: dict[str, float] = {}
178
207
  content_scores: dict[str, float] = {}
179
208
  matched_terms: dict[str, list[str]] = {}
180
209
 
181
210
  for path, node in repo_graph.nodes.items():
182
- kscore, kterms = _keyword_score(query_terms, path)
183
- cscore, cterms = _content_score(query_terms, node.path if hasattr(node, "absolute_path") else
184
- str(__import__("pathlib").Path(repo_graph.root) / path))
211
+ kscore, kterms = _keyword_score(query_terms, path, idf_weights)
212
+ cscore, cterms = _content_score(query_terms, file_contents.get(path, ""), idf_weights)
185
213
 
186
214
  keyword_scores[path] = kscore
187
215
  content_scores[path] = cscore