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 +0 -37
- package/package.json +1 -1
- package/python/import_parser.py +26 -21
- package/python/mcp_server.py +13 -3
- package/python/query_engine.py +46 -18
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
package/python/import_parser.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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)
|
package/python/mcp_server.py
CHANGED
|
@@ -71,9 +71,19 @@ async def list_tools() -> list[types.Tool]:
|
|
|
71
71
|
"query": {
|
|
72
72
|
"type": "string",
|
|
73
73
|
"description": (
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
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": {
|
package/python/query_engine.py
CHANGED
|
@@ -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
|
|
99
|
-
|
|
100
|
-
|
|
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],
|
|
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
|
|
112
|
+
Uses TF-IDF weighting, capped to avoid huge files dominating.
|
|
109
113
|
"""
|
|
110
|
-
|
|
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 =
|
|
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,
|
|
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
|