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/memory.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent memory — stores facts across sessions in structured Markdown files.
|
|
3
|
+
Inspired by Claude Code's auto-memory system (MEMORY.md index + topic files).
|
|
4
|
+
|
|
5
|
+
Storage layout:
|
|
6
|
+
~/.bharatcode/memory/
|
|
7
|
+
MEMORY.md ← index, always loaded into every system prompt
|
|
8
|
+
file.md ← memories tagged "file" (file paths, contents)
|
|
9
|
+
project.md ← memories tagged "project" (architecture, decisions)
|
|
10
|
+
user.md ← memories tagged "user" (preferences, background)
|
|
11
|
+
feedback.md ← memories tagged "feedback" (what to do/avoid)
|
|
12
|
+
general.md ← everything else
|
|
13
|
+
<any_custom_tag>.md ← agent can use any tag
|
|
14
|
+
|
|
15
|
+
Each entry in a topic file:
|
|
16
|
+
- [id=42] [2026-06-08 10:22] content here
|
|
17
|
+
|
|
18
|
+
MEMORY.md is a one-line-per-entry index pointing to topic files.
|
|
19
|
+
It is loaded verbatim into the system prompt so the model always knows what exists.
|
|
20
|
+
Topic files are loaded fully (budget-capped at ~6K chars total) alongside the index.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
MEMORY_DIR = Path.home() / ".bharatcode" / "memory"
|
|
27
|
+
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
|
|
28
|
+
_COUNTER_FILE = MEMORY_DIR / ".counter"
|
|
29
|
+
_OLD_JSON = Path.home() / ".bharatcode" / "memory.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _ensure() -> None:
|
|
35
|
+
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if not MEMORY_INDEX.exists():
|
|
37
|
+
if _OLD_JSON.exists():
|
|
38
|
+
_migrate_from_json()
|
|
39
|
+
else:
|
|
40
|
+
MEMORY_INDEX.write_text("# Memory Index\n\n", encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _next_id() -> int:
|
|
44
|
+
"""Global auto-increment counter for memory entry IDs."""
|
|
45
|
+
try:
|
|
46
|
+
n = int(_COUNTER_FILE.read_text(encoding="utf-8").strip()) + 1
|
|
47
|
+
except Exception:
|
|
48
|
+
n = 1
|
|
49
|
+
_COUNTER_FILE.write_text(str(n), encoding="utf-8")
|
|
50
|
+
return n
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tag_file(tag: str) -> Path:
|
|
54
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in tag.lower())
|
|
55
|
+
return MEMORY_DIR / f"{safe}.md"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rebuild_index() -> None:
|
|
59
|
+
"""Rewrite MEMORY.md from the current set of topic files."""
|
|
60
|
+
lines = ["# Memory Index\n"]
|
|
61
|
+
for md in sorted(MEMORY_DIR.glob("*.md")):
|
|
62
|
+
if md.name == "MEMORY.md":
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
content = md.read_text(encoding="utf-8")
|
|
66
|
+
# first bullet line as description
|
|
67
|
+
preview = next(
|
|
68
|
+
(l.strip().lstrip("- ").split("] ", 2)[-1] # strip [id=N] [ts]
|
|
69
|
+
for l in content.splitlines()
|
|
70
|
+
if l.strip().startswith("- [")),
|
|
71
|
+
md.stem,
|
|
72
|
+
)[:120]
|
|
73
|
+
lines.append(f"- [{md.stem}]({md.name}) — {preview}")
|
|
74
|
+
except Exception:
|
|
75
|
+
lines.append(f"- [{md.stem}]({md.name})")
|
|
76
|
+
MEMORY_INDEX.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _migrate_from_json() -> None:
|
|
80
|
+
"""
|
|
81
|
+
One-time migration: flat memory.json → individual topic .md files.
|
|
82
|
+
Preserves all entries and assigns sequential IDs.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
old = json.loads(_OLD_JSON.read_text(encoding="utf-8"))
|
|
86
|
+
except Exception:
|
|
87
|
+
old = []
|
|
88
|
+
|
|
89
|
+
# Group by tag
|
|
90
|
+
by_tag: dict[str, list[tuple[int, str, str]]] = {}
|
|
91
|
+
counter = 0
|
|
92
|
+
for m in old:
|
|
93
|
+
counter += 1
|
|
94
|
+
tag = m.get("tag", "general")
|
|
95
|
+
content = (m.get("text") or m.get("content") or "").strip()
|
|
96
|
+
ts = (m.get("created") or "")[:16] or "migrated"
|
|
97
|
+
if content:
|
|
98
|
+
by_tag.setdefault(tag, []).append((counter, ts, content))
|
|
99
|
+
|
|
100
|
+
for tag, entries in by_tag.items():
|
|
101
|
+
lines = [f"# {tag.title()}\n"]
|
|
102
|
+
for mid, ts, content in entries:
|
|
103
|
+
lines.append(f"- [id={mid}] [{ts}] {content}")
|
|
104
|
+
_tag_file(tag).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
105
|
+
|
|
106
|
+
# Set counter to max ID used
|
|
107
|
+
_COUNTER_FILE.write_text(str(counter), encoding="utf-8")
|
|
108
|
+
_rebuild_index()
|
|
109
|
+
|
|
110
|
+
# Back up old file
|
|
111
|
+
try:
|
|
112
|
+
_OLD_JSON.rename(_OLD_JSON.with_suffix(".json.bak"))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def add_memory(text: str, tag: str = "general") -> dict:
|
|
120
|
+
"""Add a memory entry. Returns dict with id, text, tag, created.
|
|
121
|
+
Exact-duplicate facts in the same topic file are skipped — the agent
|
|
122
|
+
saves memory after every task, so without this the store fills with
|
|
123
|
+
repeats that crowd real facts out of the context budget."""
|
|
124
|
+
_ensure()
|
|
125
|
+
ts = datetime.now().isoformat()
|
|
126
|
+
ts_short = ts[:16]
|
|
127
|
+
text = text.strip()
|
|
128
|
+
|
|
129
|
+
f = _tag_file(tag)
|
|
130
|
+
if f.exists() and text:
|
|
131
|
+
try:
|
|
132
|
+
if text in f.read_text(encoding="utf-8"):
|
|
133
|
+
return {"id": -1, "text": text, "tag": tag, "created": ts, "duplicate": True}
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
mid = _next_id()
|
|
138
|
+
if not f.exists():
|
|
139
|
+
f.write_text(f"# {tag.title()}\n\n", encoding="utf-8")
|
|
140
|
+
with open(f, "a", encoding="utf-8") as fp:
|
|
141
|
+
fp.write(f"- [id={mid}] [{ts_short}] {text}\n")
|
|
142
|
+
|
|
143
|
+
_rebuild_index()
|
|
144
|
+
return {"id": mid, "text": text, "tag": tag, "created": ts}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def delete_memory(memory_id: int) -> bool:
|
|
148
|
+
"""Delete a memory entry by its ID. Returns True if found and deleted."""
|
|
149
|
+
_ensure()
|
|
150
|
+
marker = f"[id={memory_id}]"
|
|
151
|
+
for md in MEMORY_DIR.glob("*.md"):
|
|
152
|
+
if md.name == "MEMORY.md":
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
content = md.read_text(encoding="utf-8")
|
|
156
|
+
if marker not in content:
|
|
157
|
+
continue
|
|
158
|
+
lines = content.splitlines(keepends=True)
|
|
159
|
+
new_lines = [l for l in lines if marker not in l]
|
|
160
|
+
md.write_text("".join(new_lines), encoding="utf-8")
|
|
161
|
+
_rebuild_index()
|
|
162
|
+
return True
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_memories() -> list[dict]:
|
|
169
|
+
"""Backward compat — return flat list of {id, text, tag, created} dicts."""
|
|
170
|
+
_ensure()
|
|
171
|
+
result = []
|
|
172
|
+
for md in sorted(MEMORY_DIR.glob("*.md")):
|
|
173
|
+
if md.name == "MEMORY.md":
|
|
174
|
+
continue
|
|
175
|
+
tag = md.stem
|
|
176
|
+
try:
|
|
177
|
+
for line in md.read_text(encoding="utf-8").splitlines():
|
|
178
|
+
if not line.strip().startswith("- [id="):
|
|
179
|
+
continue
|
|
180
|
+
# Parse: - [id=N] [YYYY-MM-DD HH:MM] content
|
|
181
|
+
rest = line.lstrip("- ")
|
|
182
|
+
# Extract id
|
|
183
|
+
id_end = rest.find("]")
|
|
184
|
+
try:
|
|
185
|
+
mid = int(rest[4:id_end]) # rest starts with "[id=N]"
|
|
186
|
+
except Exception:
|
|
187
|
+
mid = 0
|
|
188
|
+
rest = rest[id_end + 2:].strip() # skip "] "
|
|
189
|
+
# Extract timestamp
|
|
190
|
+
if rest.startswith("["):
|
|
191
|
+
ts_end = rest.find("]")
|
|
192
|
+
ts = rest[1:ts_end]
|
|
193
|
+
rest = rest[ts_end + 2:].strip()
|
|
194
|
+
else:
|
|
195
|
+
ts = ""
|
|
196
|
+
result.append({"id": mid, "text": rest, "tag": tag, "created": ts})
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def remember(text: str, tag: str = "project") -> str:
|
|
203
|
+
"""Tool-callable: save a memory and return confirmation."""
|
|
204
|
+
entry = add_memory(text, tag)
|
|
205
|
+
if entry.get("duplicate"):
|
|
206
|
+
return f"Already in memory — skipped duplicate: {text[:80]}"
|
|
207
|
+
return f"Memory saved (id={entry['id']}): {text[:80]}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def memories_to_context() -> str:
|
|
211
|
+
"""
|
|
212
|
+
Build the memory section injected into the system prompt.
|
|
213
|
+
Always includes MEMORY.md index. Then loads all topic files
|
|
214
|
+
(newest first) up to a ~6K char budget so large memory stores
|
|
215
|
+
don't flood the context.
|
|
216
|
+
"""
|
|
217
|
+
_ensure()
|
|
218
|
+
if not MEMORY_INDEX.exists():
|
|
219
|
+
return ""
|
|
220
|
+
|
|
221
|
+
index = MEMORY_INDEX.read_text(encoding="utf-8").strip()
|
|
222
|
+
if not index or index == "# Memory Index":
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
parts = [f"\n\n## Persistent Memory (from past sessions)\n\n{index}"]
|
|
226
|
+
|
|
227
|
+
# Load topic files up to budget — NEWEST entries first within each file,
|
|
228
|
+
# so when the budget cuts anything it always cuts the OLDEST facts.
|
|
229
|
+
# (Topic files are append-only: newest entries live at the bottom.)
|
|
230
|
+
budget = 6000
|
|
231
|
+
for md in sorted(MEMORY_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
232
|
+
if md.name == "MEMORY.md":
|
|
233
|
+
continue
|
|
234
|
+
if budget <= 0:
|
|
235
|
+
break
|
|
236
|
+
try:
|
|
237
|
+
entry_lines = [
|
|
238
|
+
l for l in md.read_text(encoding="utf-8").splitlines()
|
|
239
|
+
if l.strip().startswith("- [id=")
|
|
240
|
+
]
|
|
241
|
+
if not entry_lines:
|
|
242
|
+
continue
|
|
243
|
+
entry_lines = entry_lines[::-1][:40] # newest first, max 40 per topic
|
|
244
|
+
chunk = f"\n\n### {md.stem} (newest first)\n" + "\n".join(entry_lines)
|
|
245
|
+
if len(chunk) > budget:
|
|
246
|
+
chunk = chunk[:budget] + "\n ...(older entries omitted)"
|
|
247
|
+
parts.append(chunk)
|
|
248
|
+
budget -= len(chunk)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
return "".join(parts)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def show_memories(console) -> None:
|
|
256
|
+
"""Pretty-print all memory entries for /memory list."""
|
|
257
|
+
_ensure()
|
|
258
|
+
files = [f for f in sorted(MEMORY_DIR.glob("*.md")) if f.name != "MEMORY.md"]
|
|
259
|
+
|
|
260
|
+
if not files:
|
|
261
|
+
console.print("[dim]No memories saved yet. The agent saves memories automatically, "
|
|
262
|
+
"or use: /memory add <text>[/dim]")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
console.print(f"\n[bold]Memory[/bold] [dim]{MEMORY_DIR}[/dim]\n")
|
|
266
|
+
total = 0
|
|
267
|
+
for md in files:
|
|
268
|
+
try:
|
|
269
|
+
content = md.read_text(encoding="utf-8").strip()
|
|
270
|
+
entries = [l for l in content.splitlines() if l.strip().startswith("- [id=")]
|
|
271
|
+
if not entries:
|
|
272
|
+
continue
|
|
273
|
+
console.print(f" [bold cyan]{md.stem}[/bold cyan] [dim]({len(entries)} entries)[/dim]")
|
|
274
|
+
for line in entries:
|
|
275
|
+
# Parse display: strip [id=N] prefix, keep [ts] and content
|
|
276
|
+
rest = line.lstrip("- ")
|
|
277
|
+
id_end = rest.find("]")
|
|
278
|
+
mid = rest[4:id_end] if rest.startswith("[id=") else "?"
|
|
279
|
+
rest = rest[id_end + 2:].strip()
|
|
280
|
+
console.print(f" [dim]{mid:>4}[/dim] {rest}")
|
|
281
|
+
total += 1
|
|
282
|
+
console.print()
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
console.print(f"[dim] {total} total entries — /memory del <id> to remove[/dim]\n")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Permission system — inspired by Claude Code's BashPermissionRequest component.
|
|
3
|
+
Ask user allow/deny/always before running bash commands.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Prompt
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
_SESSION_ALWAYS: set[str] = set() # commands allowed for this session
|
|
14
|
+
_ALWAYS_FILE = Path.home() / ".bharatcode" / "always_allow.json"
|
|
15
|
+
|
|
16
|
+
def _load_always() -> set[str]:
|
|
17
|
+
if _ALWAYS_FILE.exists():
|
|
18
|
+
try:
|
|
19
|
+
return set(json.loads(_ALWAYS_FILE.read_text()))
|
|
20
|
+
except Exception:
|
|
21
|
+
return set()
|
|
22
|
+
return set()
|
|
23
|
+
|
|
24
|
+
def _save_always(items: set[str]):
|
|
25
|
+
_ALWAYS_FILE.parent.mkdir(exist_ok=True)
|
|
26
|
+
_ALWAYS_FILE.write_text(json.dumps(sorted(items)))
|
|
27
|
+
|
|
28
|
+
_PERMANENT_ALWAYS: set[str] = _load_always()
|
|
29
|
+
|
|
30
|
+
def _command_key(cmd: str) -> str:
|
|
31
|
+
"""Normalize a command for matching (first word / verb)."""
|
|
32
|
+
return cmd.strip().split()[0] if cmd.strip() else ""
|
|
33
|
+
|
|
34
|
+
def needs_approval(tool_name: str, args: dict, auto_approve: bool = False) -> tuple[bool, str]:
|
|
35
|
+
"""
|
|
36
|
+
Returns (approved, reason).
|
|
37
|
+
Safe read-only tools are auto-approved.
|
|
38
|
+
Bash requires user confirmation unless always-allowed.
|
|
39
|
+
"""
|
|
40
|
+
if auto_approve:
|
|
41
|
+
return True, "auto"
|
|
42
|
+
|
|
43
|
+
# Read-only tools: always OK
|
|
44
|
+
if tool_name in ("read_file", "glob", "grep"):
|
|
45
|
+
return True, "safe"
|
|
46
|
+
|
|
47
|
+
# write/edit — auto-approve (user sees the diff anyway)
|
|
48
|
+
if tool_name in ("write_file", "edit_file"):
|
|
49
|
+
return True, "safe"
|
|
50
|
+
|
|
51
|
+
# bash — check allow lists
|
|
52
|
+
if tool_name == "bash":
|
|
53
|
+
cmd = args.get("command", "")
|
|
54
|
+
key = _command_key(cmd)
|
|
55
|
+
if key in _PERMANENT_ALWAYS or key in _SESSION_ALWAYS:
|
|
56
|
+
return True, "always-allowed"
|
|
57
|
+
return False, "needs-approval"
|
|
58
|
+
|
|
59
|
+
return True, "safe"
|
|
60
|
+
|
|
61
|
+
def ask_permission(tool_name: str, args: dict) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Show a permission dialog for bash commands.
|
|
64
|
+
Returns True if approved.
|
|
65
|
+
"""
|
|
66
|
+
cmd = args.get("command", "")
|
|
67
|
+
|
|
68
|
+
console.print()
|
|
69
|
+
console.print(Panel(
|
|
70
|
+
f"[bold yellow]{cmd}[/bold yellow]",
|
|
71
|
+
title="[bold red] Sylithe Code wants to run a command [/bold red]",
|
|
72
|
+
border_style="yellow",
|
|
73
|
+
padding=(0, 1),
|
|
74
|
+
))
|
|
75
|
+
console.print(
|
|
76
|
+
" [green]y[/green] Allow once "
|
|
77
|
+
"[cyan]s[/cyan] Allow for session "
|
|
78
|
+
"[blue]a[/blue] Always allow "
|
|
79
|
+
"[red]n[/red] Deny"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
choice = Prompt.ask(" [dim]Permission[/dim]", choices=["y", "s", "a", "n"], default="y")
|
|
83
|
+
|
|
84
|
+
if choice == "n":
|
|
85
|
+
console.print(" [red]Denied.[/red]")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
key = _command_key(cmd)
|
|
89
|
+
|
|
90
|
+
if choice == "s":
|
|
91
|
+
_SESSION_ALWAYS.add(key)
|
|
92
|
+
console.print(f" [cyan]Allowed for this session: {key}[/cyan]")
|
|
93
|
+
elif choice == "a":
|
|
94
|
+
_PERMANENT_ALWAYS.add(key)
|
|
95
|
+
_save_always(_PERMANENT_ALWAYS)
|
|
96
|
+
console.print(f" [blue]Always allowed: {key}[/blue]")
|
|
97
|
+
|
|
98
|
+
console.print()
|
|
99
|
+
return True
|
bharatcode/project.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project auto-detection — inspired by Claude Code's detectRepository.
|
|
3
|
+
Reads package.json / requirements.txt / pom.xml / build.gradle / go.mod
|
|
4
|
+
and injects project context into every system prompt.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detect_project(cwd: str = ".") -> dict:
|
|
11
|
+
"""Detect project type and metadata from common config files."""
|
|
12
|
+
root = Path(cwd)
|
|
13
|
+
info = {
|
|
14
|
+
"type": "unknown",
|
|
15
|
+
"language": "unknown",
|
|
16
|
+
"name": root.name,
|
|
17
|
+
"version": "",
|
|
18
|
+
"deps": [],
|
|
19
|
+
"scripts": [],
|
|
20
|
+
"test_cmd": "",
|
|
21
|
+
"run_cmd": "",
|
|
22
|
+
"framework": "",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Node.js
|
|
26
|
+
pkg = root / "package.json"
|
|
27
|
+
if pkg.exists():
|
|
28
|
+
try:
|
|
29
|
+
d = json.loads(pkg.read_text(encoding="utf-8"))
|
|
30
|
+
info.update({
|
|
31
|
+
"type": "node",
|
|
32
|
+
"language": "javascript" if not (root / "tsconfig.json").exists() else "typescript",
|
|
33
|
+
"name": d.get("name", root.name),
|
|
34
|
+
"version": d.get("version", ""),
|
|
35
|
+
"deps": list(d.get("dependencies", {}).keys())[:20],
|
|
36
|
+
"scripts": list(d.get("scripts", {}).keys()),
|
|
37
|
+
"test_cmd": "npm test",
|
|
38
|
+
"run_cmd": "npm start",
|
|
39
|
+
"framework": _detect_node_framework(d),
|
|
40
|
+
})
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return info
|
|
44
|
+
|
|
45
|
+
# Python
|
|
46
|
+
for pyfile in ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"]:
|
|
47
|
+
if (root / pyfile).exists():
|
|
48
|
+
info["type"] = "python"
|
|
49
|
+
info["language"] = "python"
|
|
50
|
+
info["test_cmd"] = "pytest"
|
|
51
|
+
info["run_cmd"] = "python app.py"
|
|
52
|
+
|
|
53
|
+
if pyfile == "requirements.txt":
|
|
54
|
+
lines = (root / pyfile).read_text(encoding="utf-8").splitlines()
|
|
55
|
+
info["deps"] = [l.split("==")[0].split(">=")[0].strip()
|
|
56
|
+
for l in lines if l.strip() and not l.startswith("#")][:20]
|
|
57
|
+
info["framework"] = _detect_python_framework(info["deps"])
|
|
58
|
+
|
|
59
|
+
elif pyfile == "pyproject.toml":
|
|
60
|
+
try:
|
|
61
|
+
import tomllib
|
|
62
|
+
with open(root / pyfile, "rb") as f:
|
|
63
|
+
d = tomllib.load(f)
|
|
64
|
+
proj = d.get("project", {})
|
|
65
|
+
info["name"] = proj.get("name", root.name)
|
|
66
|
+
info["version"] = proj.get("version", "")
|
|
67
|
+
info["deps"] = [str(d).split("[")[0].split(">=")[0].split("==")[0].strip()
|
|
68
|
+
for d in proj.get("dependencies", [])][:20]
|
|
69
|
+
info["framework"] = _detect_python_framework(info["deps"])
|
|
70
|
+
scripts = d.get("project", {}).get("scripts", {})
|
|
71
|
+
if scripts:
|
|
72
|
+
info["run_cmd"] = f"python -m {list(scripts.values())[0]}"
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return info
|
|
77
|
+
|
|
78
|
+
# Java / Maven
|
|
79
|
+
if (root / "pom.xml").exists():
|
|
80
|
+
info.update({
|
|
81
|
+
"type": "java",
|
|
82
|
+
"language": "java",
|
|
83
|
+
"test_cmd": "mvn test",
|
|
84
|
+
"run_cmd": "mvn spring-boot:run",
|
|
85
|
+
"framework": "Spring Boot",
|
|
86
|
+
})
|
|
87
|
+
return info
|
|
88
|
+
|
|
89
|
+
# Java / Gradle
|
|
90
|
+
if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
|
|
91
|
+
kts = (root / "build.gradle.kts").exists()
|
|
92
|
+
info.update({
|
|
93
|
+
"type": "java",
|
|
94
|
+
"language": "kotlin" if kts else "java",
|
|
95
|
+
"test_cmd": "./gradlew test",
|
|
96
|
+
"run_cmd": "./gradlew bootRun",
|
|
97
|
+
"framework": "Spring Boot",
|
|
98
|
+
})
|
|
99
|
+
return info
|
|
100
|
+
|
|
101
|
+
# Go
|
|
102
|
+
if (root / "go.mod").exists():
|
|
103
|
+
info.update({
|
|
104
|
+
"type": "go",
|
|
105
|
+
"language": "go",
|
|
106
|
+
"test_cmd": "go test ./...",
|
|
107
|
+
"run_cmd": "go run .",
|
|
108
|
+
})
|
|
109
|
+
return info
|
|
110
|
+
|
|
111
|
+
# Rust
|
|
112
|
+
if (root / "Cargo.toml").exists():
|
|
113
|
+
info.update({
|
|
114
|
+
"type": "rust",
|
|
115
|
+
"language": "rust",
|
|
116
|
+
"test_cmd": "cargo test",
|
|
117
|
+
"run_cmd": "cargo run",
|
|
118
|
+
})
|
|
119
|
+
return info
|
|
120
|
+
|
|
121
|
+
# Flutter / Dart
|
|
122
|
+
if (root / "pubspec.yaml").exists():
|
|
123
|
+
info.update({
|
|
124
|
+
"type": "flutter",
|
|
125
|
+
"language": "dart",
|
|
126
|
+
"test_cmd": "flutter test",
|
|
127
|
+
"run_cmd": "flutter run",
|
|
128
|
+
"framework": "Flutter",
|
|
129
|
+
})
|
|
130
|
+
return info
|
|
131
|
+
|
|
132
|
+
return info
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _detect_node_framework(pkg: dict) -> str:
|
|
136
|
+
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
137
|
+
if "next" in deps: return "Next.js"
|
|
138
|
+
if "react" in deps: return "React"
|
|
139
|
+
if "@angular/core" in deps: return "Angular"
|
|
140
|
+
if "vue" in deps: return "Vue.js"
|
|
141
|
+
if "express" in deps: return "Express.js"
|
|
142
|
+
if "fastify" in deps: return "Fastify"
|
|
143
|
+
if "nestjs" in deps: return "NestJS"
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _detect_python_framework(deps: list[str]) -> str:
|
|
148
|
+
deps_lower = [d.lower() for d in deps]
|
|
149
|
+
if "django" in deps_lower: return "Django"
|
|
150
|
+
if "flask" in deps_lower: return "Flask"
|
|
151
|
+
if "fastapi" in deps_lower: return "FastAPI"
|
|
152
|
+
if "streamlit" in deps_lower: return "Streamlit"
|
|
153
|
+
if "celery" in deps_lower: return "Celery"
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def project_context_string(cwd: str = ".") -> str:
|
|
158
|
+
"""Returns a string to inject into the system prompt about this project."""
|
|
159
|
+
info = detect_project(cwd)
|
|
160
|
+
if info["type"] == "unknown":
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
lines = [f"\n\n## Auto-detected Project Context"]
|
|
164
|
+
lines.append(f"- **Project**: {info['name']}")
|
|
165
|
+
lines.append(f"- **Language**: {info['language']}")
|
|
166
|
+
if info.get("framework"):
|
|
167
|
+
lines.append(f"- **Framework**: {info['framework']}")
|
|
168
|
+
if info.get("version"):
|
|
169
|
+
lines.append(f"- **Version**: {info['version']}")
|
|
170
|
+
if info.get("test_cmd"):
|
|
171
|
+
lines.append(f"- **Test command**: `{info['test_cmd']}`")
|
|
172
|
+
if info.get("run_cmd"):
|
|
173
|
+
lines.append(f"- **Run command**: `{info['run_cmd']}`")
|
|
174
|
+
if info.get("deps"):
|
|
175
|
+
lines.append(f"- **Key dependencies**: {', '.join(info['deps'][:10])}")
|
|
176
|
+
if info.get("scripts"):
|
|
177
|
+
lines.append(f"- **Scripts**: {', '.join(info['scripts'][:8])}")
|
|
178
|
+
|
|
179
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session persistence — saves conversation history to JSONL as it happens,
|
|
3
|
+
so sessions survive process crashes and can be resumed in a new terminal.
|
|
4
|
+
|
|
5
|
+
Storage layout:
|
|
6
|
+
~/.bharatcode/sessions/<project_hash>/<session_id>.jsonl
|
|
7
|
+
~/.bharatcode/sessions/<project_hash>/latest ← ID of most recent session
|
|
8
|
+
|
|
9
|
+
One JSON object per line. Messages appended in real-time so a crash mid-turn
|
|
10
|
+
only loses the in-progress messages, not the whole session.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
import hashlib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
_SESSIONS_ROOT = Path.home() / ".bharatcode" / "sessions"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Path helpers ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def _project_dir(project_path: str) -> Path:
|
|
24
|
+
h = hashlib.md5(str(project_path).encode()).hexdigest()[:10]
|
|
25
|
+
d = _SESSIONS_ROOT / h
|
|
26
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_session_id() -> str:
|
|
31
|
+
return uuid.uuid4().hex[:12]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def session_path(project_path: str, session_id: str) -> Path:
|
|
35
|
+
return _project_dir(project_path) / f"{session_id}.jsonl"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Read / Write ──────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def append_messages(path: Path, messages: list[dict]) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Append messages to the JSONL file. Fire-and-forget — never raises.
|
|
43
|
+
Called by interactive_mode after each agent turn completes.
|
|
44
|
+
"""
|
|
45
|
+
if not messages:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
49
|
+
for msg in messages:
|
|
50
|
+
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_messages(path: Path) -> list[dict]:
|
|
56
|
+
"""Load all valid messages from a JSONL session file."""
|
|
57
|
+
if not path or not path.exists():
|
|
58
|
+
return []
|
|
59
|
+
messages = []
|
|
60
|
+
try:
|
|
61
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if line:
|
|
64
|
+
try:
|
|
65
|
+
messages.append(json.loads(line))
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
pass
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
return messages
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Session discovery ─────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def save_latest_pointer(project_path: str, session_id: str) -> None:
|
|
76
|
+
(_project_dir(project_path) / "latest").write_text(session_id, encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_recent(project_path: str, max_n: int = 5) -> list[dict]:
|
|
80
|
+
"""
|
|
81
|
+
Return metadata for the N most recent sessions for this project.
|
|
82
|
+
Each entry: {session_id, path, turns, last_message, mtime_str}
|
|
83
|
+
"""
|
|
84
|
+
d = _project_dir(project_path)
|
|
85
|
+
results = []
|
|
86
|
+
for f in sorted(d.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
87
|
+
messages = load_messages(f)
|
|
88
|
+
if not messages:
|
|
89
|
+
continue
|
|
90
|
+
user_msgs = [m for m in messages if m.get("role") == "user"]
|
|
91
|
+
if not user_msgs:
|
|
92
|
+
continue
|
|
93
|
+
last = user_msgs[-1].get("content", "")
|
|
94
|
+
last_preview = last[:70].replace("\n", " ") if last else ""
|
|
95
|
+
import datetime
|
|
96
|
+
mtime = f.stat().st_mtime
|
|
97
|
+
dt = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
|
98
|
+
results.append({
|
|
99
|
+
"session_id": f.stem,
|
|
100
|
+
"path": f,
|
|
101
|
+
"turns": len(user_msgs),
|
|
102
|
+
"last_message": last_preview,
|
|
103
|
+
"mtime": mtime,
|
|
104
|
+
"mtime_str": dt,
|
|
105
|
+
})
|
|
106
|
+
if len(results) >= max_n:
|
|
107
|
+
break
|
|
108
|
+
return results
|