lambda-agent 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.
lambda_agent/main.py ADDED
@@ -0,0 +1,318 @@
1
+ from .agent import Agent, TokenUsage
2
+ from . import config
3
+ from .spinner import console
4
+ import os
5
+ import getpass
6
+ from pathlib import Path
7
+
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+ from rich.rule import Rule
11
+ from rich.markdown import Markdown
12
+ from rich.prompt import Prompt
13
+ from rich import box
14
+ from rich.align import Align
15
+ from rich.table import Table
16
+
17
+
18
+ BANNER = r"""
19
+ ██╗ █████╗ ███╗ ███╗██████╗ ██████╗ █████╗
20
+ ██║ ██╔══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗
21
+ ██║ ███████║██╔████╔██║██████╔╝██║ ██║███████║
22
+ ██║ ██╔══██║██║╚██╔╝██║██╔══██╗██║ ██║██╔══██║
23
+ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝██████╔╝██║ ██║
24
+ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝
25
+ """
26
+
27
+
28
+ SLASH_COMMANDS = {
29
+ "/models": "List available models and switch between them",
30
+ "/config": "Update API key and save to config",
31
+ "/help": "Show available slash commands",
32
+ }
33
+
34
+
35
+ def print_banner():
36
+ banner_text = Text(BANNER, style="bold cyan", justify="center")
37
+ subtitle = Text(
38
+ " Minimal AI Coding Agent · Type '/help' for commands ",
39
+ style="dim white",
40
+ justify="center",
41
+ )
42
+
43
+ panel = Panel(
44
+ Align.center(Text.assemble(banner_text, "\n", subtitle)),
45
+ border_style="cyan",
46
+ box=box.DOUBLE_EDGE,
47
+ padding=(0, 2),
48
+ )
49
+ console.print(panel)
50
+
51
+
52
+ def print_user_message(text: str):
53
+ label = Text(" YOU ", style="bold black on bright_yellow")
54
+ content = Text(f" {text}", style="bright_white")
55
+ console.print()
56
+ console.print(Text.assemble(label, content))
57
+
58
+
59
+ def print_lambda_message(text: str):
60
+ console.print()
61
+ label = Text(" LAMBDA ", style="bold black on cyan")
62
+ console.print(label)
63
+ console.print(
64
+ Panel(
65
+ Markdown(text),
66
+ border_style="cyan",
67
+ box=box.ROUNDED,
68
+ padding=(0, 2),
69
+ )
70
+ )
71
+
72
+
73
+ def print_token_stats(turn: TokenUsage, session: TokenUsage):
74
+ """Render a compact token usage line under the Lambda response."""
75
+ console.print(
76
+ Text.assemble(
77
+ (" ▶ tokens ", "dim"),
78
+ ("this turn: ", "dim"),
79
+ (f"↑{turn.prompt:,}", "dim cyan"),
80
+ (" in ", "dim"),
81
+ (f"↓{turn.completion:,}", "dim cyan"),
82
+ (" out ", "dim"),
83
+ ("session total: ", "dim"),
84
+ (f"{session.total:,}", "bold cyan"),
85
+ (" tokens", "dim"),
86
+ )
87
+ )
88
+
89
+
90
+ def handle_models_command(agent: Agent):
91
+ """Display available models and let the user pick one."""
92
+ table = Table(
93
+ title="Available Models",
94
+ title_style="bold cyan",
95
+ border_style="cyan",
96
+ box=box.ROUNDED,
97
+ padding=(0, 2),
98
+ )
99
+ table.add_column("#", style="dim", width=4)
100
+ table.add_column("Model", style="white")
101
+ table.add_column("Status", justify="center")
102
+
103
+ models = config.AVAILABLE_MODELS
104
+ for i, model in enumerate(models, 1):
105
+ is_active = model == agent.model_name
106
+ status = "[bold green]● active[/bold green]" if is_active else "[dim]—[/dim]"
107
+ name_style = "bold cyan" if is_active else "white"
108
+ table.add_row(str(i), f"[{name_style}]{model}[/{name_style}]", status)
109
+
110
+ console.print()
111
+ console.print(table)
112
+ console.print()
113
+
114
+ choice = Prompt.ask(
115
+ "[bold bright_yellow] Select model #[/bold bright_yellow] (or Enter to cancel)",
116
+ default="",
117
+ console=console,
118
+ )
119
+
120
+ if not choice.strip():
121
+ console.print(" [dim]No change.[/dim]")
122
+ return
123
+
124
+ try:
125
+ idx = int(choice) - 1
126
+ if 0 <= idx < len(models):
127
+ selected = models[idx]
128
+ if selected == agent.model_name:
129
+ console.print(f" [dim]Already using[/dim] [cyan]{selected}[/cyan]")
130
+ else:
131
+ msg = agent.switch_model(selected)
132
+ console.print(f" {msg}")
133
+ else:
134
+ console.print(" [red]Invalid selection.[/red]")
135
+ except ValueError:
136
+ console.print(" [red]Please enter a number.[/red]")
137
+
138
+
139
+ def handle_help_command():
140
+ """Show available slash commands."""
141
+ table = Table(
142
+ title="Slash Commands",
143
+ title_style="bold cyan",
144
+ border_style="cyan",
145
+ box=box.ROUNDED,
146
+ padding=(0, 1),
147
+ )
148
+ table.add_column("Command", style="bold bright_yellow", min_width=12)
149
+ table.add_column("Description", style="white")
150
+
151
+ for cmd, desc in SLASH_COMMANDS.items():
152
+ table.add_row(cmd, desc)
153
+
154
+ # Also list the built-in exit commands
155
+ table.add_row("exit / quit", "End the session")
156
+
157
+ console.print()
158
+ console.print(table)
159
+
160
+
161
+ def handle_config_command(agent: Agent):
162
+ """Let the user update their API key mid-session."""
163
+ console.print()
164
+ console.print(
165
+ Panel(
166
+ Text.assemble(
167
+ ("Current API key: ", "dim"),
168
+ (f"{config.API_KEY[:8]}...{config.API_KEY[-4:]}", "cyan"),
169
+ ),
170
+ border_style="cyan",
171
+ box=box.ROUNDED,
172
+ title="[bold cyan]⚙ Configuration[/bold cyan]",
173
+ title_align="left",
174
+ )
175
+ )
176
+ console.print()
177
+
178
+ new_key = getpass.getpass(" Enter new API key (or press Enter to keep current): ")
179
+
180
+ if not new_key.strip():
181
+ console.print(" [dim]No change.[/dim]")
182
+ return
183
+
184
+ # Update in-memory config
185
+ config.API_KEY = new_key.strip()
186
+ os.environ["API_KEY"] = config.API_KEY
187
+
188
+ # Re-create the API client with the new key
189
+ from google import genai
190
+
191
+ agent.client = genai.Client(api_key=config.API_KEY)
192
+
193
+ # Re-create the chat session so the new client is used
194
+ from google.genai import types
195
+ from .tools import TOOL_FUNCTIONS
196
+
197
+ agent.chat_session = agent.client.chats.create(
198
+ model=agent.model_name,
199
+ config=types.GenerateContentConfig(
200
+ system_instruction=agent.system_instruction,
201
+ tools=TOOL_FUNCTIONS,
202
+ automatic_function_calling=types.AutomaticFunctionCallingConfig(
203
+ disable=True
204
+ ),
205
+ ),
206
+ )
207
+ agent.is_first_message = True
208
+
209
+ # Persist to config file
210
+ config_file = Path.home() / ".config" / "lambda-agent" / "config.env"
211
+ try:
212
+ config_file.parent.mkdir(parents=True, exist_ok=True)
213
+ with open(config_file, "w") as f:
214
+ f.write(f"API_KEY={config.API_KEY}\n")
215
+ f.write(f"MODEL_NAME={config.MODEL_NAME}\n")
216
+ os.chmod(config_file, 0o600)
217
+ console.print(" [green]✓[/green] API key updated and saved to config.")
218
+ except Exception as e:
219
+ console.print(" [green]✓[/green] API key updated in memory.")
220
+ console.print(f" [yellow]⚠[/yellow] Could not save to disk: {e}")
221
+
222
+
223
+ def main():
224
+ print_banner()
225
+
226
+ try:
227
+ if not config.API_KEY:
228
+ from .cli_setup import run_setup
229
+
230
+ config.API_KEY, config.MODEL_NAME = run_setup()
231
+ os.environ["API_KEY"] = config.API_KEY
232
+ os.environ["MODEL_NAME"] = config.MODEL_NAME
233
+
234
+ agent = Agent()
235
+
236
+ console.print(
237
+ Rule("[bold cyan]Session Started[/bold cyan]", style="cyan"),
238
+ )
239
+
240
+ while True:
241
+ try:
242
+ # Styled prompt — uses plain input to keep cursor on same line
243
+ user_input = Prompt.ask(
244
+ "\n[bold bright_yellow] You[/bold bright_yellow]",
245
+ console=console,
246
+ )
247
+
248
+ if user_input.lower() in ["exit", "quit"]:
249
+ console.print()
250
+ # Show session token summary before quitting
251
+ if agent.token_usage.total > 0:
252
+ console.print(
253
+ Panel(
254
+ Text.assemble(
255
+ ("Session token usage\n", "bold white"),
256
+ (" Prompt (in): ", "dim"),
257
+ (f"{agent.token_usage.prompt:>10,}\n", "cyan"),
258
+ (" Completion (out): ", "dim"),
259
+ (f"{agent.token_usage.completion:>10,}\n", "cyan"),
260
+ (" Total: ", "dim"),
261
+ (f"{agent.token_usage.total:>10,}", "bold cyan"),
262
+ ),
263
+ border_style="cyan",
264
+ box=box.ROUNDED,
265
+ title="[bold cyan]⚡ Token Summary[/bold cyan]",
266
+ title_align="left",
267
+ )
268
+ )
269
+ console.print(
270
+ Panel(
271
+ "[bold cyan]Goodbye! Lambda signing off.[/bold cyan]",
272
+ border_style="cyan",
273
+ box=box.ROUNDED,
274
+ )
275
+ )
276
+ break
277
+
278
+ if not user_input.strip():
279
+ continue
280
+
281
+ # Handle slash commands
282
+ if user_input.strip().lower() == "/models":
283
+ handle_models_command(agent)
284
+ continue
285
+ elif user_input.strip().lower() == "/config":
286
+ handle_config_command(agent)
287
+ continue
288
+ elif user_input.strip().lower() == "/help":
289
+ handle_help_command()
290
+ continue
291
+ elif user_input.strip().startswith("/"):
292
+ console.print(
293
+ f" [red]Unknown command:[/red] {user_input.strip()} "
294
+ "[dim]Type /help for available commands.[/dim]"
295
+ )
296
+ continue
297
+
298
+ response, turn_usage = agent.chat(user_input)
299
+ print_lambda_message(response)
300
+ print_token_stats(turn_usage, agent.token_usage)
301
+
302
+ except KeyboardInterrupt:
303
+ console.print()
304
+ console.print("[bold cyan]\nGoodbye![/bold cyan]")
305
+ break
306
+
307
+ except Exception as e:
308
+ console.print(
309
+ Panel(
310
+ f"[bold red]Failed to initialize Lambda:[/bold red]\n{str(e)}",
311
+ border_style="red",
312
+ box=box.ROUNDED,
313
+ )
314
+ )
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
@@ -0,0 +1,139 @@
1
+ """
2
+ Scratchpad Module
3
+ =================
4
+ Provides tools for the agent to maintain a persistent, human-readable scratchpad
5
+ file (.lambda_scratchpad.md) in the user's working directory.
6
+
7
+ The scratchpad lets the agent plan complex tasks, track progress, and keep
8
+ implementation notes — all visible to the user as a markdown file in the repo.
9
+ """
10
+
11
+ import os
12
+ from datetime import datetime
13
+
14
+ AGENT_DIR = ".agent"
15
+ SCRATCHPAD_FILE = os.path.join(AGENT_DIR, "scratchpad.md")
16
+
17
+ _HEADER_TEMPLATE = """\
18
+ <!-- This file is managed by the Lambda coding agent. -->
19
+ <!-- Feel free to read it, but edits may be overwritten by the agent. -->
20
+
21
+ # Lambda Scratchpad
22
+
23
+ """
24
+
25
+
26
+ def _ensure_scratchpad() -> str:
27
+ """Return the absolute path to the scratchpad, creating it if it doesn't exist."""
28
+ agent_dir = os.path.abspath(AGENT_DIR)
29
+ os.makedirs(agent_dir, exist_ok=True)
30
+ path = os.path.abspath(SCRATCHPAD_FILE)
31
+ if not os.path.exists(path):
32
+ with open(path, "w", encoding="utf-8") as f:
33
+ f.write(_HEADER_TEMPLATE)
34
+ return path
35
+
36
+
37
+ def read_scratchpad() -> str:
38
+ """Reads the full contents of the Lambda scratchpad file (.lambda_scratchpad.md).
39
+
40
+ Use this to recall your previous plans, task lists, and implementation notes.
41
+ """
42
+ path = _ensure_scratchpad()
43
+ try:
44
+ with open(path, "r", encoding="utf-8") as f:
45
+ return f.read()
46
+ except Exception as e:
47
+ return f"Error reading scratchpad: {e}"
48
+
49
+
50
+ def write_scratchpad(content: str) -> str:
51
+ """Overwrites the entire Lambda scratchpad file with the provided content.
52
+
53
+ Use this when you need to replace the scratchpad with a fresh plan or when
54
+ starting a new major task. For incremental updates, prefer update_scratchpad.
55
+
56
+ Args:
57
+ content: The full markdown content to write to the scratchpad.
58
+ """
59
+ path = _ensure_scratchpad()
60
+ try:
61
+ with open(path, "w", encoding="utf-8") as f:
62
+ f.write(_HEADER_TEMPLATE + content)
63
+ return f"Scratchpad written successfully → {path}"
64
+ except Exception as e:
65
+ return f"Error writing scratchpad: {e}"
66
+
67
+
68
+ def update_scratchpad(note: str, section: str = "Notes") -> str:
69
+ """Appends a timestamped note to a specific section in the scratchpad.
70
+
71
+ This is ideal for incrementally logging progress, decisions, and discoveries
72
+ without replacing existing content.
73
+
74
+ Args:
75
+ note: The text to append (supports markdown).
76
+ section: The section heading to append under (e.g. 'Plan', 'Progress', 'Notes').
77
+ """
78
+ path = _ensure_scratchpad()
79
+ try:
80
+ with open(path, "r", encoding="utf-8") as f:
81
+ existing = f.read()
82
+
83
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
84
+ entry = f"\n- **[{timestamp}]** {note}"
85
+
86
+ section_heading = f"## {section}"
87
+ if section_heading in existing:
88
+ # Append under the existing section
89
+ parts = existing.split(section_heading, 1)
90
+ # Find the next section heading (##) or end of file
91
+ rest = parts[1]
92
+ next_section = rest.find("\n## ")
93
+ if next_section == -1:
94
+ # No next section — just append at the end
95
+ updated = existing + entry
96
+ else:
97
+ # Insert before the next section
98
+ insert_pos = len(parts[0]) + len(section_heading) + next_section
99
+ updated = existing[:insert_pos] + entry + "\n" + existing[insert_pos:]
100
+ else:
101
+ # Create the section at the end
102
+ updated = existing.rstrip() + f"\n\n{section_heading}\n{entry}\n"
103
+
104
+ with open(path, "w", encoding="utf-8") as f:
105
+ f.write(updated)
106
+
107
+ return f"Scratchpad updated (section: {section}) → {path}"
108
+ except Exception as e:
109
+ return f"Error updating scratchpad: {e}"
110
+
111
+
112
+ def clear_scratchpad() -> str:
113
+ """Clears the scratchpad, resetting it to a blank state.
114
+
115
+ Use this when a major task is fully complete and the scratchpad is no longer needed.
116
+ """
117
+ path = _ensure_scratchpad()
118
+ try:
119
+ with open(path, "w", encoding="utf-8") as f:
120
+ f.write(_HEADER_TEMPLATE)
121
+ return f"Scratchpad cleared → {path}"
122
+ except Exception as e:
123
+ return f"Error clearing scratchpad: {e}"
124
+
125
+
126
+ # Tool registrations for the agent
127
+ SCRATCHPAD_EXECUTORS = {
128
+ "read_scratchpad": read_scratchpad,
129
+ "write_scratchpad": write_scratchpad,
130
+ "update_scratchpad": update_scratchpad,
131
+ "clear_scratchpad": clear_scratchpad,
132
+ }
133
+
134
+ SCRATCHPAD_FUNCTIONS = [
135
+ read_scratchpad,
136
+ write_scratchpad,
137
+ update_scratchpad,
138
+ clear_scratchpad,
139
+ ]
@@ -0,0 +1,50 @@
1
+ import random
2
+ from rich.console import Console
3
+ from rich.spinner import Spinner as RichSpinner
4
+ from rich.live import Live
5
+ from rich.text import Text
6
+
7
+ # Shared console instance used across the whole app
8
+ console = Console()
9
+
10
+ QUOTES = [
11
+ "Consulting the mainframe…",
12
+ "Synthesizing logic…",
13
+ "Bending the matrix…",
14
+ "Drinking virtual coffee…",
15
+ "Compiling thoughts…",
16
+ "Evaluating your code…",
17
+ "Simulating outcomes…",
18
+ "Reversing the polarity…",
19
+ "Aligning the vectors…",
20
+ "Traversing the graph…",
21
+ "Reading your source code…",
22
+ "Assembling the bytes…",
23
+ ]
24
+
25
+
26
+ class Spinner:
27
+ """Context manager that shows a rich animated spinner while Lambda is thinking."""
28
+
29
+ def __init__(self, message: str = "Thinking"):
30
+ self._label = random.choice(QUOTES) if random.random() > 0.3 else message
31
+ self._live = None
32
+
33
+ def __enter__(self):
34
+ renderable = RichSpinner(
35
+ "dots",
36
+ text=Text(f" {self._label}", style="dim cyan italic"),
37
+ style="bold cyan",
38
+ )
39
+ self._live = Live(
40
+ renderable,
41
+ console=console,
42
+ refresh_per_second=15,
43
+ transient=True,
44
+ )
45
+ self._live.__enter__()
46
+ return self
47
+
48
+ def __exit__(self, exc_type, exc_val, exc_tb):
49
+ if self._live:
50
+ self._live.__exit__(exc_type, exc_val, exc_tb)