bhai-cli 0.2.2__tar.gz → 0.2.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bhai-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: BHAI — The Dual-Brain AI Coding Agent with Punjabi Swagger. Indic-native voice + universal LLM tool-calling.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bhai-cli"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "BHAI — The Dual-Brain AI Coding Agent with Punjabi Swagger. Indic-native voice + universal LLM tool-calling."
5
5
  authors = ["Madhav Kapila <smartatk04@gmail.com>"]
6
6
  readme = "README.md"
@@ -30,6 +30,7 @@ from bhai_cli.config_manager import (
30
30
  CONFIG_DIR,
31
31
  _mask_key,
32
32
  )
33
+ from bhai_cli.session_manager import get_all_sessions, delete_session
33
34
 
34
35
  console = Console()
35
36
 
@@ -99,22 +100,75 @@ def interactive_menu():
99
100
  console.print(" [2] 🎙️ Listen (Voice mode)")
100
101
  console.print(" [3] ⚙️ Setup Wizard")
101
102
  console.print(" [4] 📋 View Config")
102
- console.print(" [5] ❌ Exit\n")
103
+ console.print(" [5] 🗂️ Sessions")
104
+ console.print(" [6] ❌ Exit\n")
103
105
 
104
- choice = Prompt.ask("[bold green]Choose an option[/]", choices=["1", "2", "3", "4", "5"])
106
+ choice = Prompt.ask("[bold green]Choose an option[/]", choices=["1", "2", "3", "4", "5", "6"])
105
107
 
106
108
  if choice == "1":
107
- text(prompt="", vibe_model=None, coder_model=None, interactive=False)
109
+ text(prompt="", vibe_model=None, coder_model=None, session_id=None, interactive=False)
108
110
  elif choice == "2":
109
- listen(vibe_model=None, coder_model=None, file=None)
111
+ listen(vibe_model=None, coder_model=None, file=None, session_id=None)
110
112
  elif choice == "3":
111
113
  setup()
112
114
  elif choice == "4":
113
115
  config()
114
116
  elif choice == "5":
117
+ sessions_menu()
118
+ elif choice == "6":
115
119
  console.print("\n[bold yellow]BHAI:[/] Accha bhai, phir milenge! 🤙")
116
120
  sys.exit(0)
117
121
 
122
+ def sessions_menu():
123
+ """Interactive menu to manage sessions."""
124
+ while True:
125
+ sessions = get_all_sessions()
126
+ console.print("\n[bold cyan]🗂️ Session Management[/]")
127
+
128
+ if not sessions:
129
+ console.print("[dim]No past sessions found.[/]")
130
+ else:
131
+ table = Table(show_header=True, header_style="bold cyan")
132
+ table.add_column("No.", style="bold white")
133
+ table.add_column("Title", style="yellow")
134
+ table.add_column("Updated At", style="dim")
135
+ table.add_column("ID", style="dim")
136
+
137
+ for idx, s in enumerate(sessions):
138
+ table.add_row(str(idx + 1), s["title"], s["updated_at"][:16], s["id"])
139
+ console.print(table)
140
+
141
+ console.print("\nOptions: [bold green]N[/] (New Session) | [bold yellow]D <no>[/] (Delete) | [bold cyan]<no>[/] (Resume) | [bold red]B[/] (Back)")
142
+ choice = Prompt.ask("Select").strip().lower()
143
+
144
+ if choice == "b" or choice == "back":
145
+ break
146
+ elif choice == "n" or choice == "new":
147
+ console.print("[bold green]Starting new session...[/]")
148
+ text(prompt="", vibe_model=None, coder_model=None, session_id=None, interactive=True)
149
+ break
150
+ elif choice.startswith("d "):
151
+ try:
152
+ idx = int(choice.split(" ")[1]) - 1
153
+ if 0 <= idx < len(sessions):
154
+ s_id = sessions[idx]["id"]
155
+ if delete_session(s_id):
156
+ console.print(f"[green]Deleted session: {s_id}[/]")
157
+ else:
158
+ console.print("[red]Failed to delete session.[/]")
159
+ except ValueError:
160
+ console.print("[red]Invalid selection.[/]")
161
+ else:
162
+ try:
163
+ idx = int(choice) - 1
164
+ if 0 <= idx < len(sessions):
165
+ s_id = sessions[idx]["id"]
166
+ console.print(f"[bold green]Resuming session: {sessions[idx]['title']}[/]")
167
+ text(prompt="", vibe_model=None, coder_model=None, session_id=s_id, interactive=True)
168
+ break
169
+ except ValueError:
170
+ console.print("[red]Invalid selection.[/]")
171
+
118
172
 
