bharatcode 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.
bharatcode/cost.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ Session cost tracker — inspired by Claude Code's /cost command.
3
+ Tracks tokens and calculates DeepSeek API cost per session.
4
+ """
5
+ from dataclasses import dataclass, field
6
+
7
+ # DeepSeek pricing (USD per 1M tokens) — updated for v4 models
8
+ PRICING = {
9
+ "deepseek-v4-flash": {
10
+ "input": 0.27,
11
+ "input_cache": 0.07,
12
+ "output": 1.10,
13
+ },
14
+ "deepseek-v4-pro": {
15
+ "input": 0.55,
16
+ "input_cache": 0.14,
17
+ "output": 2.19,
18
+ },
19
+ }
20
+ # Keep old names working for anyone who has them cached in session_cost.model
21
+ PRICING["deepseek-chat"] = PRICING["deepseek-v4-flash"]
22
+ PRICING["deepseek-reasoner"] = PRICING["deepseek-v4-pro"]
23
+
24
+ _DEFAULT_MODEL = "deepseek-v4-flash"
25
+
26
+ @dataclass
27
+ class SessionCost:
28
+ model: str = "deepseek-v4-flash"
29
+ prompt_tokens: int = 0
30
+ output_tokens: int = 0
31
+ turns: int = 0
32
+ tool_calls: int = 0
33
+
34
+ def add(self, prompt: int, output: int):
35
+ self.prompt_tokens += prompt
36
+ self.output_tokens += output
37
+ self.turns += 1
38
+
39
+ def add_tool(self):
40
+ self.tool_calls += 1
41
+
42
+ @property
43
+ def total_tokens(self) -> int:
44
+ return self.prompt_tokens + self.output_tokens
45
+
46
+ @property
47
+ def cost_usd(self) -> float:
48
+ p = PRICING.get(self.model, PRICING[_DEFAULT_MODEL])
49
+ in_cost = (self.prompt_tokens / 1_000_000) * p["input"]
50
+ out_cost = (self.output_tokens / 1_000_000) * p["output"]
51
+ return in_cost + out_cost
52
+
53
+ def display(self, console):
54
+ from rich.table import Table
55
+ from rich import box
56
+
57
+ price = PRICING.get(self.model, PRICING[_DEFAULT_MODEL])
58
+ t = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
59
+ t.add_column("key", style="dim", no_wrap=True)
60
+ t.add_column("value", style="cyan", no_wrap=True)
61
+
62
+ from .config import model_label
63
+ t.add_row("Model", model_label(self.model))
64
+ t.add_row("Turns", str(self.turns))
65
+ t.add_row("Tool calls", str(self.tool_calls))
66
+ t.add_row("Input tokens", f"{self.prompt_tokens:,}")
67
+ t.add_row("Output tokens", f"{self.output_tokens:,}")
68
+ t.add_row("Total tokens", f"{self.total_tokens:,}")
69
+ t.add_row("Est. cost", f"${self.cost_usd:.4f} USD")
70
+ t.add_row("Input price", f"${price['input']}/1M tokens")
71
+ t.add_row("Output price", f"${price['output']}/1M tokens")
72
+
73
+ console.print("\n[bold]Session Cost[/bold]")
74
+ console.print(t)
75
+
76
+ # Global session tracker
77
+ session_cost = SessionCost()
bharatcode/diff.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Diff display — inspired by Claude Code's StructuredDiff + FileEditToolDiff components.
3
+ Shows colored unified diffs when files are written or edited.
4
+ """
5
+ import difflib
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+ from rich.text import Text
9
+ from rich.panel import Panel
10
+
11
+ console = Console()
12
+
13
+ def show_file_diff(path: str, old_content: str, new_content: str):
14
+ """Show a colored unified diff between old and new file content."""
15
+ old_lines = old_content.splitlines(keepends=True)
16
+ new_lines = new_content.splitlines(keepends=True)
17
+
18
+ diff = list(difflib.unified_diff(
19
+ old_lines, new_lines,
20
+ fromfile=f"a/{path}",
21
+ tofile=f"b/{path}",
22
+ lineterm="",
23
+ ))
24
+
25
+ if not diff:
26
+ return
27
+
28
+ rendered = Text()
29
+ for line in diff:
30
+ if line.startswith("+++") or line.startswith("---"):
31
+ rendered.append(line + "\n", style="bold white")
32
+ elif line.startswith("@@"):
33
+ rendered.append(line + "\n", style="cyan")
34
+ elif line.startswith("+"):
35
+ rendered.append(line + "\n", style="green")
36
+ elif line.startswith("-"):
37
+ rendered.append(line + "\n", style="red")
38
+ else:
39
+ rendered.append(line + "\n", style="dim")
40
+
41
+ added = sum(1 for l in diff if l.startswith("+") and not l.startswith("+++"))
42
+ removed = sum(1 for l in diff if l.startswith("-") and not l.startswith("---"))
43
+
44
+ title = (
45
+ f"[white]{path}[/white] "
46
+ f"[green]+{added}[/green] [red]-{removed}[/red]"
47
+ )
48
+ try:
49
+ console.print(Panel(rendered, title=title, border_style="dim", padding=(0, 1)))
50
+ except UnicodeEncodeError:
51
+ # Terminal can't render the characters (e.g. emoji on Windows cp1252).
52
+ # Show a plain summary and keep going — never stop the agent.
53
+ console.print(
54
+ f" [dim]{path} [green]+{added}[/green] [red]-{removed}[/red]"
55
+ f" (diff hidden — file contains Unicode/emoji)[/dim]"
56
+ )
57
+
58
+ def capture_write(path: str, new_content: str, mode: str = "w") -> str:
59
+ """Write file with diff display. Supports mode='w' (overwrite) or 'a' (append)."""
60
+ if not path or not path.strip():
61
+ return (
62
+ "Error: 'path' is empty. Provide the full file path. "
63
+ "For large files write in chunks using mode='a' to append."
64
+ )
65
+ if path.strip() in (".", "./", "/", "\\"):
66
+ return "Error: 'path' must be a file path like 'C:/chhelu 1/analysis/dashboard.html', not a directory."
67
+ p = Path(path)
68
+ if p.exists() and p.is_dir():
69
+ return f"Error: '{path}' is a directory. Provide a full file path including filename."
70
+
71
+ from .tools import _read_text_safe
72
+ old = _read_text_safe(p) if p.exists() else ""
73
+ p.parent.mkdir(parents=True, exist_ok=True)
74
+ write_mode = "a" if mode == "a" else "w"
75
+ with open(p, write_mode, encoding="utf-8") as f:
76
+ f.write(new_content)
77
+ full = _read_text_safe(p)
78
+ if old != full:
79
+ show_file_diff(path, old, full)
80
+ total_lines = len(full.splitlines())
81
+ action = "Appended to" if write_mode == "a" else "Written"
82
+ return f"{action} {path} ({total_lines} lines total)"
83
+
84
+ def capture_edit(path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
85
+ """Perform edit via tools.edit_file (CRLF-safe, with nearest-match hints
86
+ on failure) and show a colored diff on success."""
87
+ from .tools import edit_file as _edit_impl, _read_text_safe
88
+
89
+ if not path or not path.strip():
90
+ return (
91
+ "Error: 'path' is empty. Provide the full file path. "
92
+ "Example: edit_file(path='C:/chhelu 1/analysis/site3_report.html', old_string='...', new_string='...')"
93
+ )
94
+ p = Path(path)
95
+ old_content = None
96
+ if p.exists() and p.is_file():
97
+ try:
98
+ old_content = _read_text_safe(p)
99
+ except Exception:
100
+ old_content = None
101
+
102
+ result = _edit_impl(path, old_string, new_string, replace_all=replace_all)
103
+
104
+ if (old_content is not None
105
+ and not result.startswith("Error")
106
+ and not result.startswith("File not found")):
107
+ try:
108
+ new_content = _read_text_safe(p)
109
+ if new_content != old_content:
110
+ show_file_diff(path, old_content, new_content)
111
+ except Exception:
112
+ pass
113
+ return result
bharatcode/hooks.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Hook system — inspired by Claude Code's PreToolUse / PostToolUse hooks.
3
+ Hooks run before/after every tool call and can block, modify, or log.
4
+ """
5
+ from typing import Callable, Optional
6
+ from dataclasses import dataclass, field
7
+
8
+ @dataclass
9
+ class ToolCall:
10
+ name: str
11
+ args: dict
12
+
13
+ @dataclass
14
+ class HookResult:
15
+ proceed: bool = True # False = block the tool call
16
+ modified_args: dict = None # Optionally change the args
17
+ message: str = "" # Message to show user if blocked
18
+
19
+ PreToolHook = Callable[[ToolCall], HookResult]
20
+ PostToolHook = Callable[[ToolCall, str], None] # (tool_call, result)
21
+
22
+ class HookRegistry:
23
+ def __init__(self):
24
+ self._pre: list[PreToolHook] = []
25
+ self._post: list[PostToolHook] = []
26
+
27
+ def add_pre(self, hook: PreToolHook):
28
+ self._pre.append(hook)
29
+
30
+ def add_post(self, hook: PostToolHook):
31
+ self._post.append(hook)
32
+
33
+ def run_pre(self, call: ToolCall) -> HookResult:
34
+ for hook in self._pre:
35
+ result = hook(call)
36
+ if not result.proceed:
37
+ return result
38
+ if result.modified_args:
39
+ call.args = result.modified_args
40
+ return HookResult(proceed=True)
41
+
42
+ def run_post(self, call: ToolCall, result: str):
43
+ for hook in self._post:
44
+ hook(call, result)
45
+
46
+ # Global registry
47
+ hooks = HookRegistry()
48
+
49
+ # ── Built-in Hooks ────────────────────────────────────────────────────────────
50
+
51
+ def _safety_hook(call: ToolCall) -> HookResult:
52
+ """Block dangerous bash commands."""
53
+ if call.name == "bash":
54
+ cmd = call.args.get("command", "")
55
+ dangerous = ["rm -rf /", "format c:", "DROP TABLE", "sudo rm -rf"]
56
+ for d in dangerous:
57
+ if d in cmd:
58
+ return HookResult(proceed=False, message=f"Blocked dangerous command: {d}")
59
+ return HookResult(proceed=True)
60
+
61
+ def _log_hook(call: ToolCall) -> HookResult:
62
+ """Log all tool calls to ~/.bharatcode/tool_log.txt"""
63
+ import datetime
64
+ from pathlib import Path
65
+ log_file = Path.home() / ".bharatcode" / "tool_log.txt"
66
+ log_file.parent.mkdir(exist_ok=True)
67
+ with open(log_file, "a") as f:
68
+ ts = datetime.datetime.now().isoformat()
69
+ first_arg = list(call.args.values())[0] if call.args else ""
70
+ f.write(f"{ts} {call.name}({str(first_arg)[:60]})\n")
71
+ return HookResult(proceed=True)
72
+
73
+ # Register built-in hooks
74
+ hooks.add_pre(_safety_hook)
75
+ hooks.add_pre(_log_hook)
bharatcode/index.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ Project symbol index (Feature 5) — scans the project directory at session start
3
+ and returns a compact file/symbol map injected into the system prompt so the
4
+ agent knows what exists without cold-reading files just to discover structure.
5
+ """
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+
10
+ SKIP_DIRS = {
11
+ '__pycache__', 'node_modules', '.git', 'dist', 'build',
12
+ '.venv', 'venv', 'env', 'ENV', 'coverage', '.next',
13
+ 'out', 'target', 'vendor', 'bower_components', '.mypy_cache',
14
+ '.pytest_cache', '.tox', 'htmlcov', '.eggs', 'site-packages',
15
+ }
16
+ SKIP_EXTS = {
17
+ '.pyc', '.pyo', '.pyd', '.so', '.dll', '.exe', '.bin',
18
+ '.lock', '.whl', '.egg', '.png', '.jpg', '.jpeg', '.gif',
19
+ '.ico', '.svg', '.mp4', '.mp3', '.zip', '.tar', '.gz',
20
+ '.map', '.wasm', '.db', '.sqlite', '.sqlite3',
21
+ }
22
+ CODE_EXTS = {'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.go',
23
+ '.rs', '.rb', '.php', '.cs', '.cpp', '.c', '.h', '.kt'}
24
+ CONFIG_EXTS = {'.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
25
+ '.conf', '.env', '.md', '.txt', '.sh', '.bat'}
26
+
27
+
28
+ def _extract_symbols(path: Path) -> list[str]:
29
+ """Extract top-level class / function names from a source file."""
30
+ symbols: list[str] = []
31
+ try:
32
+ content = path.read_text(encoding='utf-8', errors='replace')
33
+ ext = path.suffix.lower()
34
+
35
+ if ext == '.py':
36
+ for m in re.finditer(r'^(?:async\s+)?def\s+(\w+)', content, re.MULTILINE):
37
+ symbols.append(f'def {m.group(1)}')
38
+ for m in re.finditer(r'^class\s+(\w+)', content, re.MULTILINE):
39
+ symbols.append(f'class {m.group(1)}')
40
+
41
+ elif ext in ('.js', '.ts', '.jsx', '.tsx'):
42
+ for m in re.finditer(
43
+ r'^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)',
44
+ content, re.MULTILINE
45
+ ):
46
+ symbols.append(f'fn {m.group(1)}')
47
+ for m in re.finditer(r'^(?:export\s+)?class\s+(\w+)', content, re.MULTILINE):
48
+ symbols.append(f'class {m.group(1)}')
49
+ for m in re.finditer(
50
+ r'^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(',
51
+ content, re.MULTILINE
52
+ ):
53
+ symbols.append(f'const {m.group(1)}')
54
+
55
+ elif ext == '.go':
56
+ for m in re.finditer(
57
+ r'^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)',
58
+ content, re.MULTILINE
59
+ ):
60
+ symbols.append(f'func {m.group(1)}')
61
+
62
+ elif ext in ('.java', '.kt'):
63
+ for m in re.finditer(
64
+ r'(?:public|private|protected)\s+(?:static\s+)?(?:\w+\s+)+(\w+)\s*\(',
65
+ content
66
+ ):
67
+ symbols.append(f'fn {m.group(1)}')
68
+ for m in re.finditer(
69
+ r'(?:class|interface|object)\s+(\w+)',
70
+ content
71
+ ):
72
+ symbols.append(f'class {m.group(1)}')
73
+
74
+ except Exception:
75
+ pass
76
+
77
+ seen: set[str] = set()
78
+ unique: list[str] = []
79
+ for s in symbols:
80
+ if s not in seen:
81
+ seen.add(s)
82
+ unique.append(s)
83
+ return unique[:10]
84
+
85
+
86
+ def build_project_index(project_path: str, max_files: int = 150) -> str:
87
+ """
88
+ Walk the project and return a compact symbol map string.
89
+ Injected into the system prompt so the model knows what files/symbols
90
+ exist without having to call list_dir or read files to discover structure.
91
+ Returns empty string for empty or single-file projects.
92
+ """
93
+ import time
94
+ root = Path(project_path).resolve()
95
+ entries: list[str] = []
96
+ file_count = 0
97
+ deadline = time.monotonic() + 3.0 # 3-second hard cap
98
+
99
+ for dirpath, dirnames, filenames in os.walk(root):
100
+ if time.monotonic() > deadline:
101
+ entries.append(f" ... (index timed out — use glob/grep to find files)")
102
+ break
103
+
104
+ dirnames[:] = sorted(
105
+ d for d in dirnames
106
+ if d not in SKIP_DIRS and not d.startswith('.')
107
+ )
108
+
109
+ for fname in sorted(filenames):
110
+ if file_count >= max_files:
111
+ entries.append(f" ... (index capped at {max_files} files — use glob/grep)")
112
+ break
113
+ if time.monotonic() > deadline:
114
+ break
115
+
116
+ fpath = Path(dirpath) / fname
117
+ ext = fpath.suffix.lower()
118
+
119
+ if ext in SKIP_EXTS or fpath.name.startswith('.'):
120
+ continue
121
+ try:
122
+ if fpath.stat().st_size > 400_000:
123
+ continue
124
+ except OSError:
125
+ continue
126
+
127
+ try:
128
+ rel_path = str(fpath.relative_to(root))
129
+ except ValueError:
130
+ rel_path = str(fpath)
131
+
132
+ if ext in CODE_EXTS:
133
+ try:
134
+ content = fpath.read_text(encoding='utf-8', errors='replace')
135
+ line_count = content.count('\n') + 1
136
+ symbols = _extract_symbols(fpath)
137
+ sym_str = f" [{', '.join(symbols[:8])}]" if symbols else ""
138
+ entries.append(f" {rel_path} ({line_count}L){sym_str}")
139
+ except Exception:
140
+ entries.append(f" {rel_path}")
141
+ elif ext in CONFIG_EXTS:
142
+ entries.append(f" {rel_path}")
143
+
144
+ file_count += 1
145
+
146
+ if file_count >= max_files:
147
+ break
148
+
149
+ if not entries:
150
+ return ""
151
+
152
+ lines = [f"\n\n## Project Index — {root.name} ({file_count} files)"]
153
+ lines.extend(entries)
154
+ lines.append("\nUse grep to locate a specific symbol before reading its file.")
155
+ return "\n".join(lines)