contextl 1.2.6 → 1.2.8

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
@@ -84,44 +84,7 @@ Finds unused files and dead code by analyzing the dependency graph for files wit
84
84
 
85
85
  Takes the exact dependency graph built by the intelligence engine and physically writes it to disk as a directory of interconnected Markdown files. Automatically injects file metadata, JSDoc/Docstring explanations, and uses standard `[[wikilinks]]` to map out dependencies. Open the generated folder as an Obsidian vault for a stunning 3D interactive graph of your architecture!
86
86
 
87
- ---
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
87
 
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
88
 
126
89
  ## How the ranking works
127
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
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",
@@ -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
 
@@ -60,37 +62,6 @@ class RankedFile:
60
62
  )
61
63
 
62
64
 
63
- @dataclass
64
- class FileFeatures:
65
- tokens: set[str]
66
- annotations: set[str]
67
- declarations: set[str]
68
-
69
-
70
- def _extract_file_features(content: str, extension: str) -> FileFeatures:
71
- """Extract structural heuristics and lowercase tokens from a file."""
72
- # 1. Declarations: class X, interface Y, enum Z
73
- decl_pattern = r"\b(?:class|interface|enum)\s+([A-Za-z0-9_]+)"
74
- declarations = {m.group(1).lower() for m in re.finditer(decl_pattern, content)}
75
-
76
- # 2. Annotations: @Controller, @Injectable
77
- annotations = {m.group(1).lower() for m in re.finditer(r"@([A-Za-z0-9_]+)", content)}
78
-
79
- # 3. Base tokens
80
- tokens = set(re.findall(r"[a-z0-9]+", content.lower()))
81
-
82
- # 4. CamelCase / PascalCase Splitting for structural languages
83
- if extension in {".java", ".ts", ".tsx"}:
84
- for word in re.findall(r"[A-Za-z]+", content):
85
- if not word.islower() and not word.isupper() and len(word) > 2:
86
- # Split CamelCase
87
- sub = re.sub(r"([a-z])([A-Z])", r"\1 \2", word).lower()
88
- for part in re.findall(r"[a-z0-9]+", sub):
89
- if len(part) > 1:
90
- tokens.add(part)
91
-
92
- return FileFeatures(tokens=tokens, annotations=annotations, declarations=declarations)
93
-
94
65
 
95
66
  def _tokenize(text: str) -> list[str]:
96
67
  """Lowercase, split on non-alphanumeric, remove stop words."""
@@ -122,55 +93,154 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
122
93
  score = 0.0
123
94
 
124
95
  for term in query_terms:
96
+ # Exact match
125
97
  if term in filename_toks:
126
- score += idf_weights.get(term, 1.0) # Strong signal: term in filename
98
+ score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
127
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
128
105
  elif term in path_toks:
129
- 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)
130
112
  if term not in matched:
131
113
  matched.append(term)
132
114
 
133
115
  # Normalize by max possible score (sum of all IDFs)
134
116
  max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
135
117
  if max_score > 0:
136
- score = score / max_score
118
+ score = min(1.0, score / max_score) # Cap at 1.0 since we have massive multipliers
137
119
 
138
120
  return score, matched
139
121
 
140
122
 
141
- def _content_score(query_terms: list[str], features: "FileFeatures", 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]]:
142
124
  """
143
125
  Score how often query terms appear in the file's source code.
144
- Applies massive multipliers for architectural declarations and annotations.
126
+ Uses BM25 Term Frequency weighting to neutralize document length bias.
127
+ Includes language-specific heuristics for Java and other OOP languages.
145
128
  """
