Skip to content

Commit d482d25

Browse files
committed
feat: add ctxeng watch mode
Made-with: Cursor
1 parent c0ba8a3 commit d482d25

7 files changed

Lines changed: 349 additions & 9 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,24 @@ ctxeng build "Explain the payment flow" --output context.md
138138
ctxeng info
139139
```
140140

141+
### Watch mode
142+
143+
Automatically rebuild context when files change (requires `watchdog`):
144+
145+
```bash
146+
pip install "ctxeng[watch]"
147+
ctxeng watch "Fix the auth bug" --output context.md
148+
```
149+
150+
Example output:
151+
152+
```text
153+
[14:32:01] File changed: src/auth/login.py
154+
[14:32:01] Rebuilding context...
155+
[14:32:01] Done. 8 files, 12,340 tokens, ~$0.037
156+
[14:32:01] Written to: context.md
157+
```
158+
141159
### `.ctxengignore`
142160

143161
Add a **`.ctxengignore`** file at your project root to exclude paths from filesystem discovery (same syntax as **`.gitignore`**). It is applied automatically when you run `ctxeng build`, `ctxeng info`, or `ContextEngine` / `ContextBuilder` without explicit `--files` / `include_files`.
@@ -436,6 +454,9 @@ build options:
436454
Import hops when import graph is on (default: 1)
437455
--show-cost / --no-show-cost
438456
Include estimated input cost in stderr summary (default: on)
457+
458+
watch options:
459+
--interval S Polling interval in seconds (default: 1.0)
439460
```
440461

441462
---

ctxeng/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ctxeng.ignore import parse_ctxengignore
1010
from ctxeng.import_graph import build_import_graph, expand_with_imports
1111
from ctxeng.models import Context, ContextFile, TokenBudget
12+
from ctxeng.watcher import ContextWatcher, WatchConfig
1213

1314
__version__ = "0.1.3"
1415
__all__ = [
@@ -23,4 +24,6 @@
2324
"COST_PER_1K_INPUT_TOKENS",
2425
"estimate_cost",
2526
"matched_pricing_model",
27+
"ContextWatcher",
28+
"WatchConfig",
2629
]

