contextl 1.2.7 → 1.2.9

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": "contextl",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
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",
@@ -71,19 +71,12 @@ async def list_tools() -> list[types.Tool]:
71
71
  "query": {
72
72
  "type": "string",
73
73
  "description": (
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'"
74
+ "SYSTEM INSTRUCTION: The contextl search engine is highly optimized for both raw conversational queries AND exact keyword searches. "
75
+ "It uses dynamic structural multipliers to instantly rank classes for short/conversational queries, and switches to BM25 frequency math for longer keyword queries.\n\n"
76
+ "STRATEGY INSTRUCTION:\n"
77
+ "1. INITIAL SEARCHES: Pass the user's raw natural-language query directly. Do NOT convert it to keywords. The engine is smart enough to drop stop-words and find the primary class anchors. Example: 'where is the auth logic?' -> 'where is the auth logic?'\n"
78
+ "2. TARGETED DEBUG SEARCHES: If the initial search fails, or if you are deep into debugging and need to cross-reference very specific variables across the codebase, then convert the query into highly optimized, space-separated keywords. Example: 'auth failure bug' -> 'login jwt AuthController retry_count token'\n"
79
+ "Never append generic filler words to targeted searches."
87
80
  ),
88
81
  },
89
82
  "top_n": {
@@ -32,7 +32,9 @@ STOP_WORDS = {
32
32
  "where", "when", "which", "who", "make", "change", "update", "fix",
33
33
  "add", "remove", "edit", "modify", "the", "my", "our", "i", "we",
34
34
  "string", "int", "boolean", "class", "function", "return", "import",
35
- "public", "private", "logic", "handler", "data", "value"
35
+ "public", "private", "logic", "handler", "data", "value", "yo", "bro",
36
+ "bruh", "pls", "please", "bug", "issue", "error", "help", "stuff",
37
+ "handling", "failing", "why", "need", "can", "could", "would", "should", "fr"
36
38
  }
37
39
 
38
40
 
@@ -91,70 +93,138 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
91
93
  score = 0.0
92
94
 
93
95
  for term in query_terms:
96
+ # Exact match
94
97
  if term in filename_toks:
95
- score += idf_weights.get(term, 1.0) # Strong signal: term in filename
98
+ score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
96
99
  matched.append(term)
100
+ # Substring match on filename
101
+ elif len(term) > 3 and any(term in ft for ft in filename_toks):
102
+ score += 1.5 * idf_weights.get(term, 1.0)
103
+ matched.append(term)
104
+ # Exact match on path
97
105
  elif term in path_toks:
98
- score += 0.4 * idf_weights.get(term, 1.0) # Weaker signal: term in directory path
106
+ score += 0.8 * idf_weights.get(term, 1.0) # Strong signal
107
+ if term not in matched:
108
+ matched.append(term)
109
+ # Substring match on path
110
+ elif len(term) > 3 and any(term in pt for pt in path_toks):
111
+ score += 0.4 * idf_weights.get(term, 1.0)
99
112
  if term not in matched:
100
113
  matched.append(term)
101
114
 
102
115
  # Normalize by max possible score (sum of all IDFs)
103
116
  max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
104
117
  if max_score > 0:
105
- score = score / max_score
118
+ score = min(1.0, score / max_score) # Cap at 1.0 since we have massive multipliers
106
119
 
107
120
  return score, matched
108
121
 
109
122
 
110
- def _content_score(query_terms: list[str], file_path: str, file_content: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
123
+ def _content_score(query_terms: list[str], file_path: str, file_content: str, idf_weights: dict[str, float], dl: int, avgdl: float) -> tuple[float, list[str]]:
111
124
  """
112
125
  Score how often query terms appear in the file's source code.
113
- Uses TF-IDF weighting, capped to avoid huge files dominating.
126
+ Uses BM25 Term Frequency weighting to neutralize document length bias.
114
127
  Includes language-specific heuristics for Java and other OOP languages.
115
128
  """
116
- is_oop = file_path.endswith(".java") or file_path.endswith(".ts") or file_path.endswith(".tsx")
117
- content_tokens = set(re.findall(r"[a-z0-9]+", file_content.lower()))
129
+ is_java = file_path.endswith(".java")
130
+ is_ts = file_path.endswith(".ts") or file_path.endswith(".tsx")
131
+ is_oop = is_java or is_ts
132
+
133
+ content_tokens_list = re.findall(r"[a-z0-9]+", file_content.lower())
118
134
 
119
- # 1. CamelCase Splitting
120
- if is_oop:
135
+ # 1. CamelCase Splitting (Java only)
136
+ if is_java:
121
137
  camel_tokens = []
122
138
  for word in re.findall(r"[a-zA-Z]+", file_content):
123
139
  if any(c.isupper() for c in word):
124
140
  sub = re.sub(r"([A-Z])", r" \1", word).lower()
125
141
  camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
126
- content_tokens.update([t for t in camel_tokens if len(t) > 1])
142
+ content_tokens_list.extend([t for t in camel_tokens if len(t) > 1])
127
143
 
128
- # 2. Extract Class Declarations & Annotations
144
+ # 2. Extract Class Declarations, Annotations & Method Names
129
145
  declared_classes = set()
146
+ ts_exports = set()
147
+ java_methods = set()
130
148
  if is_oop or file_path.endswith(".py"):
131
149
  decls = re.findall(r"\b(?:class|interface|enum)\s+([a-zA-Z0-9_]+)", file_content)
132
150
  for d in decls:
133
151
  declared_classes.add(d.lower())
134
152
 
153
+ if is_java:
154
+ # Extract Java method names: public/private/protected ... methodName(
155
+ methods = re.findall(r"\b(?:public|private|protected)\s+(?:static\s+)?(?:[a-zA-Z0-9_<>\[\]]+)\s+([a-zA-Z0-9_]+)\s*\(", file_content)
156
+ for m in methods:
157
+ if m.lower() not in {"main", "get", "set", "is", "has"}:
158
+ java_methods.add(m.lower())
159
+ # Also add CamelCase parts of method names
160
+ sub = re.sub(r"([A-Z])", r" \1", m).lower()
161
+ for part in re.findall(r"[a-z0-9]+", sub):
162
+ if len(part) > 3:
163
+ java_methods.add(part)
164
+
165
+ if is_ts:
166
+ exports = re.findall(r"\bexport\s+(?:const|let|var|class|interface|function|default)\s+([a-zA-Z0-9_]+)", file_content)
167
+ for e in exports:
168
+ ts_exports.add(e.lower())
169
+
135
170
  annotations = set()
136
171
  if is_oop or file_path.endswith(".py"):
137
172
  anns = re.findall(r"@([a-zA-Z0-9_]+)", file_content)
138
173
  for a in anns:
139
174
  annotations.add(a.lower())
140
175
 
141
- matched = [t for t in query_terms if t in content_tokens]
176
+ term_counts = {}
177
+ for t in query_terms:
178
+ count = 0
179
+ for ct in content_tokens_list:
180
+ if t == ct:
181
+ count += 1
182
+ elif len(t) > 3 and t in ct:
183
+ count += 1
184
+ if count > 0:
185
+ term_counts[t] = count
186
+
187
+ matched = list(term_counts.keys())
142
188
 
143
189
  if not query_terms:
144
190
  return 0.0, []
145
191
 
192
+ # Multiplier strength scales inversely with query length.
193
+ # Short queries (1-2 terms, likely vibe) need a loud structural signal.
194
+ # Long queries (4+ terms, likely standard LLM) rely more on BM25 math.
195
+ n_terms = len(query_terms)
196
+ if n_terms <= 2:
197
+ class_mult = 10.0
198
+ ann_mult = 5.0
199
+ elif n_terms <= 4:
200
+ class_mult = 6.0
201
+ ann_mult = 3.0
202
+ else:
203
+ class_mult = 3.0
204
+ ann_mult = 2.0
205
+
146
206
  score = 0.0
147
207
  for t in matched:
148
208
  base_weight = idf_weights.get(t, 1.0)
149
209
  multiplier = 1.0
150
210
 
151
- # 3. Apply Multipliers
152
- if t in declared_classes:
153
- multiplier = 10.0
211
+ # 3. Apply Multipliers — TS exports always get 8x regardless of query length
212
+ if t in ts_exports:
213
+ multiplier = 8.0
214
+ elif t in declared_classes:
215
+ multiplier = class_mult
216
+ elif t in java_methods:
217
+ multiplier = 4.0
154
218
  elif t in annotations:
155
- multiplier = 5.0
219
+ multiplier = ann_mult
156
220
 
157
- score += (base_weight * multiplier)
221
+ # 4. BM25 Term Frequency
222
+ count = term_counts[t]
223
+ k1 = 1.5
224
+ b = 0.75
225
+ tf_bm25 = (count * (k1 + 1)) / (count + k1 * (1 - b + b * (dl / avgdl)))
226
+
227
+ score += (tf_bm25 * base_weight * multiplier)
158
228
 
159
229
  max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
160
230
  if max_score > 0:
@@ -170,7 +240,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
170
240
  def _apply_neighbor_bonus(
171
241
  scores: dict[str, float],
172
242
  repo_graph: RepoGraph,
173
- boost: float = 0.15,
243
+ boost: float = 0.35,
174
244
  depth: int = 1,
175
245
  ) -> dict[str, float]:
176
246
  """
@@ -218,20 +288,29 @@ def query(
218
288
  if not query_terms:
219
289
  return []
220
290
 
221
- # --- Pre-computation: Global TF-IDF ---
291
+ # --- Pre-computation: Global TF-IDF & BM25 ---
222
292
  import pathlib
223
293
  total_files = len(repo_graph.nodes)
224
294
  document_frequency = {term: 0 for term in query_terms}
225
295
 
226
296
  file_contents = {}
297
+ doc_lengths = {}
298
+ total_tokens_count = 0
299
+
227
300
  for path, node in repo_graph.nodes.items():
228
301
  abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
229
302
  try:
230
303
  content = open(abs_path, encoding="utf-8").read()
231
304
  file_contents[path] = content
232
- content_tokens = set(re.findall(r"[a-z0-9]+", content.lower()))
233
305
 
234
- if path.endswith(".java") or path.endswith(".ts") or path.endswith(".tsx"):
306
+ raw_tokens = re.findall(r"[a-z0-9]+", content.lower())
307
+ dl = len(raw_tokens)
308
+ doc_lengths[path] = dl
309
+ total_tokens_count += dl
310
+
311
+ content_tokens = set(raw_tokens)
312
+
313
+ if path.endswith(".java"):
235
314
  camel_tokens = []
236
315
  for word in re.findall(r"[a-zA-Z]+", content):
237
316
  if any(c.isupper() for c in word):
@@ -244,13 +323,24 @@ def query(
244
323
  for term in query_terms:
245
324
  if term in combined_tokens:
246
325
  document_frequency[term] += 1
326
+ elif len(term) > 3:
327
+ for ct in combined_tokens:
328
+ if term in ct:
329
+ document_frequency[term] += 1
330
+ break
247
331
  except Exception:
248
332
  file_contents[path] = ""
333
+ doc_lengths[path] = 0
334
+
335
+ avgdl = total_tokens_count / total_files if total_files > 0 else 1.0
249
336
 
250
337
  idf_weights = {}
251
338
  for term in query_terms:
252
339
  df = document_frequency[term]
253
- idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
340
+ if df == 0:
341
+ idf_weights[term] = 0.0
342
+ else:
343
+ idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
254
344
 
255
345
  # --- Pass 1: keyword + content scores per file ---
256
346
  keyword_scores: dict[str, float] = {}
@@ -259,7 +349,8 @@ def query(
259
349
 
260
350
  for path, node in repo_graph.nodes.items():
261
351
  kscore, kterms = _keyword_score(query_terms, path, idf_weights)
262
- cscore, cterms = _content_score(query_terms, path, file_contents.get(path, ""), idf_weights)
352
+ dl = doc_lengths.get(path, 0)
353
+ cscore, cterms = _content_score(query_terms, path, file_contents.get(path, ""), idf_weights, dl, avgdl)
263
354
 
264
355
  keyword_scores[path] = kscore
265
356
  content_scores[path] = cscore