contextl 1.2.12 → 1.2.13

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.12",
3
+ "version": "1.2.13",
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",
@@ -11,16 +11,27 @@ Usage (the IDE does this for you, but you can test manually):
11
11
  python mcp_server.py
12
12
 
13
13
  Tools exposed:
14
- query_repo — rank files by relevance to a natural-language query
15
- scan_repo — list all source files the engine can see in a repo
16
- analyze_impact — find every file affected by changing a target file
14
+ query_repo — rank files by relevance to a natural-language query
15
+ scan_repo — list all source files the engine can see in a repo
16
+ analyze_impact — find every file affected by changing a target file
17
+ find_standalone_files — find unimported files with in-degree 0
18
+ export_obsidian_vault — export repo graph to an Obsidian markdown vault
19
+
20
+ Performance:
21
+ A shared graph cache is maintained per repo path + file mtime fingerprint.
22
+ The 4-step build pipeline (scan → parse → graph → query) runs ONCE per
23
+ session and is reused by all tool calls. Subsequent calls on an unchanged
24
+ repo return from cache in <1ms.
17
25
  """
18
26
 
19
- import argparse
20
27
  import asyncio
21
28
  import json
29
+ import os
22
30
  import sys
31
+ import threading
32
+ from dataclasses import dataclass
23
33
  from pathlib import Path
34
+ from typing import Optional
24
35
 
25
36
  import mcp.types as types
26
37
  from mcp.server import Server
@@ -39,6 +50,76 @@ from standalone import find_standalone_files
39
50
  from obsidian_export import export_obsidian_vault
40
51
 
41
52
 
53
+ # ---------------------------------------------------------------------------
54
+ # Shared graph cache — one pipeline build per repo per session
55
+ # ---------------------------------------------------------------------------
56
+ @dataclass
57
+ class _CacheEntry:
58
+ repo_path: str # resolved absolute path
59
+ mtime_hash: float # max mtime across all source files at build time
60
+ scan: object # ScanResult
61
+ repo_graph: object # RepoGraph
62
+
63
+
64
+ _cache_lock = threading.Lock()
65
+ _cache: Optional[_CacheEntry] = None
66
+
67
+
68
+ def _compute_mtime_hash(repo_path: str) -> float:
69
+ """Walk repo_path and return the maximum file modification time."""
70
+ max_mtime = 0.0
71
+ for dirpath, _, filenames in os.walk(repo_path):
72
+ # Skip hidden dirs and common noise dirs
73
+ dirpath_obj = Path(dirpath)
74
+ if any(part.startswith(".") or part in ("node_modules", "__pycache__", ".git", "dist", "build", "target")
75
+ for part in dirpath_obj.parts):
76
+ continue
77
+ for fname in filenames:
78
+ try:
79
+ mtime = os.path.getmtime(os.path.join(dirpath, fname))
80
+ if mtime > max_mtime:
81
+ max_mtime = mtime
82
+ except OSError:
83
+ pass
84
+ return max_mtime
85
+
86
+
87
+ def _get_graph(repo_path: str):
88
+ """
89
+ Return (scan, repo_graph) for the given repo_path.
90
+
91
+ On first call (or after any source file changes): runs the full
92
+ 4-step pipeline and stores the result in the module-level cache.
93
+ On subsequent calls with unchanged files: returns instantly from cache.
94
+ """
95
+ global _cache
96
+
97
+ resolved = str(Path(repo_path).resolve())
98
+ mtime_hash = _compute_mtime_hash(resolved)
99
+
100
+ with _cache_lock:
101
+ if (_cache is not None
102
+ and _cache.repo_path == resolved
103
+ and _cache.mtime_hash == mtime_hash):
104
+ print("[contextl] graph cache hit", file=sys.stderr, flush=True)
105
+ return _cache.scan, _cache.repo_graph
106
+
107
+ # Cache miss — build the pipeline
108
+ print("[contextl] building graph...", file=sys.stderr, flush=True)
109
+ scan = scan_repo(resolved)
110
+ parse = parse_imports(scan)
111
+ repo_graph = build_graph(scan, parse)
112
+
113
+ _cache = _CacheEntry(
114
+ repo_path=resolved,
115
+ mtime_hash=mtime_hash,
116
+ scan=scan,
117
+ repo_graph=repo_graph,
118
+ )
119
+ print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
120
+ return scan, repo_graph
121
+
122
+
42
123
  # ---------------------------------------------------------------------------
43
124
  # Server setup
44
125
  # ---------------------------------------------------------------------------
@@ -231,10 +312,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
231
312
 
232
313
 
233
314
  def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
234
- """Synchronous pipelinecalled from a thread executor."""
235
- scan = scan_repo(repo_path)
236
- parse = parse_imports(scan)
237
- repo_graph = build_graph(scan, parse)
315
+ """Uses cached graph pipeline runs only on first call or after file changes."""
316
+ scan, repo_graph = _get_graph(repo_path)
238
317
  ranked = engine_query(query_str, repo_graph, top_n=top_n)
239
318
 
240
319
  return {
@@ -268,7 +347,8 @@ async def _handle_scan(args: dict) -> list[types.TextContent]:
268
347
 
269
348
 
270
349
  def _run_scan(repo_path: str) -> dict:
271
- scan = scan_repo(repo_path)
350
+ """Uses cached graph — pipeline runs only on first call or after file changes."""
351
+ scan, _ = _get_graph(repo_path)
272
352
  return {
273
353
  "repo": scan.root,
274
354
  "total_files": scan.total_files,
@@ -294,10 +374,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
294
374
 
295
375
 
296
376
  def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
297
- """Synchronous pipelinecalled from a thread executor."""
298
- scan = scan_repo(repo_path)
299
- parse = parse_imports(scan)
300
- repo_graph = build_graph(scan, parse)
377
+ """Uses cached graph pipeline runs only on first call or after file changes."""
378
+ scan, repo_graph = _get_graph(repo_path)
301
379
  report = analyze_impact(target_file, repo_graph, max_depth=max_depth)
302
380
 
303
381
  return {
@@ -334,10 +412,8 @@ async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
334
412
 
335
413
 
336
414
  def _run_standalone_files(repo_path: str) -> dict:
337
- """Synchronous pipelinecalled from a thread executor."""
338
- scan = scan_repo(repo_path)
339
- parse = parse_imports(scan)
340
- repo_graph = build_graph(scan, parse)
415
+ """Uses cached graph pipeline runs only on first call or after file changes."""
416
+ scan, repo_graph = _get_graph(repo_path)
341
417
  standalone_files = find_standalone_files(repo_graph)
342
418
 
343
419
  return {
@@ -362,13 +438,10 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
362
438
 
363
439
 
364
440
  def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
365
- """Synchronous pipelinecalled from a thread executor."""
366
- scan = scan_repo(repo_path)
367
- parse = parse_imports(scan)
368
- repo_graph = build_graph(scan, parse)
369
-
441
+ """Uses cached graph pipeline runs only on first call or after file changes."""
442
+ scan, repo_graph = _get_graph(repo_path)
370
443
  vault_path = export_obsidian_vault(repo_graph, output_dir)
371
-
444
+
372
445
  return {
373
446
  "repo": scan.root,
374
447
  "vault_path": vault_path,