119
173
  # ---------------------------------------------------------------------------
120
174
  # bhai-code setup
@@ -204,18 +258,21 @@ def text(
204
258
  prompt: str = typer.Argument("", help="Your message to BHAI. If empty, starts interactive mode."),
205
259
  vibe_model: Optional[str] = typer.Option(None, "--vibe-model", "-vm", help="Override VibeLLM model."),
206
260
  coder_model: Optional[str] = typer.Option(None, "--coder-model", "-cm", help="Override CoderAgent model."),
261
+ session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Resume a specific session ID."),
207
262
  interactive: bool = typer.Option(False, "--interactive", "-i", help="Enter interactive chat mode (always active if prompt is empty)."),
208
263
  ) -> None:
209
264
  """[bold magenta]💬 text[/] — Send a text prompt to BHAI or start interactive session."""
210
265
  if not prompt:
211
266
  print_banner()
267
+ if session_id:
268
+ console.print(f"[bold cyan]Resuming Session: {session_id}[/]")
212
269
  console.print("[bold cyan]Welcome to BHAI Interactive Mode.[/]\n[dim]Type 'exit', 'quit', or 'nikal' to leave.[/]\n")
213
270
  interactive = True
214
271
 
215
272
  from bhai_cli.orchestrator import BhaiOrchestrator
216
273
 
217
274
  cfg = load_config(vibe_model_override=vibe_model, coder_model_override=coder_model)
218
- orchestrator = BhaiOrchestrator(cfg)
275
+ orchestrator = BhaiOrchestrator(cfg, session_id=session_id)
219
276
 
220
277
  if prompt:
221
278
  asyncio.run(orchestrator.process(prompt))
