ctxeng 0.1.0__py3-none-any.whl

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.
ctxeng/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ ctxeng — Python Context Engineering Library
3
+ Automatically build, compress, and inject perfect LLM context from your codebase.
4
+ """
5
+
6
+ from ctxeng.builder import ContextBuilder
7
+ from ctxeng.core import ContextEngine
8
+ from ctxeng.models import Context, ContextFile, TokenBudget
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["ContextEngine", "ContextBuilder", "Context", "ContextFile", "TokenBudget"]
ctxeng/builder.py ADDED
@@ -0,0 +1,115 @@
1
+ """Fluent builder API for constructing context step by step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ctxeng.core import ContextEngine
8
+ from ctxeng.models import Context, TokenBudget
9
+
10
+
11
+ class ContextBuilder:
12
+ """
13
+ Fluent, chainable API for building context.
14
+
15
+ Example::
16
+
17
+ from ctxeng import ContextBuilder
18
+
19
+ ctx = (
20
+ ContextBuilder(root=".")
21
+ .for_model("gpt-4o")
22
+ .only("**/*.py")
23
+ .exclude("tests/**")
24
+ .from_git_diff()
25
+ .with_system("You are a senior Python engineer.")
26
+ .build("Refactor the payment module to use async/await")
27
+ )
28
+ print(ctx.to_string("markdown"))
29
+ """
30
+
31
+ def __init__(self, root: str | Path = ".") -> None:
32
+ self._root = Path(root).resolve()
33
+ self._model = "claude-sonnet-4"
34
+ self._budget: TokenBudget | None = None
35
+ self._include: list[str] = []
36
+ self._exclude: list[str] = []
37
+ self._explicit_files: list[Path] = []
38
+ self._use_git_diff = False
39
+ self._git_base = "HEAD"
40
+ self._system = ""
41
+ self._max_file_size_kb = 500
42
+ self._use_git = True
43
+
44
+ def for_model(self, model: str) -> ContextBuilder:
45
+ """Set the target model (determines token budget)."""
46
+ self._model = model
47
+ return self
48
+
49
+ def with_budget(self, total: int, reserved_output: int = 2048) -> ContextBuilder:
50
+ """Set an explicit token budget."""
51
+ self._budget = TokenBudget(total=total, reserved_output=reserved_output)
52
+ return self
53
+
54
+ def only(self, *patterns: str) -> ContextBuilder:
55
+ """Only include files matching these glob patterns."""
56
+ self._include.extend(patterns)
57
+ return self
58
+
59
+ def exclude(self, *patterns: str) -> ContextBuilder:
60
+ """Exclude files matching these glob patterns."""
61
+ self._exclude.extend(patterns)
62
+ return self
63
+
64
+ def include_files(self, *paths: str | Path) -> ContextBuilder:
65
+ """Explicitly include specific files (bypasses auto-discovery)."""
66
+ self._explicit_files.extend(Path(p) for p in paths)
67
+ return self
68
+
69
+ def from_git_diff(self, base: str = "HEAD") -> ContextBuilder:
70
+ """Only include files changed since `base`."""
71
+ self._use_git_diff = True
72
+ self._git_base = base
73
+ return self
74
+
75
+ def with_system(self, prompt: str) -> ContextBuilder:
76
+ """Set a system prompt (counts against token budget)."""
77
+ self._system = prompt
78
+ return self
79
+
80
+ def max_file_size(self, kb: int) -> ContextBuilder:
81
+ """Skip files larger than this size in KB."""
82
+ self._max_file_size_kb = kb
83
+ return self
84
+
85
+ def no_git(self) -> ContextBuilder:
86
+ """Disable git-based recency scoring."""
87
+ self._use_git = False
88
+ return self
89
+
90
+ def build(self, query: str = "") -> Context:
91
+ """
92
+ Build and return the optimized Context.
93
+
94
+ Args:
95
+ query: What you want the LLM to do. Be specific for best results.
96
+
97
+ Returns:
98
+ :class:`~ctxeng.models.Context`
99
+ """
100
+ engine = ContextEngine(
101
+ root=self._root,
102
+ model=self._model,
103
+ budget=self._budget,
104
+ max_file_size_kb=self._max_file_size_kb,
105
+ include_patterns=self._include or None,
106
+ exclude_patterns=self._exclude or None,
107
+ use_git=self._use_git,
108
+ )
109
+ return engine.build(
110
+ query=query,
111
+ files=self._explicit_files or None,
112
+ git_diff=self._use_git_diff,
113
+ git_base=self._git_base,
114
+ system_prompt=self._system,
115
+ )
ctxeng/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ """
2
+ ctxeng CLI — build and inspect context from the command line.
3
+
4
+ Usage:
5
+ ctxeng build "Fix the auth bug"
6
+ ctxeng build "Refactor payment module" --model gpt-4o --fmt markdown
7
+ ctxeng build "Review my changes" --git-diff
8
+ ctxeng build "Explain this file" --files src/core.py src/models.py
9
+ ctxeng info
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ def cmd_build(args: argparse.Namespace) -> None:
20
+ from ctxeng import ContextBuilder
21
+
22
+ builder = (
23
+ ContextBuilder(root=args.root)
24
+ .for_model(args.model)
25
+ .max_file_size(args.max_size)
26
+ )
27
+
28
+ if args.only:
29
+ builder = builder.only(*args.only)
30
+ if args.exclude:
31
+ builder = builder.exclude(*args.exclude)
32
+ if args.files:
33
+ builder = builder.include_files(*args.files)
34
+ if args.git_diff:
35
+ builder = builder.from_git_diff(args.git_base)
36
+ if args.system:
37
+ builder = builder.with_system(args.system)
38
+ if args.no_git:
39
+ builder = builder.no_git()
40
+ if args.budget:
41
+ builder = builder.with_budget(args.budget)
42
+
43
+ query = " ".join(args.query) if args.query else ""
44
+
45
+ print(f"Building context for: {query!r}", file=sys.stderr)
46
+ print(f"Root: {Path(args.root).resolve()}", file=sys.stderr)
47
+ print(f"Model: {args.model}", file=sys.stderr)
48
+ print("", file=sys.stderr)
49
+
50
+ ctx = builder.build(query)
51
+
52
+ print(ctx.summary(), file=sys.stderr)
53
+ print("", file=sys.stderr)
54
+ print("─" * 60, file=sys.stderr)
55
+ print("Context output:", file=sys.stderr)
56
+ print("─" * 60, file=sys.stderr)
57
+
58
+ if args.output:
59
+ out_path = Path(args.output)
60
+ out_path.write_text(ctx.to_string(args.fmt), encoding="utf-8")
61
+ print(f"Written to: {out_path}", file=sys.stderr)
62
+ else:
63
+ print(ctx.to_string(args.fmt))
64
+
65
+
66
+ def cmd_info(args: argparse.Namespace) -> None:
67
+ import subprocess
68
+
69
+ from ctxeng.optimizer import count_tokens, detect_language
70
+ from ctxeng.sources import collect_filesystem
71
+
72
+ root = Path(args.root).resolve()
73
+ print(f"Project: {root}")
74
+
75
+ # Git info
76
+ try:
77
+ r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
78
+ cwd=root, capture_output=True, text=True, timeout=2)
79
+ if r.returncode == 0:
80
+ print(f"Branch: {r.stdout.strip()}")
81
+ r2 = subprocess.run(["git", "log", "-1", "--format=%h %s"],
82
+ cwd=root, capture_output=True, text=True, timeout=2)
83
+ if r2.returncode == 0:
84
+ print(f"Commit: {r2.stdout.strip()}")
85
+ except Exception:
86
+ print("Git: not available")
87
+
88
+ print()
89
+ files = list(collect_filesystem(root))
90
+ lang_counts: dict[str, int] = {}
91
+ total_tokens = 0
92
+ for path, content in files:
93
+ lang = detect_language(path) or "other"
94
+ lang_counts[lang] = lang_counts.get(lang, 0) + 1
95
+ total_tokens += count_tokens(content)
96
+
97
+ print(f"Files found: {len(files)}")
98
+ print(f"Estimated total tokens: {total_tokens:,}")
99
+ print()
100
+ print("By language:")
101
+ for lang, count in sorted(lang_counts.items(), key=lambda x: -x[1])[:10]:
102
+ bar = "█" * min(20, count)
103
+ print(f" {lang:<15} {bar} {count}")
104
+
105
+
106
+ def main() -> None:
107
+ parser = argparse.ArgumentParser(
108
+ prog="ctxeng",
109
+ description="Build perfect LLM context from your codebase.",
110
+ )
111
+ parser.add_argument("--root", default=".", help="Project root (default: cwd)")
112
+
113
+ sub = parser.add_subparsers(dest="command", required=True)
114
+
115
+ # --- build ---
116
+ build_p = sub.add_parser("build", help="Build context for a query")
117
+ build_p.add_argument("query", nargs="*", help="What you want the LLM to do")
118
+ build_p.add_argument("--model", "-m", default="claude-sonnet-4",
119
+ help="Target model (default: claude-sonnet-4)")
120
+ build_p.add_argument("--fmt", "-f", default="xml",
121
+ choices=["xml", "markdown", "plain"],
122
+ help="Output format (default: xml)")
123
+ build_p.add_argument("--output", "-o", help="Write output to file instead of stdout")
124
+ build_p.add_argument("--only", nargs="+", metavar="PATTERN",
125
+ help='Include only matching globs, e.g. "**/*.py"')
126
+ build_p.add_argument("--exclude", nargs="+", metavar="PATTERN",
127
+ help="Exclude matching globs")
128
+ build_p.add_argument("--files", nargs="+", metavar="FILE",
129
+ help="Explicit list of files to include")
130
+ build_p.add_argument("--git-diff", action="store_true",
131
+ help="Only include git-changed files")
132
+ build_p.add_argument("--git-base", default="HEAD",
133
+ help="Git base ref for diff (default: HEAD)")
134
+ build_p.add_argument("--system", help="System prompt text")
135
+ build_p.add_argument("--no-git", action="store_true",
136
+ help="Disable git recency scoring")
137
+ build_p.add_argument("--budget", type=int,
138
+ help="Override token budget total")
139
+ build_p.add_argument("--max-size", type=int, default=500,
140
+ help="Max file size in KB (default: 500)")
141
+ build_p.set_defaults(func=cmd_build)
142
+
143
+ # --- info ---
144
+ info_p = sub.add_parser("info", help="Show project info and file stats")
145
+ info_p.set_defaults(func=cmd_info)
146
+
147
+ args = parser.parse_args()
148
+ args.func(args)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
ctxeng/core.py ADDED
@@ -0,0 +1,156 @@
1
+ """Main ContextEngine — the primary public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ctxeng.models import Context, ContextFile, TokenBudget
8
+ from ctxeng.optimizer import count_tokens, detect_language, optimize_budget
9
+ from ctxeng.scorer import rank_files
10
+ from ctxeng.sources import collect_explicit, collect_filesystem, collect_git_changed
11
+
12
+
13
+ class ContextEngine:
14
+ """
15
+ Build perfect LLM context from your codebase.
16
+
17
+ Example::
18
+
19
+ from ctxeng import ContextEngine
20
+
21
+ engine = ContextEngine(root=".", model="claude-sonnet-4")
22
+ ctx = engine.build("Fix the authentication bug in the login flow")
23
+ print(ctx.summary())
24
+ # → paste ctx.to_string() into your LLM
25
+
26
+ Args:
27
+ root: Root directory of your project (default: cwd).
28
+ model: Target model name — used to set token budget.
29
+ budget: Explicit TokenBudget, overrides `model`.
30
+ max_file_size_kb: Skip files larger than this (default: 500 KB).
31
+ include_patterns: Only include files matching these glob patterns.
32
+ exclude_patterns: Skip files matching these glob patterns.
33
+ use_git: Score files using git recency signal (default: True).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ root: str | Path = ".",
39
+ model: str = "claude-sonnet-4",
40
+ budget: TokenBudget | None = None,
41
+ max_file_size_kb: int = 500,
42
+ include_patterns: list[str] | None = None,
43
+ exclude_patterns: list[str] | None = None,
44
+ use_git: bool = True,
45
+ ) -> None:
46
+ self.root = Path(root).resolve()
47
+ self.model = model
48
+ self.budget = budget or TokenBudget.for_model(model)
49
+ self.max_file_size_kb = max_file_size_kb
50
+ self.include_patterns = include_patterns
51
+ self.exclude_patterns = exclude_patterns
52
+ self.use_git = use_git
53
+
54
+ def build(
55
+ self,
56
+ query: str = "",
57
+ *,
58
+ files: list[str | Path] | None = None,
59
+ git_diff: bool = False,
60
+ git_base: str = "HEAD",
61
+ system_prompt: str = "",
62
+ fmt: str = "xml",
63
+ ) -> Context:
64
+ """
65
+ Build and return a Context object optimized for your query.
66
+
67
+ Args:
68
+ query: What you're asking the LLM to do. More specific = better scoring.
69
+ files: Explicit list of files to include (bypasses auto-discovery).
70
+ git_diff: Only include files changed since `git_base`.
71
+ git_base: Git ref for diff base (default: HEAD).
72
+ system_prompt: Optional system prompt (counts against token budget).
73
+ fmt: Output format hint, passed through to Context.
74
+
75
+ Returns:
76
+ A :class:`Context` object. Call ``.to_string()`` to get the LLM-ready string,
77
+ or ``.summary()`` to see what was included and why.
78
+ """
79
+ # 1. Collect raw files
80
+ raw: list[tuple[Path, str]] = []
81
+
82
+ if files:
83
+ explicit_paths = [Path(f) for f in files]
84
+ raw.extend(collect_explicit(explicit_paths, self.root))
85
+ elif git_diff:
86
+ raw.extend(collect_git_changed(self.root, base=git_base))
87
+ else:
88
+ raw.extend(
89
+ collect_filesystem(
90
+ self.root,
91
+ max_file_size_kb=self.max_file_size_kb,
92
+ include_patterns=self.include_patterns,
93
+ exclude_patterns=self.exclude_patterns,
94
+ )
95
+ )
96
+
97
+ # 2. Score and rank
98
+ ranked = rank_files(raw, query, self.root)
99
+
100
+ # 3. Build ContextFile objects
101
+ context_files = [
102
+ ContextFile(
103
+ path=path,
104
+ content=content,
105
+ relevance_score=score,
106
+ language=detect_language(path),
107
+ )
108
+ for path, content, score in ranked
109
+ ]
110
+
111
+ # 4. Optimize for token budget
112
+ query_tokens = count_tokens(query, self.model) if query else 0
113
+ system_tokens = count_tokens(system_prompt, self.model) if system_prompt else 0
114
+
115
+ included, skipped = optimize_budget(
116
+ context_files,
117
+ self.budget,
118
+ query_tokens=query_tokens,
119
+ system_tokens=system_tokens,
120
+ model=self.model,
121
+ )
122
+
123
+ total_tokens = sum(f.token_count for f in included) + query_tokens + system_tokens
124
+
125
+ # 5. Gather metadata
126
+ metadata = self._gather_metadata()
127
+
128
+ return Context(
129
+ files=included,
130
+ system_prompt=system_prompt,
131
+ query=query,
132
+ total_tokens=total_tokens,
133
+ budget=self.budget,
134
+ skipped_files=skipped,
135
+ metadata=metadata,
136
+ )
137
+
138
+ def _gather_metadata(self) -> dict:
139
+ meta: dict = {"project_root": str(self.root)}
140
+ try:
141
+ import subprocess
142
+ r = subprocess.run(
143
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
144
+ cwd=self.root, capture_output=True, text=True, timeout=2
145
+ )
146
+ if r.returncode == 0:
147
+ meta["git_branch"] = r.stdout.strip()
148
+ r2 = subprocess.run(
149
+ ["git", "log", "-1", "--format=%h %s"],
150
+ cwd=self.root, capture_output=True, text=True, timeout=2
151
+ )
152
+ if r2.returncode == 0:
153
+ meta["last_commit"] = r2.stdout.strip()
154
+ except Exception:
155
+ pass
156
+ return meta
@@ -0,0 +1,145 @@
1
+ """
2
+ Drop-in integrations for popular LLM clients.
3
+
4
+ These helpers take a Context object and call the LLM directly,
5
+ so you never have to manually paste context again.
6
+
7
+ Example (Claude)::
8
+
9
+ from ctxeng import ContextEngine
10
+ from ctxeng.integrations import ask_claude
11
+
12
+ engine = ContextEngine(".", model="claude-sonnet-4")
13
+ ctx = engine.build("Why is the login test failing?")
14
+ response = ask_claude(ctx)
15
+ print(response)
16
+
17
+ Example (OpenAI)::
18
+
19
+ from ctxeng import ContextEngine
20
+ from ctxeng.integrations import ask_openai
21
+
22
+ engine = ContextEngine(".", model="gpt-4o")
23
+ ctx = engine.build("Refactor this to be async")
24
+ response = ask_openai(ctx)
25
+ print(response)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING
31
+
32
+ if TYPE_CHECKING:
33
+ from ctxeng.models import Context
34
+
35
+
36
+ def ask_claude(
37
+ ctx: Context,
38
+ *,
39
+ api_key: str | None = None,
40
+ model: str | None = None,
41
+ max_tokens: int = 4096,
42
+ fmt: str = "xml",
43
+ ) -> str:
44
+ """
45
+ Send a Context to Anthropic's Claude API and return the response text.
46
+
47
+ Requires: ``pip install anthropic``
48
+
49
+ Args:
50
+ ctx: The context built by ContextEngine or ContextBuilder.
51
+ api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
52
+ model: Model to use. Defaults to claude-sonnet-4-20250514.
53
+ max_tokens: Max response tokens (default 4096).
54
+ fmt: Context rendering format ('xml' recommended for Claude).
55
+
56
+ Returns:
57
+ The assistant's response as a string.
58
+ """
59
+ try:
60
+ import anthropic
61
+ except ImportError:
62
+ raise ImportError(
63
+ "anthropic package required: pip install anthropic"
64
+ ) from None
65
+
66
+ client = anthropic.Anthropic(api_key=api_key) if api_key else anthropic.Anthropic()
67
+ model = model or "claude-sonnet-4-20250514"
68
+
69
+ messages = [{"role": "user", "content": ctx.to_string(fmt)}]
70
+
71
+ kwargs: dict = dict(model=model, max_tokens=max_tokens, messages=messages)
72
+ if ctx.system_prompt:
73
+ kwargs["system"] = ctx.system_prompt
74
+
75
+ response = client.messages.create(**kwargs)
76
+ return response.content[0].text
77
+
78
+
79
+ def ask_openai(
80
+ ctx: Context,
81
+ *,
82
+ api_key: str | None = None,
83
+ model: str | None = None,
84
+ max_tokens: int = 4096,
85
+ fmt: str = "markdown",
86
+ ) -> str:
87
+ """
88
+ Send a Context to the OpenAI API and return the response text.
89
+
90
+ Requires: ``pip install openai``
91
+
92
+ Args:
93
+ ctx: The context built by ContextEngine or ContextBuilder.
94
+ api_key: OpenAI API key. Falls back to OPENAI_API_KEY env var.
95
+ model: Model to use. Defaults to gpt-4o.
96
+ max_tokens: Max response tokens (default 4096).
97
+ fmt: Context rendering format ('markdown' recommended for OpenAI).
98
+
99
+ Returns:
100
+ The assistant's response as a string.
101
+ """
102
+ try:
103
+ from openai import OpenAI
104
+ except ImportError:
105
+ raise ImportError(
106
+ "openai package required: pip install openai"
107
+ ) from None
108
+
109
+ client = OpenAI(api_key=api_key) if api_key else OpenAI()
110
+ model = model or "gpt-4o"
111
+
112
+ messages = []
113
+ if ctx.system_prompt:
114
+ messages.append({"role": "system", "content": ctx.system_prompt})
115
+ messages.append({"role": "user", "content": ctx.to_string(fmt)})
116
+
117
+ response = client.chat.completions.create(
118
+ model=model,
119
+ max_tokens=max_tokens,
120
+ messages=messages,
121
+ )
122
+ return response.choices[0].message.content or ""
123
+
124
+
125
+ def to_langchain_messages(ctx: Context, fmt: str = "xml") -> list:
126
+ """
127
+ Convert a Context to a list of LangChain message objects.
128
+
129
+ Requires: ``pip install langchain-core``
130
+
131
+ Returns:
132
+ List of [SystemMessage?, HumanMessage] suitable for any LangChain chat model.
133
+ """
134
+ try:
135
+ from langchain_core.messages import HumanMessage, SystemMessage
136
+ except ImportError:
137
+ raise ImportError(
138
+ "langchain-core required: pip install langchain-core"
139
+ ) from None
140
+
141
+ messages = []
142
+ if ctx.system_prompt:
143
+ messages.append(SystemMessage(content=ctx.system_prompt))
144
+ messages.append(HumanMessage(content=ctx.to_string(fmt)))
145
+ return messages