ctxeng/builder.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,18 @@ def build(self, query: str = "") -> Context:
110110
Returns:
111111
:class:`~ctxeng.models.Context`
112112
"""
113-
engine = ContextEngine(
113+
engine = self._build_engine()
114+
return engine.build(
115+
query=query,
116+
files=self._explicit_files or None,
117+
git_diff=self._use_git_diff,
118+
git_base=self._git_base,
119+
system_prompt=self._system,
120+
)
121+
122+
def _build_engine(self) -> ContextEngine:
123+
"""Internal helper for CLI/watch integrations."""
124+
return ContextEngine(
114125
root=self._root,
115126
model=self._model,
116127
budget=self._budget,
@@ -121,10 +132,3 @@ def build(self, query: str = "") -> Context:
121132
use_import_graph=self._use_import_graph,
122133
import_graph_depth=self._import_graph_depth,
123134
)
124-
return engine.build(
125-
query=query,
126-
files=self._explicit_files or None,
127-
git_diff=self._use_git_diff,
128-
git_base=self._git_base,
129-
system_prompt=self._system,
130-
)

ctxeng/cli.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,46 @@ def cmd_info(args: argparse.Namespace) -> None:
107107
print(f" {lang:<15} {bar} {count}")
108108

109109

110+
def cmd_watch(args: argparse.Namespace) -> None:
111+
from ctxeng import ContextBuilder
112+
from ctxeng.watcher import ContextWatcher, WatchConfig
113+
114+
builder = (
115+
ContextBuilder(root=args.root)
116+
.for_model(args.model)
117+
.max_file_size(args.max_size)
118+
)
119+
120+
if args.only:
121+
builder = builder.only(*args.only)
122+
if args.exclude:
123+
builder = builder.exclude(*args.exclude)
124+
if args.files:
125+
builder = builder.include_files(*args.files)
126+
if args.system:
127+
builder = builder.with_system(args.system)
128+
if args.no_git:
129+
builder = builder.no_git()
130+
if not args.import_graph:
131+
builder = builder.no_import_graph()
132+
else:
133+
builder = builder.use_import_graph(depth=args.import_graph_depth)
134+
if args.budget:
135+
builder = builder.with_budget(args.budget)
136+
137+
query = " ".join(args.query) if args.query else ""
138+
139+
engine = builder._build_engine()
140+
watcher = ContextWatcher(
141+
query,
142+
engine=engine,
143+
output_file=args.output,
144+
fmt=args.fmt,
145+
config=WatchConfig(interval_seconds=args.interval),
146+
)
147+
watcher.run()
148+
149+
110150
def main() -> None:
111151
parser = argparse.ArgumentParser(
112152
prog="ctxeng",
@@ -167,6 +207,50 @@ def main() -> None:
167207
info_p = sub.add_parser("info", help="Show project info and file stats")
168208
info_p.set_defaults(func=cmd_info)
169209

210+
# --- watch ---
211+
watch_p = sub.add_parser("watch", help="Watch files and auto-rebuild context")
212+
watch_p.add_argument("query", nargs="*", help="What you want the LLM to do")
213+
watch_p.add_argument("--model", "-m", default="claude-sonnet-4",
214+
help="Target model (default: claude-sonnet-4)")
215+
watch_p.add_argument("--fmt", "-f", default="xml",
216+
choices=["xml", "markdown", "plain"],
217+
help="Output format (default: xml)")
218+
watch_p.add_argument("--output", "-o", help="Write output to file after each rebuild")
219+
watch_p.add_argument("--only", nargs="+", metavar="PATTERN",
220+
help='Include only matching globs, e.g. "**/*.py"')
221+
watch_p.add_argument("--exclude", nargs="+", metavar="PATTERN",
222+
help="Exclude matching globs")
223+
watch_p.add_argument("--files", nargs="+", metavar="FILE",
224+
help="Explicit list of files to include")
225+
watch_p.add_argument("--system", help="System prompt text")
226+
watch_p.add_argument("--no-git", action="store_true",
227+
help="Disable git recency scoring")
228+
watch_p.add_argument("--budget", type=int,
229+
help="Override token budget total")
230+
watch_p.add_argument("--max-size", type=int, default=500,
231+
help="Max file size in KB (default: 500)")
232+
watch_p.add_argument(
233+
"--import-graph",
234+
action=argparse.BooleanOptionalAction,
235+
default=True,
236+
help="Expand context using local Python import graph (default: on)",
237+
)
238+
watch_p.add_argument(
239+
"--import-graph-depth",
240+
type=int,
241+
default=1,
242+
metavar="N",
243+
help="Import hops when --import-graph is on (default: 1)",
244+
)
245+
watch_p.add_argument(
246+
"--interval",
247+
type=float,
248+
default=1.0,
249+
metavar="S",
250+
help="Polling interval in seconds (default: 1.0)",
251+
)
252+
watch_p.set_defaults(func=cmd_watch)
253+
170254
args = parser.parse_args()
171255
args.func(args)
172256

ctxeng/watcher.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Filesystem watcher that rebuilds ctxeng context on changes."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
import time
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from typing import Callable
10+
11+
from ctxeng.models import Context
12+
13+
14+
@dataclass(frozen=True)
15+
class WatchConfig:
16+
"""Configuration for watching and rebuilding context."""
17+
18+
debounce_seconds: float = 0.5
19+
interval_seconds: float = 1.0
20+
21+
22+
class ContextWatcher:
23+
"""
24+
Watch a project for file changes and rebuild context automatically.
25+
26+
This is an optional feature that requires the ``watchdog`` dependency:
27+
28+
pip install "ctxeng[watch]"
29+
30+
Args:
31+
query: Query to build context for.
32+
engine: A configured :class:`~ctxeng.core.ContextEngine` instance.
33+
output_file: If set, write context output to this file on rebuild.
34+
callback: If set, called with the freshly built :class:`~ctxeng.models.Context`.
35+
fmt: Output format passed to ``Context.to_string()`` when writing to file.
36+
config: Watch timing configuration (debounce + polling interval).
37+
"""
38+
39+
def __init__(
40+
self,
41+
query: str,
42+
*,
43+
engine,
44+
output_file: str | Path | None = None,
45+
callback: Callable[[Context], None] | None = None,
46+
fmt: str = "xml",
47+
config: WatchConfig | None = None,
48+
) -> None:
49+
self.query = query
50+
self.engine = engine
51+
self.root = Path(engine.root).resolve()
52+
self.output_file = Path(output_file) if output_file else None
53+
self.callback = callback
54+
self.fmt = fmt
55+
self.config = config or WatchConfig()
56+
57+
self._changed: set[Path] = set()
58+
self._lock = threading.Lock()
59+
self._timer: threading.Timer | None = None
60+
61+
def run(self) -> None:
62+
"""
63+
Block forever, rebuilding context when files change.
64+
65+
Gracefully exits on Ctrl+C.
66+
"""
67+
try:
68+
from watchdog.events import FileSystemEventHandler
69+
from watchdog.observers.polling import PollingObserver
70+
except ImportError as e: # pragma: no cover
71+
raise ImportError(
72+
"watch mode requires watchdog. Install with: pip install \"ctxeng[watch]\""
73+
) from e
74+
75+
watcher = self
76+
77+
class Handler(FileSystemEventHandler):
78+
def on_any_event(self, event) -> None: # type: ignore[override]
79+
# event has .is_directory and .src_path; moved events also have .dest_path
80+
if getattr(event, "is_directory", False):
81+
return
82+
src = getattr(event, "src_path", None)
83+
if src:
84+
watcher.notify_change(Path(src))
85+
dest = getattr(event, "dest_path", None)
86+
if dest:
87+
watcher.notify_change(Path(dest))
88+
89+
obs = PollingObserver(timeout=self.config.interval_seconds)
90+
obs.schedule(Handler(), str(self.root), recursive=True)
91+
obs.start()
92+
93+
try:
94+
while True:
95+
time.sleep(0.2)
96+
except KeyboardInterrupt:
97+
pass
98+
finally:
99+
obs.stop()
100+
obs.join(timeout=5)
101+
102+
def notify_change(self, abs_path: Path) -> None:
103+
"""Record a changed path and schedule a debounced rebuild."""
104+
rel = self._to_rel(abs_path)
105+
ts = self._timestamp()
106+
print(f"[{ts}] File changed: {rel.as_posix()}")
107+
with self._lock:
108+
self._changed.add(rel)
109+
if self._timer is not None:
110+
self._timer.cancel()
111+
self._timer = threading.Timer(self.config.debounce_seconds, self._rebuild)
112+
self._timer.daemon = True
113+
self._timer.start()
114+
115+
def _rebuild(self) -> None:
116+
changed: list[Path]
117+
with self._lock:
118+
changed = sorted(self._changed)
119+
self._changed.clear()
120+
self._timer = None
121+
122+
ts = self._timestamp()
123+
print(f"[{ts}] Rebuilding context...")
124+
ctx = self.engine.build(self.query, fmt=self.fmt)
125+
126+
done_ts = self._timestamp()
127+
cost = f", ~${ctx.cost_estimate:.3f}" if ctx.cost_estimate is not None else ""
128+
print(f"[{done_ts}] Done. {len(ctx.files)} files, {ctx.total_tokens:,} tokens{cost}")
129+
130+
if self.output_file:
131+
out = self.output_file
132+
out.write_text(ctx.to_string(self.fmt), encoding="utf-8")
133+
print(f"[{self._timestamp()}] Written to: {out}")
134+
135+
if self.callback:
136+
self.callback(ctx)
137+
138+
# changed is currently only printed as individual lines on notify; keep for future hooks
139+
_ = changed
140+
141+
def _to_rel(self, abs_path: Path) -> Path:
142+
try:
143+
return abs_path.resolve().relative_to(self.root)
144+
except Exception:
145+
return abs_path
146+
147+
@staticmethod
148+
def _timestamp() -> str:
149+
return time.strftime("%H:%M:%S", time.localtime())
150+

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ description = "Build perfect LLM context from your Python codebase — automatic
99
readme = "README.md"
1010
license = { text = "MIT" }
1111
requires-python = ">=3.10"
12-
authors = [{ name = "ctxeng contributors" }]
12+
authors = [
13+
{ name = "Abkari Mohammed Sayeem", email = "saeemabkari6@gmail.com" },
14+
]
15+
maintainers = [
16+
{ name = "Abkari Mohammed Sayeem", email = "saeemabkari6@gmail.com" },
17+
]
1318
keywords = [
1419
"llm", "context", "context-engineering", "claude", "openai",
1520
"gpt", "token", "ai", "developer-tools", "codebase",

0 commit comments

Comments
 (0)