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/__init__.py +13 -0
- bharatcode/agent.py +2088 -0
- bharatcode/commands.py +1072 -0
- bharatcode/config.py +93 -0
- bharatcode/coordinator.py +670 -0
- bharatcode/cost.py +77 -0
- bharatcode/diff.py +113 -0
- bharatcode/hooks.py +75 -0
- bharatcode/index.py +155 -0
- bharatcode/main.py +846 -0
- bharatcode/memory.py +286 -0
- bharatcode/permissions.py +99 -0
- bharatcode/project.py +179 -0
- bharatcode/session_storage.py +108 -0
- bharatcode/skills.py +1746 -0
- bharatcode/subagent.py +363 -0
- bharatcode/tools.py +1021 -0
- bharatcode/ui.py +72 -0
- bharatcode-0.1.0.dist-info/METADATA +150 -0
- bharatcode-0.1.0.dist-info/RECORD +23 -0
- bharatcode-0.1.0.dist-info/WHEEL +5 -0
- bharatcode-0.1.0.dist-info/entry_points.txt +3 -0
- bharatcode-0.1.0.dist-info/top_level.txt +1 -0
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)
|