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.
- pw_agent-0.3.1/PKG-INFO +67 -0
- pw_agent-0.3.1/README.md +36 -0
- pw_agent-0.3.1/agent.py +363 -0
- pw_agent-0.3.1/config.py +88 -0
- pw_agent-0.3.1/llm_client.py +234 -0
- pw_agent-0.3.1/pw_agent.egg-info/PKG-INFO +67 -0
- pw_agent-0.3.1/pw_agent.egg-info/SOURCES.txt +13 -0
- pw_agent-0.3.1/pw_agent.egg-info/dependency_links.txt +1 -0
- pw_agent-0.3.1/pw_agent.egg-info/entry_points.txt +2 -0
- pw_agent-0.3.1/pw_agent.egg-info/requires.txt +3 -0
- pw_agent-0.3.1/pw_agent.egg-info/top_level.txt +5 -0
- pw_agent-0.3.1/pw_agent.py +547 -0
- pw_agent-0.3.1/setup.cfg +4 -0
- pw_agent-0.3.1/setup.py +36 -0
- pw_agent-0.3.1/tools.py +234 -0
pw_agent-0.3.1/PKG-INFO
ADDED
|
@@ -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)
|
pw_agent-0.3.1/README.md
ADDED
|
@@ -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)
|
pw_agent-0.3.1/agent.py
ADDED
|
@@ -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]")
|
pw_agent-0.3.1/config.py
ADDED
|
@@ -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)
|