146
- if not features or not query_terms:
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())
134
+
135
+ # 1. CamelCase Splitting (Java only)
136
+ if is_java:
137
+ camel_tokens = []
138
+ for word in re.findall(r"[a-zA-Z]+", file_content):
139
+ if any(c.isupper() for c in word):
140
+ sub = re.sub(r"([A-Z])", r" \1", word).lower()
141
+ camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
142
+ content_tokens_list.extend([t for t in camel_tokens if len(t) > 1])
143
+
144
+ # 2. Extract Class Declarations, Annotations & Method Names
145
+ declared_classes = set()
146
+ ts_exports = set()
147
+ java_methods = set()
148
+ if is_oop or file_path.endswith(".py"):
149
+ decls = re.findall(r"\b(?:class|interface|enum)\s+([a-zA-Z0-9_]+)", file_content)
150
+ for d in decls:
151
+ declared_classes.add(d.lower())
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
+
170
+ annotations = set()
171
+ if is_oop or file_path.endswith(".py"):
172
+ anns = re.findall(r"@([a-zA-Z0-9_]+)", file_content)
173
+ for a in anns:
174
+ annotations.add(a.lower())
175
+
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())
188
+
189
+ if not query_terms:
147
190
  return 0.0, []
148
191
 
149
- matched = [t for t in query_terms if t in features.tokens]
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
150
205
 
151
206
  score = 0.0
152
207
  for t in matched:
153
208
  base_weight = idf_weights.get(t, 1.0)
154
209
  multiplier = 1.0
155
- if t in features.declarations:
156
- multiplier = 10.0
157
- elif t in features.annotations:
158
- multiplier = 5.0
159
- score += base_weight * multiplier
210
+
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
218
+ elif t in annotations:
219
+ multiplier = ann_mult
220
+
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)
160
228
 
161
229
  max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
162
230
  if max_score > 0:
163
- score = score / max_score
231
+ base_normalized = min(1.0, sum(idf_weights.get(t, 1.0) for t in matched) / max_score)
232
+ bonus = score - sum(idf_weights.get(t, 1.0) for t in matched)
233
+ final_score = base_normalized + bonus
164
234
  else:
165
- score = 0.0
235
+ final_score = 0.0
166
236
 
167
- return score, matched
237
+ return final_score, matched
168
238
 
169
239
 
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,33 +288,59 @@ 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
- file_features_cache = {}
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
- extension = pathlib.Path(path).suffix.lower()
230
-
231
302
  try:
232
303
  content = open(abs_path, encoding="utf-8").read()
233
- features = _extract_file_features(content, extension)
234
- file_features_cache[path] = features
304
+ file_contents[path] = content
235
305
 
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"):
314
+ camel_tokens = []
315
+ for word in re.findall(r"[a-zA-Z]+", content):
316
+ if any(c.isupper() for c in word):
317
+ sub = re.sub(r"([A-Z])", r" \1", word).lower()
318
+ camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
319
+ content_tokens.update([t for t in camel_tokens if len(t) > 1])
320
+
236
321
  path_tokens = set(_path_tokens(path))
237
- combined_tokens = features.tokens.union(path_tokens)
322
+ combined_tokens = content_tokens.union(path_tokens)
238
323
  for term in query_terms:
239
324
  if term in combined_tokens:
240
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
241
331
  except Exception:
242
- file_features_cache[path] = None
332
+ file_contents[path] = ""
333
+ doc_lengths[path] = 0
334
+
335
+ avgdl = total_tokens_count / total_files if total_files > 0 else 1.0
243
336
 
244
337
  idf_weights = {}
245
338
  for term in query_terms:
246
339
  df = document_frequency[term]
247
- 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
248
344
 
249
345
  # --- Pass 1: keyword + content scores per file ---
250
346
  keyword_scores: dict[str, float] = {}
@@ -253,7 +349,8 @@ def query(
253
349
 
254
350
  for path, node in repo_graph.nodes.items():
255
351
  kscore, kterms = _keyword_score(query_terms, path, idf_weights)
256
- cscore, cterms = _content_score(query_terms, file_features_cache.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)
257
354
 
258
355
  keyword_scores[path] = kscore
259
356
  content_scores[path] = cscore