pw-agent 0.3.1__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.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: pw-agent
3
+ Version: 0.3.1
4
+ Summary: CLI coding assistant powered by your Ollama GPUs via PastaWater
5
+ Home-page: https://pastawater.io
6
+ Author: PastaWater
7
+ Author-email: support@pastawater.io
8
+ Project-URL: Homepage, https://pastawater.io
9
+ Project-URL: GPU Setup, https://pastawater.io/gpu-setup
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.28.0
19
+ Requires-Dist: rich>=13.0.0
20
+ Requires-Dist: prompt_toolkit>=3.0.0
21
+ Dynamic: author
22
+ Dynamic: author-email
23
+ Dynamic: classifier
24
+ Dynamic: description
25
+ Dynamic: description-content-type
26
+ Dynamic: home-page
27
+ Dynamic: project-url
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
31
+
32
+ # PW Agent
33
+
34
+ CLI coding assistant powered by your Ollama GPUs via [PastaWater](https://pastawater.io).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install pw-agent
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ pw-agent
46
+ ```
47
+
48
+ First run guides you through setup — paste your API token, pick a GPU, start chatting.
49
+
50
+ ## Features
51
+
52
+ - Interactive REPL with streaming responses
53
+ - Tab autocomplete for commands and file paths
54
+ - Session persistence (resume where you left off)
55
+ - `/add file.py` or `@file.py` — inject files into context
56
+ - `/models` — view your GPU fleet
57
+ - `/switch N` — hot-switch between GPUs
58
+ - `/commit` — AI-generated git commit messages
59
+ - `-y` flag for auto-approve mode
60
+ - `-p "prompt"` for one-shot non-interactive mode
61
+
62
+ ## Connect
63
+
64
+ - **Cloud mode**: Use your PastaWater API token
65
+ - **Direct mode**: Point at a local Ollama instance (`--brain http://localhost:11434`)
66
+
67
+ Get your token at [pastawater.io/settings](https://pastawater.io/settings?tab=cli)
@@ -0,0 +1,36 @@
1
+ # PW Agent
2
+
3
+ CLI coding assistant powered by your Ollama GPUs via [PastaWater](https://pastawater.io).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install pw-agent
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ pw-agent
15
+ ```
16
+
17
+ First run guides you through setup — paste your API token, pick a GPU, start chatting.
18
+
19
+ ## Features
20
+
21
+ - Interactive REPL with streaming responses
22
+ - Tab autocomplete for commands and file paths
23
+ - Session persistence (resume where you left off)
24
+ - `/add file.py` or `@file.py` — inject files into context
25
+ - `/models` — view your GPU fleet
26
+ - `/switch N` — hot-switch between GPUs
27
+ - `/commit` — AI-generated git commit messages
28
+ - `-y` flag for auto-approve mode
29
+ - `-p "prompt"` for one-shot non-interactive mode
30
+
31
+ ## Connect
32
+
33
+ - **Cloud mode**: Use your PastaWater API token
34
+ - **Direct mode**: Point at a local Ollama instance (`--brain http://localhost:11434`)
35
+
36
+ Get your token at [pastawater.io/settings](https://pastawater.io/settings?tab=cli)
@@ -0,0 +1,363 @@
1
+ """ReAct agent loop — the brain of pw-agent."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from typing import Optional
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+ from llm_client import LLMClient
14
+ from tools import TOOL_DEFINITIONS, execute_tool
15
+ from config import save_session
16
+
17
+
18
+ MAX_ITERATIONS = 25
19
+ # ~4 chars per token is a reasonable estimate for most models
20
+ CHARS_PER_TOKEN = 4
21
+
22
+ # ─── Supported PastaWater Ollama models and their context limits ──────────
23
+ # These are the exact models available on the GPU Setup page.
24
+ # Context limits are the model's native max, but we apply a safe budget
25
+ # based on available VRAM to prevent OOM.
26
+ SUPPORTED_MODELS = {
27
+ # id native_ctx safe_ctx description
28
+ "gpt-oss:20b": {"native": 32768, "safe": 8192, "name": "GPT-OSS 20B", "vram_gb": 16},
29
+ "qwen2.5:14b": {"native": 32768, "safe": 8192, "name": "Qwen 2.5 14B", "vram_gb": 10},
30
+ "qwen3.5:4b": {"native": 32768, "safe": 16384, "name": "Qwen 3.5 4B", "vram_gb": 3.5},
31
+ "qwen3.5:2b": {"native": 32768, "safe": 16384, "name": "Qwen 3.5 2B", "vram_gb": 2},
32
+ }
33
+ # Fallback for unknown models
34
+ DEFAULT_SAFE_CONTEXT = 4096
35
+
36
+ # Reserve tokens for the model's response
37
+ RESPONSE_RESERVE = 2048
38
+
39
+ console = Console()
40
+
41
+
42
+ SYSTEM_PROMPT = """You are PW Agent — a helpful AI assistant running on the user's own GPU hardware via PastaWater. You can have natural conversations, answer questions, AND help with coding tasks.
43
+
44
+ Be yourself. Answer questions naturally. If the user asks about you, your model, your training, etc — answer honestly based on what you know. You're not limited to only coding topics.
45
+
46
+ When the user needs help with files or code, you have tools available. For casual conversation, just respond normally.
47
+
48
+ ## Project Context
49
+ Working directory: {cwd}
50
+ {git_context}
51
+
52
+ ## Tools (use only when needed for file/code tasks)
53
+
54
+ {tool_list}
55
+
56
+ When you need a tool, output EXACTLY this on its own line:
57
+ ACTION: {{"tool": "tool_name", "args": {{"param1": "value1"}}}}
58
+
59
+ Tool rules:
60
+ - ONE action at a time, then wait for the result.
61
+ - Read a file before editing it.
62
+ - For edit_file, old_str must match EXACTLY (including whitespace).
63
+ - After a RESULT, continue or use another tool.
64
+ - When done, respond normally without ACTION.
65
+ """
66
+
67
+
68
+ def _build_tool_list() -> str:
69
+ """Format tool definitions for the system prompt."""
70
+ lines = []
71
+ for tool in TOOL_DEFINITIONS:
72
+ params = ", ".join(
73
+ f"{k}: {v['type']}" for k, v in tool["parameters"].items()
74
+ )
75
+ lines.append(f"- {tool['name']}({params}) — {tool['description']}")
76
+ return "\n".join(lines)
77
+
78
+
79
+ def _get_git_context() -> str:
80
+ """Gather git status for the system prompt."""
81
+ parts = []
82
+ try:
83
+ branch = subprocess.run(
84
+ ["git", "branch", "--show-current"],
85
+ capture_output=True, text=True, timeout=5
86
+ )
87
+ if branch.returncode == 0 and branch.stdout.strip():
88
+ parts.append(f"Git branch: {branch.stdout.strip()}")
89
+
90
+ status = subprocess.run(
91
+ ["git", "status", "--short"],
92
+ capture_output=True, text=True, timeout=5
93
+ )
94
+ if status.returncode == 0 and status.stdout.strip():
95
+ changed = len(status.stdout.strip().split("\n"))
96
+ parts.append(f"Git status: {changed} changed files")
97
+
98
+ log = subprocess.run(
99
+ ["git", "log", "--oneline", "-5"],
100
+ capture_output=True, text=True, timeout=5
101
+ )
102
+ if log.returncode == 0 and log.stdout.strip():
103
+ parts.append(f"Recent commits:\n{log.stdout.strip()}")
104
+ except (subprocess.TimeoutExpired, FileNotFoundError):
105
+ pass
106
+
107
+ return "\n".join(parts) if parts else "Not a git repository"
108
+
109
+
110
+ def _build_messages(conversation: list[dict], cwd: str) -> list[dict]:
111
+ """Build Ollama chat messages from conversation history."""
112
+ git_ctx = _get_git_context()
113
+ system = SYSTEM_PROMPT.format(
114
+ cwd=cwd,
115
+ git_context=git_ctx,
116
+ tool_list=_build_tool_list(),
117
+ )
118
+
119
+ messages = [{"role": "system", "content": system}]
120
+
121
+ for msg in conversation:
122
+ role = msg["role"]
123
+ content = msg["content"]
124
+ if role == "user":
125
+ messages.append({"role": "user", "content": content})
126
+ elif role == "assistant":
127
+ messages.append({"role": "assistant", "content": content})
128
+ elif role == "tool_result":
129
+ messages.append({"role": "user", "content": content})
130
+
131
+ return messages
132
+
133
+
134
+ def _parse_tool_call(response: str) -> Optional[tuple[dict, str]]:
135
+ """Extract a tool call from the model's response.
136
+
137
+ Returns (tool_call_dict, text_before_action) or None.
138
+ """
139
+ match = re.search(r"ACTION:\s*(\{.*?\})\s*$", response, re.MULTILINE | re.DOTALL)
140
+ if not match:
141
+ match = re.search(r'ACTION:\s*(\{[^}]*"tool"[^}]*"args"[^}]*\{[^}]*\}[^}]*\})', response, re.DOTALL)
142
+ if not match:
143
+ return None
144
+
145
+ try:
146
+ call = json.loads(match.group(1))
147
+ if "tool" in call and "args" in call:
148
+ before = response[:match.start()].strip()
149
+ return call, before
150
+ except json.JSONDecodeError:
151
+ json_str = match.group(1).replace("'", '"')
152
+ try:
153
+ call = json.loads(json_str)
154
+ if "tool" in call and "args" in call:
155
+ before = response[:match.start()].strip()
156
+ return call, before
157
+ except json.JSONDecodeError:
158
+ pass
159
+
160
+ return None
161
+
162
+
163
+ def _estimate_tokens(text: str) -> int:
164
+ """Rough token estimate (~4 chars per token)."""
165
+ return len(text) // CHARS_PER_TOKEN + 1
166
+
167
+
168
+ def _get_context_budget(model_name: str) -> int:
169
+ """Get the safe input token budget for a model.
170
+
171
+ Uses the 'safe' context limit (conservative for VRAM) minus response reserve.
172
+ Larger models use more VRAM per token, so their safe limit is lower than native.
173
+ """
174
+ model_lower = model_name.lower().strip()
175
+ # Exact match first
176
+ if model_lower in SUPPORTED_MODELS:
177
+ return SUPPORTED_MODELS[model_lower]["safe"] - RESPONSE_RESERVE
178
+ # Prefix match (e.g. "qwen3.5" matches "qwen3.5:4b")
179
+ for model_id, info in SUPPORTED_MODELS.items():
180
+ base = model_id.split(":")[0]
181
+ if model_lower.startswith(base):
182
+ return info["safe"] - RESPONSE_RESERVE
183
+ return DEFAULT_SAFE_CONTEXT - RESPONSE_RESERVE
184
+
185
+
186
+ def _truncate_history(conversation: list[dict], model: str = "default") -> list[dict]:
187
+ """Sliding window: keep recent turns within the model's context budget.
188
+
189
+ Always preserves:
190
+ 1. The first user message (original task context)
191
+ 2. The most recent turns (working memory)
192
+ Drops middle messages when the budget is exceeded.
193
+ """
194
+ if len(conversation) <= 3:
195
+ return conversation
196
+
197
+ budget = _get_context_budget(model)
198
+ total_tokens = sum(_estimate_tokens(m["content"]) for m in conversation)
199
+
200
+ if total_tokens <= budget:
201
+ return conversation
202
+
203
+ # Always keep the first message
204
+ first = conversation[0]
205
+ first_tokens = _estimate_tokens(first["content"])
206
+ remaining_budget = budget - first_tokens
207
+
208
+ # Walk backwards from the end, adding messages until budget is hit
209
+ remaining = conversation[1:]
210
+ kept = []
211
+ used = 0
212
+ for msg in reversed(remaining):
213
+ msg_tokens = _estimate_tokens(msg["content"])
214
+ if used + msg_tokens > remaining_budget:
215
+ break
216
+ kept.insert(0, msg)
217
+ used += msg_tokens
218
+
219
+ dropped = len(remaining) - len(kept)
220
+ result = [first]
221
+ if dropped > 0:
222
+ result.append({"role": "tool_result", "content": f"[... {dropped} earlier messages truncated to fit context window ...]"})
223
+ result.extend(kept)
224
+ return result
225
+
226
+
227
+ class Agent:
228
+ """ReAct agent that uses an LLM to perform coding tasks."""
229
+
230
+ def __init__(self, client: LLMClient, stream: bool = True):
231
+ self.client = client
232
+ self.stream = stream
233
+ self.conversation: list[dict] = []
234
+ self.cwd = os.getcwd()
235
+ self.files_in_context: list[str] = []
236
+
237
+ def run(self, user_input: str) -> None:
238
+ """Process a user message through the ReAct loop."""
239
+ self.conversation.append({"role": "user", "content": user_input})
240
+
241
+ for iteration in range(MAX_ITERATIONS):
242
+ trimmed = _truncate_history(self.conversation, self.client.model)
243
+ messages = _build_messages(trimmed, self.cwd)
244
+
245
+ # Get response (streaming or not)
246
+ if self.stream:
247
+ response = self._stream_response(messages)
248
+ else:
249
+ with console.status("[cyan]Thinking...", spinner="dots"):
250
+ response = self.client.chat(messages)
251
+
252
+ if not response or response.startswith("[Error:"):
253
+ console.print(f" [red]{response or '[Empty response from model]'}[/red]")
254
+ return
255
+
256
+ # Check for tool call
257
+ parsed = _parse_tool_call(response)
258
+
259
+ if parsed:
260
+ tool_call, thinking = parsed
261
+ tool_name = tool_call["tool"]
262
+ tool_args = tool_call["args"]
263
+
264
+ # Print thinking
265
+ if thinking:
266
+ console.print()
267
+ console.print(Markdown(thinking))
268
+
269
+ # Print tool call
270
+ args_display = []
271
+ for k, v in tool_args.items():
272
+ if isinstance(v, str) and len(v) > 80:
273
+ args_display.append(f'{k}="...{len(v)} chars..."')
274
+ else:
275
+ args_display.append(f'{k}="{v}"' if isinstance(v, str) else f'{k}={v}')
276
+ args_str = ", ".join(args_display)
277
+ console.print(f" [cyan]> {tool_name}({args_str})[/cyan]")
278
+
279
+ # Execute tool
280
+ result = execute_tool(tool_name, tool_args)
281
+
282
+ # Print result
283
+ display = result[:800] + "..." if len(result) > 800 else result
284
+ if tool_name == "read_file" and len(result) > 200:
285
+ # Show file content with line numbers
286
+ console.print(f" [dim]{display}[/dim]")
287
+ else:
288
+ console.print(f" [dim]{display}[/dim]")
289
+
290
+ # Add to conversation
291
+ if thinking:
292
+ self.conversation.append({"role": "assistant", "content": thinking})
293
+ self.conversation.append({"role": "assistant", "content": f"ACTION: used {tool_name}"})
294
+ self.conversation.append({"role": "tool_result", "content": f"RESULT of {tool_name}:\n{result}"})
295
+
296
+ else:
297
+ # Final answer
298
+ self.conversation.append({"role": "assistant", "content": response})
299
+ if not self.stream:
300
+ console.print()
301
+ console.print(Markdown(response))
302
+ self._auto_save()
303
+ return
304
+
305
+ console.print(f" [yellow][Reached max iterations ({MAX_ITERATIONS}), stopping][/yellow]")
306
+ self._auto_save()
307
+
308
+ def _stream_response(self, messages: list[dict]) -> str:
309
+ """Stream response tokens to terminal, return full text."""
310
+ chunks = []
311
+ console.print()
312
+ for chunk in self.client.chat_stream(messages):
313
+ chunks.append(chunk)
314
+ # Print raw chunks for streaming effect
315
+ console.print(chunk, end="", highlight=False)
316
+
317
+ full = "".join(chunks)
318
+ console.print() # newline after stream
319
+
320
+ # If it contains a tool call, re-render won't be needed (agent loop handles it)
321
+ return full
322
+
323
+ def _auto_save(self):
324
+ """Save session to disk after each turn."""
325
+ try:
326
+ save_session(self.conversation, self.cwd)
327
+ except Exception:
328
+ pass
329
+
330
+ def load_session(self, conversation: list[dict]):
331
+ """Resume a previous session."""
332
+ self.conversation = conversation
333
+ turns = sum(1 for m in conversation if m["role"] == "user")
334
+ console.print(f" [dim]Resumed session ({turns} turns)[/dim]")
335
+
336
+ def add_file(self, path: str):
337
+ """Add a file's contents to the conversation context."""
338
+ full = os.path.abspath(path)
339
+ if not os.path.exists(full):
340
+ console.print(f" [red]File not found: {path}[/red]")
341
+ return
342
+ try:
343
+ with open(full, "r", encoding="utf-8", errors="replace") as f:
344
+ content = f.read()
345
+ # Truncate large files
346
+ if len(content) > 15000:
347
+ content = content[:15000] + f"\n... [truncated, {len(content)} chars total]"
348
+ self.conversation.append({
349
+ "role": "user",
350
+ "content": f"[File added to context: {path}]\n```\n{content}\n```"
351
+ })
352
+ self.files_in_context.append(path)
353
+ console.print(f" [green]+ {path}[/green] [dim]({len(content)} chars)[/dim]")
354
+ except Exception as e:
355
+ console.print(f" [red]Error reading {path}: {e}[/red]")
356
+
357
+ def reset(self):
358
+ """Clear conversation history and session."""
359
+ self.conversation = []
360
+ self.files_in_context = []
361
+ from config import clear_session
362
+ clear_session(self.cwd)
363
+ console.print(" [dim]Conversation cleared.[/dim]")
@@ -0,0 +1,88 @@
1
+ """Config management — persists token, slot, model preferences."""
2
+
3
+ import json
4
+ import os
5
+
6
+ DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/pw-agent")
7
+ CONFIG_FILE = "config.json"
8
+
9
+
10
+ def _config_path(config_dir: str = "") -> str:
11
+ d = config_dir or DEFAULT_CONFIG_DIR
12
+ return os.path.join(d, CONFIG_FILE)
13
+
14
+
15
+ def load_config(config_dir: str = "") -> dict:
16
+ """Load saved config, or return empty dict."""
17
+ path = _config_path(config_dir)
18
+ if not os.path.exists(path):
19
+ return {}
20
+ try:
21
+ with open(path, "r") as f:
22
+ return json.load(f)
23
+ except (json.JSONDecodeError, OSError):
24
+ return {}
25
+
26
+
27
+ def save_config(data: dict, config_dir: str = "") -> str:
28
+ """Save config and return the file path."""
29
+ d = config_dir or DEFAULT_CONFIG_DIR
30
+ os.makedirs(d, exist_ok=True)
31
+ path = os.path.join(d, CONFIG_FILE)
32
+ with open(path, "w") as f:
33
+ json.dump(data, f, indent=2)
34
+ return path
35
+
36
+
37
+ def has_config(config_dir: str = "") -> bool:
38
+ """Check if a config file exists."""
39
+ return os.path.exists(_config_path(config_dir))
40
+
41
+
42
+ # ─── Session persistence ─────────────────────────────────────────────────────
43
+
44
+ def _sessions_dir(config_dir: str = "") -> str:
45
+ d = config_dir or DEFAULT_CONFIG_DIR
46
+ return os.path.join(d, "sessions")
47
+
48
+
49
+ def save_session(conversation: list[dict], cwd: str, config_dir: str = "") -> str:
50
+ """Save conversation to a session file. Returns the path."""
51
+ d = _sessions_dir(config_dir)
52
+ os.makedirs(d, exist_ok=True)
53
+ # Use cwd hash as session key so each project has its own session
54
+ import hashlib
55
+ key = hashlib.md5(cwd.encode()).hexdigest()[:10]
56
+ path = os.path.join(d, f"{key}.json")
57
+ data = {"cwd": cwd, "conversation": conversation}
58
+ with open(path, "w") as f:
59
+ json.dump(data, f)
60
+ return path
61
+
62
+
63
+ def load_session(cwd: str, config_dir: str = "") -> list[dict]:
64
+ """Load the session for a given cwd, or return empty list."""
65
+ d = _sessions_dir(config_dir)
66
+ import hashlib
67
+ key = hashlib.md5(cwd.encode()).hexdigest()[:10]
68
+ path = os.path.join(d, f"{key}.json")
69
+ if not os.path.exists(path):
70
+ return []
71
+ try:
72
+ with open(path, "r") as f:
73
+ data = json.load(f)
74
+ if data.get("cwd") == cwd:
75
+ return data.get("conversation", [])
76
+ except (json.JSONDecodeError, OSError):
77
+ pass
78
+ return []
79
+
80
+
81
+ def clear_session(cwd: str, config_dir: str = ""):
82
+ """Delete the session for a given cwd."""
83
+ d = _sessions_dir(config_dir)
84
+ import hashlib
85
+ key = hashlib.md5(cwd.encode()).hexdigest()[:10]
86
+ path = os.path.join(d, f"{key}.json")
87
+ if os.path.exists(path):
88
+ os.remove(path)