autoforge-ai 0.1.0
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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant Chat Session
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Manages read-only conversational assistant sessions for projects.
|
|
6
|
+
The assistant can answer questions about the codebase and features
|
|
7
|
+
but cannot modify any files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import AsyncGenerator, Optional
|
|
19
|
+
|
|
20
|
+
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
|
|
23
|
+
from .assistant_database import (
|
|
24
|
+
add_message,
|
|
25
|
+
create_conversation,
|
|
26
|
+
get_messages,
|
|
27
|
+
)
|
|
28
|
+
from .chat_constants import API_ENV_VARS, ROOT_DIR
|
|
29
|
+
|
|
30
|
+
# Load environment variables from .env file if present
|
|
31
|
+
load_dotenv()
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Read-only feature MCP tools
|
|
36
|
+
READONLY_FEATURE_MCP_TOOLS = [
|
|
37
|
+
"mcp__features__feature_get_stats",
|
|
38
|
+
"mcp__features__feature_get_by_id",
|
|
39
|
+
"mcp__features__feature_get_ready",
|
|
40
|
+
"mcp__features__feature_get_blocked",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Feature management tools (create/skip but not mark_passing)
|
|
44
|
+
FEATURE_MANAGEMENT_TOOLS = [
|
|
45
|
+
"mcp__features__feature_create",
|
|
46
|
+
"mcp__features__feature_create_bulk",
|
|
47
|
+
"mcp__features__feature_skip",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Combined list for assistant
|
|
51
|
+
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS
|
|
52
|
+
|
|
53
|
+
# Read-only built-in tools (no Write, Edit, Bash)
|
|
54
|
+
READONLY_BUILTIN_TOOLS = [
|
|
55
|
+
"Read",
|
|
56
|
+
"Glob",
|
|
57
|
+
"Grep",
|
|
58
|
+
"WebFetch",
|
|
59
|
+
"WebSearch",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
|
64
|
+
"""Generate the system prompt for the assistant with project context."""
|
|
65
|
+
# Try to load app_spec.txt for context
|
|
66
|
+
app_spec_content = ""
|
|
67
|
+
from autoforge_paths import get_prompts_dir
|
|
68
|
+
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
|
69
|
+
if app_spec_path.exists():
|
|
70
|
+
try:
|
|
71
|
+
app_spec_content = app_spec_path.read_text(encoding="utf-8")
|
|
72
|
+
# Truncate if too long
|
|
73
|
+
if len(app_spec_content) > 5000:
|
|
74
|
+
app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning(f"Failed to read app_spec.txt: {e}")
|
|
77
|
+
|
|
78
|
+
return f"""You are a helpful project assistant and backlog manager for the "{project_name}" project.
|
|
79
|
+
|
|
80
|
+
Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.
|
|
81
|
+
|
|
82
|
+
You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools.
|
|
83
|
+
|
|
84
|
+
## What You CAN Do
|
|
85
|
+
|
|
86
|
+
**Codebase Analysis (Read-Only):**
|
|
87
|
+
- Read and analyze source code files
|
|
88
|
+
- Search for patterns in the codebase
|
|
89
|
+
- Look up documentation online
|
|
90
|
+
- Check feature progress and status
|
|
91
|
+
|
|
92
|
+
**Feature Management:**
|
|
93
|
+
- Create new features/test cases in the backlog
|
|
94
|
+
- Skip features to deprioritize them (move to end of queue)
|
|
95
|
+
- View feature statistics and progress
|
|
96
|
+
|
|
97
|
+
## What You CANNOT Do
|
|
98
|
+
|
|
99
|
+
- Modify, create, or delete source code files
|
|
100
|
+
- Mark features as passing (that requires actual implementation by the coding agent)
|
|
101
|
+
- Run bash commands or execute code
|
|
102
|
+
|
|
103
|
+
If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation.
|
|
104
|
+
|
|
105
|
+
## Project Specification
|
|
106
|
+
|
|
107
|
+
{app_spec_content if app_spec_content else "(No app specification found)"}
|
|
108
|
+
|
|
109
|
+
## Available Tools
|
|
110
|
+
|
|
111
|
+
**Code Analysis:**
|
|
112
|
+
- **Read**: Read file contents
|
|
113
|
+
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
|
|
114
|
+
- **Grep**: Search file contents with regex
|
|
115
|
+
- **WebFetch/WebSearch**: Look up documentation online
|
|
116
|
+
|
|
117
|
+
**Feature Management:**
|
|
118
|
+
- **feature_get_stats**: Get feature completion progress
|
|
119
|
+
- **feature_get_by_id**: Get details for a specific feature
|
|
120
|
+
- **feature_get_ready**: See features ready for implementation
|
|
121
|
+
- **feature_get_blocked**: See features blocked by dependencies
|
|
122
|
+
- **feature_create**: Create a single feature in the backlog
|
|
123
|
+
- **feature_create_bulk**: Create multiple features at once
|
|
124
|
+
- **feature_skip**: Move a feature to the end of the queue
|
|
125
|
+
|
|
126
|
+
## Creating Features
|
|
127
|
+
|
|
128
|
+
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
|
129
|
+
|
|
130
|
+
For a **single feature**, call `feature_create` with:
|
|
131
|
+
- category: A grouping like "Authentication", "API", "UI", "Database"
|
|
132
|
+
- name: A concise, descriptive name
|
|
133
|
+
- description: What the feature should do
|
|
134
|
+
- steps: List of verification/implementation steps
|
|
135
|
+
|
|
136
|
+
For **multiple features**, call `feature_create_bulk` with an array of feature objects.
|
|
137
|
+
|
|
138
|
+
You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.
|
|
139
|
+
|
|
140
|
+
**Example interaction:**
|
|
141
|
+
User: "Add a feature for S3 sync"
|
|
142
|
+
You: I'll create that feature now.
|
|
143
|
+
[calls feature_create with appropriate parameters]
|
|
144
|
+
You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.
|
|
145
|
+
|
|
146
|
+
## Guidelines
|
|
147
|
+
|
|
148
|
+
1. Be concise and helpful
|
|
149
|
+
2. When explaining code, reference specific file paths and line numbers
|
|
150
|
+
3. Use the feature tools to answer questions about project progress
|
|
151
|
+
4. Search the codebase to find relevant information before answering
|
|
152
|
+
5. When creating features, confirm what was created
|
|
153
|
+
6. If you're unsure about details, ask for clarification"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AssistantChatSession:
|
|
157
|
+
"""
|
|
158
|
+
Manages a read-only assistant conversation for a project.
|
|
159
|
+
|
|
160
|
+
Uses Claude Opus 4.5 with only read-only tools enabled.
|
|
161
|
+
Persists conversation history to SQLite.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
|
|
165
|
+
"""
|
|
166
|
+
Initialize the session.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
project_name: Name of the project
|
|
170
|
+
project_dir: Absolute path to the project directory
|
|
171
|
+
conversation_id: Optional existing conversation ID to resume
|
|
172
|
+
"""
|
|
173
|
+
self.project_name = project_name
|
|
174
|
+
self.project_dir = project_dir
|
|
175
|
+
self.conversation_id = conversation_id
|
|
176
|
+
self.client: Optional[ClaudeSDKClient] = None
|
|
177
|
+
self._client_entered: bool = False
|
|
178
|
+
self.created_at = datetime.now()
|
|
179
|
+
self._history_loaded: bool = False # Track if we've loaded history for resumed conversations
|
|
180
|
+
|
|
181
|
+
async def close(self) -> None:
|
|
182
|
+
"""Clean up resources and close the Claude client."""
|
|
183
|
+
if self.client and self._client_entered:
|
|
184
|
+
try:
|
|
185
|
+
await self.client.__aexit__(None, None, None)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.warning(f"Error closing Claude client: {e}")
|
|
188
|
+
finally:
|
|
189
|
+
self._client_entered = False
|
|
190
|
+
self.client = None
|
|
191
|
+
|
|
192
|
+
async def start(self) -> AsyncGenerator[dict, None]:
|
|
193
|
+
"""
|
|
194
|
+
Initialize session with the Claude client.
|
|
195
|
+
|
|
196
|
+
Creates a new conversation if none exists, then sends an initial greeting.
|
|
197
|
+
For resumed conversations, skips the greeting since history is loaded from DB.
|
|
198
|
+
Yields message chunks as they stream in.
|
|
199
|
+
"""
|
|
200
|
+
# Track if this is a new conversation (for greeting decision)
|
|
201
|
+
is_new_conversation = self.conversation_id is None
|
|
202
|
+
|
|
203
|
+
# Create a new conversation if we don't have one
|
|
204
|
+
if is_new_conversation:
|
|
205
|
+
conv = create_conversation(self.project_dir, self.project_name)
|
|
206
|
+
self.conversation_id = int(conv.id) # type coercion: Column[int] -> int
|
|
207
|
+
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
|
|
208
|
+
|
|
209
|
+
# Build permissions list for assistant access (read + feature management)
|
|
210
|
+
permissions_list = [
|
|
211
|
+
"Read(./**)",
|
|
212
|
+
"Glob(./**)",
|
|
213
|
+
"Grep(./**)",
|
|
214
|
+
"WebFetch",
|
|
215
|
+
"WebSearch",
|
|
216
|
+
*ASSISTANT_FEATURE_TOOLS,
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
# Create security settings file
|
|
220
|
+
security_settings = {
|
|
221
|
+
"sandbox": {"enabled": False}, # No bash, so sandbox not needed
|
|
222
|
+
"permissions": {
|
|
223
|
+
"defaultMode": "bypassPermissions", # Read-only, no dangerous ops
|
|
224
|
+
"allow": permissions_list,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
from autoforge_paths import get_claude_assistant_settings_path
|
|
228
|
+
settings_file = get_claude_assistant_settings_path(self.project_dir)
|
|
229
|
+
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
with open(settings_file, "w") as f:
|
|
231
|
+
json.dump(security_settings, f, indent=2)
|
|
232
|
+
|
|
233
|
+
# Build MCP servers config - only features MCP for read-only access
|
|
234
|
+
mcp_servers = {
|
|
235
|
+
"features": {
|
|
236
|
+
"command": sys.executable,
|
|
237
|
+
"args": ["-m", "mcp_server.feature_mcp"],
|
|
238
|
+
"env": {
|
|
239
|
+
# Only specify variables the MCP server needs
|
|
240
|
+
# (subprocess inherits parent environment automatically)
|
|
241
|
+
"PROJECT_DIR": str(self.project_dir.resolve()),
|
|
242
|
+
"PYTHONPATH": str(ROOT_DIR.resolve()),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# Get system prompt with project context
|
|
248
|
+
system_prompt = get_system_prompt(self.project_name, self.project_dir)
|
|
249
|
+
|
|
250
|
+
# Write system prompt to CLAUDE.md file to avoid Windows command line length limit
|
|
251
|
+
# The SDK will read this via setting_sources=["project"]
|
|
252
|
+
claude_md_path = self.project_dir / "CLAUDE.md"
|
|
253
|
+
with open(claude_md_path, "w", encoding="utf-8") as f:
|
|
254
|
+
f.write(system_prompt)
|
|
255
|
+
logger.info(f"Wrote assistant system prompt to {claude_md_path}")
|
|
256
|
+
|
|
257
|
+
# Use system Claude CLI
|
|
258
|
+
system_cli = shutil.which("claude")
|
|
259
|
+
|
|
260
|
+
# Build environment overrides for API configuration
|
|
261
|
+
sdk_env: dict[str, str] = {}
|
|
262
|
+
for var in API_ENV_VARS:
|
|
263
|
+
value = os.getenv(var)
|
|
264
|
+
if value:
|
|
265
|
+
sdk_env[var] = value
|
|
266
|
+
|
|
267
|
+
# Determine model from environment or use default
|
|
268
|
+
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
|
269
|
+
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
logger.info("Creating ClaudeSDKClient...")
|
|
273
|
+
self.client = ClaudeSDKClient(
|
|
274
|
+
options=ClaudeAgentOptions(
|
|
275
|
+
model=model,
|
|
276
|
+
cli_path=system_cli,
|
|
277
|
+
# System prompt loaded from CLAUDE.md via setting_sources
|
|
278
|
+
# This avoids Windows command line length limit (~8191 chars)
|
|
279
|
+
setting_sources=["project"],
|
|
280
|
+
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
|
|
281
|
+
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
|
|
282
|
+
permission_mode="bypassPermissions",
|
|
283
|
+
max_turns=100,
|
|
284
|
+
cwd=str(self.project_dir.resolve()),
|
|
285
|
+
settings=str(settings_file.resolve()),
|
|
286
|
+
env=sdk_env,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
logger.info("Entering Claude client context...")
|
|
290
|
+
await self.client.__aenter__()
|
|
291
|
+
self._client_entered = True
|
|
292
|
+
logger.info("Claude client ready")
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.exception("Failed to create Claude client")
|
|
295
|
+
yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# Send initial greeting only for NEW conversations
|
|
299
|
+
# Resumed conversations already have history loaded from the database
|
|
300
|
+
if is_new_conversation:
|
|
301
|
+
# New conversations don't need history loading
|
|
302
|
+
self._history_loaded = True
|
|
303
|
+
try:
|
|
304
|
+
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
|
|
305
|
+
|
|
306
|
+
# Store the greeting in the database
|
|
307
|
+
# conversation_id is guaranteed non-None here (set on line 206 above)
|
|
308
|
+
assert self.conversation_id is not None
|
|
309
|
+
add_message(self.project_dir, self.conversation_id, "assistant", greeting)
|
|
310
|
+
|
|
311
|
+
yield {"type": "text", "content": greeting}
|
|
312
|
+
yield {"type": "response_done"}
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.exception("Failed to send greeting")
|
|
315
|
+
yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}
|
|
316
|
+
else:
|
|
317
|
+
# For resumed conversations, history will be loaded on first message
|
|
318
|
+
# _history_loaded stays False so send_message() will include history
|
|
319
|
+
yield {"type": "response_done"}
|
|
320
|
+
|
|
321
|
+
async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
|
|
322
|
+
"""
|
|
323
|
+
Send user message and stream Claude's response.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
user_message: The user's message
|
|
327
|
+
|
|
328
|
+
Yields:
|
|
329
|
+
Message chunks:
|
|
330
|
+
- {"type": "text", "content": str}
|
|
331
|
+
- {"type": "tool_call", "tool": str, "input": dict}
|
|
332
|
+
- {"type": "response_done"}
|
|
333
|
+
- {"type": "error", "content": str}
|
|
334
|
+
"""
|
|
335
|
+
if not self.client:
|
|
336
|
+
yield {"type": "error", "content": "Session not initialized. Call start() first."}
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
if self.conversation_id is None:
|
|
340
|
+
yield {"type": "error", "content": "No conversation ID set."}
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Store user message in database
|
|
344
|
+
add_message(self.project_dir, self.conversation_id, "user", user_message)
|
|
345
|
+
|
|
346
|
+
# For resumed conversations, include history context in first message
|
|
347
|
+
message_to_send = user_message
|
|
348
|
+
if not self._history_loaded:
|
|
349
|
+
self._history_loaded = True
|
|
350
|
+
history = get_messages(self.project_dir, self.conversation_id)
|
|
351
|
+
# Exclude the message we just added (last one)
|
|
352
|
+
history = history[:-1] if history else []
|
|
353
|
+
# Cap history to last 35 messages to prevent context overload
|
|
354
|
+
history = history[-35:] if len(history) > 35 else history
|
|
355
|
+
if history:
|
|
356
|
+
# Format history as context for Claude
|
|
357
|
+
history_lines = ["[Previous conversation history for context:]"]
|
|
358
|
+
for msg in history:
|
|
359
|
+
role = "User" if msg["role"] == "user" else "Assistant"
|
|
360
|
+
content = msg["content"]
|
|
361
|
+
# Truncate very long messages
|
|
362
|
+
if len(content) > 500:
|
|
363
|
+
content = content[:500] + "..."
|
|
364
|
+
history_lines.append(f"{role}: {content}")
|
|
365
|
+
history_lines.append("[End of history. Continue the conversation:]")
|
|
366
|
+
history_lines.append(f"User: {user_message}")
|
|
367
|
+
message_to_send = "\n".join(history_lines)
|
|
368
|
+
logger.info(f"Loaded {len(history)} messages from conversation history")
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
async for chunk in self._query_claude(message_to_send):
|
|
372
|
+
yield chunk
|
|
373
|
+
yield {"type": "response_done"}
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.exception("Error during Claude query")
|
|
376
|
+
yield {"type": "error", "content": f"Error: {str(e)}"}
|
|
377
|
+
|
|
378
|
+
async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
|
|
379
|
+
"""
|
|
380
|
+
Internal method to query Claude and stream responses.
|
|
381
|
+
|
|
382
|
+
Handles tool calls and text responses.
|
|
383
|
+
"""
|
|
384
|
+
if not self.client:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Send message to Claude
|
|
388
|
+
await self.client.query(message)
|
|
389
|
+
|
|
390
|
+
full_response = ""
|
|
391
|
+
|
|
392
|
+
# Stream the response
|
|
393
|
+
async for msg in self.client.receive_response():
|
|
394
|
+
msg_type = type(msg).__name__
|
|
395
|
+
|
|
396
|
+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
397
|
+
for block in msg.content:
|
|
398
|
+
block_type = type(block).__name__
|
|
399
|
+
|
|
400
|
+
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
401
|
+
text = block.text
|
|
402
|
+
if text:
|
|
403
|
+
full_response += text
|
|
404
|
+
yield {"type": "text", "content": text}
|
|
405
|
+
|
|
406
|
+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
|
407
|
+
tool_name = block.name
|
|
408
|
+
tool_input = getattr(block, "input", {})
|
|
409
|
+
yield {
|
|
410
|
+
"type": "tool_call",
|
|
411
|
+
"tool": tool_name,
|
|
412
|
+
"input": tool_input,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Store the complete response in the database
|
|
416
|
+
if full_response and self.conversation_id:
|
|
417
|
+
add_message(self.project_dir, self.conversation_id, "assistant", full_response)
|
|
418
|
+
|
|
419
|
+
def get_conversation_id(self) -> Optional[int]:
|
|
420
|
+
"""Get the current conversation ID."""
|
|
421
|
+
return self.conversation_id
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# Session registry with thread safety
|
|
425
|
+
_sessions: dict[str, AssistantChatSession] = {}
|
|
426
|
+
_sessions_lock = threading.Lock()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def get_session(project_name: str) -> Optional[AssistantChatSession]:
|
|
430
|
+
"""Get an existing session for a project."""
|
|
431
|
+
with _sessions_lock:
|
|
432
|
+
return _sessions.get(project_name)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def create_session(
|
|
436
|
+
project_name: str,
|
|
437
|
+
project_dir: Path,
|
|
438
|
+
conversation_id: Optional[int] = None
|
|
439
|
+
) -> AssistantChatSession:
|
|
440
|
+
"""
|
|
441
|
+
Create a new session for a project, closing any existing one.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
project_name: Name of the project
|
|
445
|
+
project_dir: Absolute path to the project directory
|
|
446
|
+
conversation_id: Optional conversation ID to resume
|
|
447
|
+
"""
|
|
448
|
+
old_session: Optional[AssistantChatSession] = None
|
|
449
|
+
|
|
450
|
+
with _sessions_lock:
|
|
451
|
+
old_session = _sessions.pop(project_name, None)
|
|
452
|
+
session = AssistantChatSession(project_name, project_dir, conversation_id)
|
|
453
|
+
_sessions[project_name] = session
|
|
454
|
+
|
|
455
|
+
if old_session:
|
|
456
|
+
try:
|
|
457
|
+
await old_session.close()
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.warning(f"Error closing old session for {project_name}: {e}")
|
|
460
|
+
|
|
461
|
+
return session
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def remove_session(project_name: str) -> None:
|
|
465
|
+
"""Remove and close a session."""
|
|
466
|
+
session: Optional[AssistantChatSession] = None
|
|
467
|
+
|
|
468
|
+
with _sessions_lock:
|
|
469
|
+
session = _sessions.pop(project_name, None)
|
|
470
|
+
|
|
471
|
+
if session:
|
|
472
|
+
try:
|
|
473
|
+
await session.close()
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.warning(f"Error closing session for {project_name}: {e}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def list_sessions() -> list[str]:
|
|
479
|
+
"""List all active session project names."""
|
|
480
|
+
with _sessions_lock:
|
|
481
|
+
return list(_sessions.keys())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def cleanup_all_sessions() -> None:
|
|
485
|
+
"""Close all active sessions. Called on server shutdown."""
|
|
486
|
+
sessions_to_close: list[AssistantChatSession] = []
|
|
487
|
+
|
|
488
|
+
with _sessions_lock:
|
|
489
|
+
sessions_to_close = list(_sessions.values())
|
|
490
|
+
_sessions.clear()
|
|
491
|
+
|
|
492
|
+
for session in sessions_to_close:
|
|
493
|
+
try:
|
|
494
|
+
await session.close()
|
|
495
|
+
except Exception as e:
|
|
496
|
+
logger.warning(f"Error closing session {session.project_name}: {e}")
|