@@ -229,6 +286,9 @@ def text(
229
286
  if user_input.strip().lower() in ("exit", "quit", "bye", "chal hatja", "nikal"):
230
287
  console.print("\n[bold yellow]BHAI:[/] Accha bhai, phir milenge! 🤙")
231
288
  break
289
+ if user_input.strip().lower() == "/compress":
290
+ asyncio.run(orchestrator.compress_context(manual=True))
291
+ continue
232
292
  asyncio.run(orchestrator.process(user_input))
233
293
  except (KeyboardInterrupt, EOFError):
234
294
  console.print("\n[bold yellow]BHAI:[/] Theek hai boss, baad mein baat karte hain! 👋")
@@ -242,6 +302,7 @@ def text(
242
302
  def listen(
243
303
  vibe_model: Optional[str] = typer.Option(None, "--vibe-model", "-vm", help="Override VibeLLM model."),
244
304
  coder_model: Optional[str] = typer.Option(None, "--coder-model", "-cm", help="Override CoderAgent model."),
305
+ session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Resume a specific session ID."),
245
306
  file: Optional[str] = typer.Option(None, "--file", "-f", help="Path to an audio file instead of live mic."),
246
307
  ) -> None:
247
308
  """[bold red]🎙️ listen[/] — Voice input — speak to BHAI in any Indian language."""
@@ -264,7 +325,7 @@ def listen(
264
325
  )
265
326
  raise typer.Exit(1)
266
327
 
267
- orchestrator = BhaiOrchestrator(cfg)
328
+ orchestrator = BhaiOrchestrator(cfg, session_id=session_id)
268
329
  audio_bytes = None
269
330
  audio_file = file or cfg.audio_fallback_file
270
331
 
@@ -24,6 +24,7 @@ from rich.markdown import Markdown
24
24
 
25
25
  from bhai_cli.config_manager import BhaiConfig
26
26
  from bhai_cli.tools import TOOL_SCHEMAS, dispatch_tool
27
+ from bhai_cli.session_manager import load_session, save_session, create_session_id
27
28
 
28
29
  console = Console()
29
30
 
@@ -39,10 +40,10 @@ VIBE_SYSTEM_PROMPT = (
39
40
  "Your job:\n"
40
41
  "1. Parse the user's intent (any Indian language, Hinglish, or English) into a clear "
41
42
  "English task description.\n"
42
- "2. IMPORTANT: You control a CoderAgent that has tools to read/edit files, list directories, and run bash commands! "
43
- "If the user asks to explain a file, run code, or do ANY system task, DO NOT ask them to upload or paste it. "
44
- "Instead, output a JSON block to delegate it: {\"task\": \"Read HelloWorld.py and explain it, then run it with a=4 b=10\"}.\n"
45
- "3. If strictly chatting with no system/code actions needed, respond conversationally with swagger.\n"
43
+ "2. IMPORTANT: You control a CoderAgent that has tools to read/edit files, list directories, run bash commands, and perform web searches! "
44
+ "If the user asks to explain a file, run code, get real-time info from the internet, or do ANY system task, DO NOT ask them to do it themselves or upload it. "
45
+ "Instead, output a JSON block to delegate it: {\"task\": \"Read HelloWorld.py and explain it\"} or {\"task\": \"Search the web for IPL 2026 points table\"}.\n"
46
+ "3. If strictly chatting with no system/code/search actions needed, respond conversationally with swagger.\n"
46
47
  "4. After CoderAgent results, review them and decide:\n"
47
48
  ' - If the task is COMPLETE, say "BHAI_DONE" somewhere in your response.\n'
48
49
  ' - If MORE WORK is needed, output another {"task": "next step description"}.\n\n'
@@ -61,7 +62,8 @@ CODER_SYSTEM_PROMPT = (
61
62
  "5. Use read_file to see exact file contents before edit_file.\n"
62
63
  "6. Use execute_bash for running tests, git, docker, and system commands.\n"
63
64
  "7. Verify your changes work by running relevant commands.\n"
64
- "8. Report results factually — what you did, what worked, what failed.\n\n"
65
+ "8. Report results factually — what you did, what worked, what failed.\n"
66
+ "9. IMPORTANT: When you are done with a task or providing an explanation, simply output plain text. DO NOT format your explanation as a JSON tool call if no tools are needed.\n\n"
65
67
  "## Tools Available:\n"
66
68
  "- list_directory: Explore project structure\n"
67
69
  "- codebase_context: Get function/class signatures (AST-based)\n"
@@ -81,11 +83,39 @@ CODER_SYSTEM_PROMPT = (
81
83
  class BhaiOrchestrator:
82
84
  """Dual-brain agentic orchestrator for BHAI-CLI."""
83
85
 
84
- def __init__(self, config: BhaiConfig):
86
+ def __init__(self, config: BhaiConfig, session_id: Optional[str] = None):
85
87
  self.config = config
86
- self.conversation_history: list[dict] = []
88
+ self.session_id = session_id or create_session_id()
89
+ self.session_title, self.conversation_history = load_session(self.session_id)
87
90
  self._setup_litellm()
88
91
 
92
+ async def _generate_session_title(self, prompt: str) -> None:
93
+ """Generate a short title using VibeLLM."""
94
+ if self.session_title:
95
+ return
96
+
97
+ messages = [
98
+ {"role": "system", "content": "Generate a very short title (max 5 words) for this chat based on the user's first prompt. Respond ONLY with the title string."},
99
+ {"role": "user", "content": prompt}
100
+ ]
101
+ try:
102
+ kwargs = {
103
+ "model": self.config.vibe.model,
104
+ "messages": messages,
105
+ "temperature": 0.5,
106
+ "max_tokens": 20,
107
+ }
108
+ if self.config.vibe.api_base:
109
+ kwargs["api_base"] = self.config.vibe.api_base
110
+ if self.config.vibe.api_key:
111
+ kwargs["api_key"] = self.config.vibe.api_key
112
+ response = await litellm.acompletion(**kwargs)
113
+ title = response.choices[0].message.content.strip().strip('"\'')
114
+ if title:
115
+ self.session_title = title
116
+ except Exception:
117
+ self.session_title = prompt[:30] + "..."
118
+
89
119
  def _setup_litellm(self) -> None:
90
120
  import os
91
121
  if self.config.vibe.api_key:
@@ -136,14 +166,24 @@ class BhaiOrchestrator:
136
166
  }
137
167
  if self.config.vibe.api_base:
138
168
  kwargs["api_base"] = self.config.vibe.api_base
169
+ if self.config.vibe.api_key:
170
+ kwargs["api_key"] = self.config.vibe.api_key
139
171
 
140
172
  try:
141
173
  response = await litellm.acompletion(**kwargs)
142
174
  content = response.choices[0].message.content or ""
143
175
  self.conversation_history.append({"role": "user", "content": user_input})
144
176
  self.conversation_history.append({"role": "assistant", "content": content})
145
- if len(self.conversation_history) > 20:
146
- self.conversation_history = self.conversation_history[-16:]
177
+
178
+ # Auto-compress if history is getting too long (e.g. 30 messages)
179
+ if len(self.conversation_history) > 30:
180
+ await self.compress_context()
181
+
182
+ if not self.session_title:
183
+ await self._generate_session_title(user_input)
184
+
185
+ save_session(self.session_id, self.session_title, self.conversation_history)
186
+
147
187
  task = self._extract_task(content)
148
188
  return task, content
149
189
  except Exception as e:
@@ -197,6 +237,8 @@ class BhaiOrchestrator:
197
237
  }
198
238
  if self.config.coder.api_base:
199
239
  kwargs["api_base"] = self.config.coder.api_base
240
+ if self.config.coder.api_key:
241
+ kwargs["api_key"] = self.config.coder.api_key
200
242
 
201
243
  try:
202
244
  response = await litellm.acompletion(**kwargs)
@@ -234,12 +276,82 @@ class BhaiOrchestrator:
234
276
  })
235
277
 
236
278
  except Exception as e:
279
+ error_str = str(e)
280
+ if "failed_generation" in error_str:
281
+ try:
282
+ json_start = error_str.find("{")
283
+ if json_start != -1:
284
+ error_json = json.loads(error_str[json_start:])
285
+ failed_gen = error_json.get("error", {}).get("failed_generation", "")
286
+ if failed_gen:
287
+ return failed_gen
288
+ except Exception:
289
+ pass
290
+
237
291
  error = f"CoderAgent error ({type(e).__name__}): {e}"
238
292
  console.print(f"[bold red]⚠ {error}[/]")
239
293
  return error
240
294
 
241
295
  return "Max iterations reached.\n" + "\n".join(all_results)
242
296
 
297
+ async def compress_context(self, manual: bool = False) -> None:
298
+ """Summarize older messages to free up context window."""
299
+ if len(self.conversation_history) <= 4 and not manual:
300
+ return
301
+
302
+ # Keep the latest 10 messages, compress the rest. If manual, compress all but the last 2.
303
+ keep_count = 2 if manual else 10
304
+ if len(self.conversation_history) <= keep_count:
305
+ if manual:
306
+ console.print("[dim]Not enough history to compress.[/]")
307
+ return
308
+
309
+ messages_to_compress = self.conversation_history[:-keep_count]
310
+ retained_messages = self.conversation_history[-keep_count:]
311
+
312
+ history_text = ""
313
+ for msg in messages_to_compress:
314
+ role = msg.get("role", "unknown")
315
+ content = msg.get("content", "")
316
+ history_text += f"{role.upper()}: {content}\n\n"
317
+
318
+ prompt = (
319
+ "Summarize the following conversation history densely. "
320
+ "Retain all key technical facts, decisions, and context. "
321
+ "Ignore conversational filler. Be concise.\n\n"
322
+ f"{history_text}"
323
+ )
324
+
325
+ with console.status("[bold cyan]BHAI is compressing memory... 🗜️[/]"):
326
+ try:
327
+ kwargs = {
328
+ "model": self.config.vibe.model,
329
+ "messages": [{"role": "user", "content": prompt}],
330
+ "temperature": 0.3,
331
+ "max_tokens": 500,
332
+ }
333
+ if self.config.vibe.api_base:
334
+ kwargs["api_base"] = self.config.vibe.api_base
335
+ if self.config.vibe.api_key:
336
+ kwargs["api_key"] = self.config.vibe.api_key
337
+
338
+ response = await litellm.acompletion(**kwargs)
339
+ summary = (response.choices[0].message.content or "Conversation compressed.").strip()
340
+
341
+ self.conversation_history = [
342
+ {"role": "system", "content": f"Previous Conversation Summary:\n{summary}"}
343
+ ] + retained_messages
344
+
345
+ save_session(self.session_id, self.session_title, self.conversation_history)
346
+
347
+ if manual:
348
+ console.print(Panel(
349
+ f"[bold green]✅ Context compressed successfully.[/]\n\n[dim]Summary:\n{summary}[/]",
350
+ title="[bold cyan]Memory Compression[/]", border_style="cyan", padding=(1, 2)
351
+ ))
352
+ except Exception as e:
353
+ console.print(f"[bold red]⚠ Compression failed:[/] {e}")
354
+
243
355
  # ── Agentic Pipeline ──
244
356
 
245
357
  async def process(self, user_input: str) -> str:
@@ -0,0 +1,94 @@
1
+ """
2
+ Session Manager for BHAI-CLI.
3
+
4
+ Handles persistent conversation history across multiple sessions.
5
+ Stores sessions as JSON files in ~/.config/bhai/sessions/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import uuid
12
+ import datetime
13
+ from pathlib import Path
14
+
15
+ from bhai_cli.config_manager import CONFIG_DIR
16
+
17
+ SESSIONS_DIR = CONFIG_DIR / "sessions"
18
+
19
+ def _get_sessions_dir() -> Path:
20
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
21
+ return SESSIONS_DIR
22
+
23
+ def create_session_id() -> str:
24
+ """Generate a new short hash-based session ID."""
25
+ return uuid.uuid4().hex[:8]
26
+
27
+ def get_all_sessions() -> list[dict]:
28
+ """
29
+ Retrieve metadata for all saved sessions.
30
+ Returns a list of dicts: {"id": str, "title": str, "updated_at": str}
31
+ sorted by updated_at descending.
32
+ """
33
+ sessions_dir = _get_sessions_dir()
34
+ sessions = []
35
+
36
+ for file_path in sessions_dir.glob("*.json"):
37
+ try:
38
+ with open(file_path, "r", encoding="utf-8") as f:
39
+ data = json.load(f)
40
+ sessions.append({
41
+ "id": data.get("id", file_path.stem),
42
+ "title": data.get("title", "Untitled Session"),
43
+ "updated_at": data.get("updated_at", ""),
44
+ })
45
+ except Exception:
46
+ pass
47
+
48
+ sessions.sort(key=lambda x: x["updated_at"], reverse=True)
49
+ return sessions
50
+
51
+ def load_session(session_id: str) -> tuple[str, list[dict]]:
52
+ """
53
+ Load a session from disk.
54
+ Returns (title, history).
55
+ If session_id doesn't exist, returns ("", []).
56
+ """
57
+ file_path = _get_sessions_dir() / f"{session_id}.json"
58
+ if not file_path.exists():
59
+ return "", []
60
+
61
+ try:
62
+ with open(file_path, "r", encoding="utf-8") as f:
63
+ data = json.load(f)
64
+ return data.get("title", ""), data.get("history", [])
65
+ except Exception:
66
+ return "", []
67
+
68
+ def save_session(session_id: str, title: str, history: list[dict]) -> None:
69
+ """
70
+ Save the session history and title to disk.
71
+ """
72
+ file_path = _get_sessions_dir() / f"{session_id}.json"
73
+ now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
74
+
75
+ data = {
76
+ "id": session_id,
77
+ "title": title or "Untitled Session",
78
+ "updated_at": now_iso,
79
+ "history": history,
80
+ }
81
+
82
+ with open(file_path, "w", encoding="utf-8") as f:
83
+ json.dump(data, f, ensure_ascii=False, indent=2)
84
+
85
+ def delete_session(session_id: str) -> bool:
86
+ """Delete a session file. Returns True if deleted, False otherwise."""
87
+ file_path = _get_sessions_dir() / f"{session_id}.json"
88
+ if file_path.exists():
89
+ try:
90
+ file_path.unlink()
91
+ return True
92
+ except Exception:
93
+ return False
94
+ return False
@@ -86,7 +86,7 @@ TOOL_SCHEMAS: list[dict] = [
86
86
  },
87
87
  "include_content": {
88
88
  "type": "boolean",
89
- "description": "If true, include full file content (use sparingly). Default: false.",
89
+ "description": "If true, include full file content. Default: false. MUST BE A NATIVE JSON BOOLEAN (true/false), NOT A STRING.",
90
90
  },
91
91
  "max_depth": {
92
92
  "type": "integer",
@@ -195,7 +195,7 @@ TOOL_SCHEMAS: list[dict] = [
195
195
  },
196
196
  "recursive": {
197
197
  "type": "boolean",
198
- "description": "If true, list recursively. Default: false.",
198
+ "description": "If true, list recursively. Default: false. MUST BE A NATIVE JSON BOOLEAN (true/false), NOT A STRING.",
199
199
  },
200
200
  "max_depth": {
201
201
  "type": "integer",
File without changes
File without changes