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,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant Database
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
SQLAlchemy models and functions for persisting assistant conversations.
|
|
6
|
+
Each project has its own assistant.db file in the project directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func
|
|
16
|
+
from sqlalchemy.engine import Engine
|
|
17
|
+
from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
class Base(DeclarativeBase):
|
|
22
|
+
"""SQLAlchemy 2.0 style declarative base."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# Engine cache to avoid creating new engines for each request
|
|
26
|
+
# Key: project directory path (as posix string), Value: SQLAlchemy engine
|
|
27
|
+
_engine_cache: dict[str, Engine] = {}
|
|
28
|
+
|
|
29
|
+
# Lock for thread-safe access to the engine cache
|
|
30
|
+
# Prevents race conditions when multiple threads create engines simultaneously
|
|
31
|
+
_cache_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _utc_now() -> datetime:
|
|
35
|
+
"""Return current UTC time. Replacement for deprecated datetime.utcnow()."""
|
|
36
|
+
return datetime.now(timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Conversation(Base):
|
|
40
|
+
"""A conversation with the assistant for a project."""
|
|
41
|
+
__tablename__ = "conversations"
|
|
42
|
+
|
|
43
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
44
|
+
project_name = Column(String(100), nullable=False, index=True)
|
|
45
|
+
title = Column(String(200), nullable=True) # Optional title, derived from first message
|
|
46
|
+
created_at = Column(DateTime, default=_utc_now)
|
|
47
|
+
updated_at = Column(DateTime, default=_utc_now, onupdate=_utc_now)
|
|
48
|
+
|
|
49
|
+
messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConversationMessage(Base):
|
|
53
|
+
"""A single message within a conversation."""
|
|
54
|
+
__tablename__ = "conversation_messages"
|
|
55
|
+
|
|
56
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
57
|
+
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
|
|
58
|
+
role = Column(String(20), nullable=False) # "user" | "assistant" | "system"
|
|
59
|
+
content = Column(Text, nullable=False)
|
|
60
|
+
timestamp = Column(DateTime, default=_utc_now)
|
|
61
|
+
|
|
62
|
+
conversation = relationship("Conversation", back_populates="messages")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_db_path(project_dir: Path) -> Path:
|
|
66
|
+
"""Get the path to the assistant database for a project."""
|
|
67
|
+
from autoforge_paths import get_assistant_db_path
|
|
68
|
+
return get_assistant_db_path(project_dir)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_engine(project_dir: Path):
|
|
72
|
+
"""Get or create a SQLAlchemy engine for a project's assistant database.
|
|
73
|
+
|
|
74
|
+
Uses a cache to avoid creating new engines for each request, which improves
|
|
75
|
+
performance by reusing database connections.
|
|
76
|
+
|
|
77
|
+
Thread-safe: Uses a lock to prevent race conditions when multiple threads
|
|
78
|
+
try to create engines simultaneously for the same project.
|
|
79
|
+
"""
|
|
80
|
+
cache_key = project_dir.as_posix()
|
|
81
|
+
|
|
82
|
+
# Double-checked locking for thread safety and performance
|
|
83
|
+
if cache_key in _engine_cache:
|
|
84
|
+
return _engine_cache[cache_key]
|
|
85
|
+
|
|
86
|
+
with _cache_lock:
|
|
87
|
+
# Check again inside the lock in case another thread created it
|
|
88
|
+
if cache_key not in _engine_cache:
|
|
89
|
+
db_path = get_db_path(project_dir)
|
|
90
|
+
# Use as_posix() for cross-platform compatibility with SQLite connection strings
|
|
91
|
+
db_url = f"sqlite:///{db_path.as_posix()}"
|
|
92
|
+
engine = create_engine(
|
|
93
|
+
db_url,
|
|
94
|
+
echo=False,
|
|
95
|
+
connect_args={
|
|
96
|
+
"check_same_thread": False,
|
|
97
|
+
"timeout": 30, # Wait up to 30s for locks
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
Base.metadata.create_all(engine)
|
|
101
|
+
_engine_cache[cache_key] = engine
|
|
102
|
+
logger.debug(f"Created new database engine for {cache_key}")
|
|
103
|
+
|
|
104
|
+
return _engine_cache[cache_key]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def dispose_engine(project_dir: Path) -> bool:
|
|
108
|
+
"""Dispose of and remove the cached engine for a project.
|
|
109
|
+
|
|
110
|
+
This closes all database connections, releasing file locks on Windows.
|
|
111
|
+
Should be called before deleting the database file.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if an engine was disposed, False if no engine was cached.
|
|
115
|
+
"""
|
|
116
|
+
cache_key = project_dir.as_posix()
|
|
117
|
+
|
|
118
|
+
if cache_key in _engine_cache:
|
|
119
|
+
engine = _engine_cache.pop(cache_key)
|
|
120
|
+
engine.dispose()
|
|
121
|
+
logger.debug(f"Disposed database engine for {cache_key}")
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_session(project_dir: Path):
|
|
128
|
+
"""Get a new database session for a project."""
|
|
129
|
+
engine = get_engine(project_dir)
|
|
130
|
+
Session = sessionmaker(bind=engine)
|
|
131
|
+
return Session()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ============================================================================
|
|
135
|
+
# Conversation Operations
|
|
136
|
+
# ============================================================================
|
|
137
|
+
|
|
138
|
+
def create_conversation(project_dir: Path, project_name: str, title: Optional[str] = None) -> Conversation:
|
|
139
|
+
"""Create a new conversation for a project."""
|
|
140
|
+
session = get_session(project_dir)
|
|
141
|
+
try:
|
|
142
|
+
conversation = Conversation(
|
|
143
|
+
project_name=project_name,
|
|
144
|
+
title=title,
|
|
145
|
+
)
|
|
146
|
+
session.add(conversation)
|
|
147
|
+
session.commit()
|
|
148
|
+
session.refresh(conversation)
|
|
149
|
+
logger.info(f"Created conversation {conversation.id} for project {project_name}")
|
|
150
|
+
return conversation
|
|
151
|
+
finally:
|
|
152
|
+
session.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
|
|
156
|
+
"""Get all conversations for a project with message counts.
|
|
157
|
+
|
|
158
|
+
Uses a subquery for message_count to avoid N+1 query problem.
|
|
159
|
+
"""
|
|
160
|
+
session = get_session(project_dir)
|
|
161
|
+
try:
|
|
162
|
+
# Subquery to count messages per conversation (avoids N+1 query)
|
|
163
|
+
message_count_subquery = (
|
|
164
|
+
session.query(
|
|
165
|
+
ConversationMessage.conversation_id,
|
|
166
|
+
func.count(ConversationMessage.id).label("message_count")
|
|
167
|
+
)
|
|
168
|
+
.group_by(ConversationMessage.conversation_id)
|
|
169
|
+
.subquery()
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Join conversation with message counts
|
|
173
|
+
conversations = (
|
|
174
|
+
session.query(
|
|
175
|
+
Conversation,
|
|
176
|
+
func.coalesce(message_count_subquery.c.message_count, 0).label("message_count")
|
|
177
|
+
)
|
|
178
|
+
.outerjoin(
|
|
179
|
+
message_count_subquery,
|
|
180
|
+
Conversation.id == message_count_subquery.c.conversation_id
|
|
181
|
+
)
|
|
182
|
+
.filter(Conversation.project_name == project_name)
|
|
183
|
+
.order_by(Conversation.updated_at.desc())
|
|
184
|
+
.all()
|
|
185
|
+
)
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
"id": c.Conversation.id,
|
|
189
|
+
"project_name": c.Conversation.project_name,
|
|
190
|
+
"title": c.Conversation.title,
|
|
191
|
+
"created_at": c.Conversation.created_at.isoformat() if c.Conversation.created_at else None,
|
|
192
|
+
"updated_at": c.Conversation.updated_at.isoformat() if c.Conversation.updated_at else None,
|
|
193
|
+
"message_count": c.message_count,
|
|
194
|
+
}
|
|
195
|
+
for c in conversations
|
|
196
|
+
]
|
|
197
|
+
finally:
|
|
198
|
+
session.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_conversation(project_dir: Path, conversation_id: int) -> Optional[dict]:
|
|
202
|
+
"""Get a conversation with all its messages."""
|
|
203
|
+
session = get_session(project_dir)
|
|
204
|
+
try:
|
|
205
|
+
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
|
|
206
|
+
if not conversation:
|
|
207
|
+
return None
|
|
208
|
+
return {
|
|
209
|
+
"id": conversation.id,
|
|
210
|
+
"project_name": conversation.project_name,
|
|
211
|
+
"title": conversation.title,
|
|
212
|
+
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
|
|
213
|
+
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
|
|
214
|
+
"messages": [
|
|
215
|
+
{
|
|
216
|
+
"id": m.id,
|
|
217
|
+
"role": m.role,
|
|
218
|
+
"content": m.content,
|
|
219
|
+
"timestamp": m.timestamp.isoformat() if m.timestamp else None,
|
|
220
|
+
}
|
|
221
|
+
for m in sorted(conversation.messages, key=lambda x: x.timestamp or datetime.min)
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
finally:
|
|
225
|
+
session.close()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def delete_conversation(project_dir: Path, conversation_id: int) -> bool:
|
|
229
|
+
"""Delete a conversation and all its messages."""
|
|
230
|
+
session = get_session(project_dir)
|
|
231
|
+
try:
|
|
232
|
+
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
|
|
233
|
+
if not conversation:
|
|
234
|
+
return False
|
|
235
|
+
session.delete(conversation)
|
|
236
|
+
session.commit()
|
|
237
|
+
logger.info(f"Deleted conversation {conversation_id}")
|
|
238
|
+
return True
|
|
239
|
+
finally:
|
|
240
|
+
session.close()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ============================================================================
|
|
244
|
+
# Message Operations
|
|
245
|
+
# ============================================================================
|
|
246
|
+
|
|
247
|
+
def add_message(project_dir: Path, conversation_id: int, role: str, content: str) -> Optional[dict]:
|
|
248
|
+
"""Add a message to a conversation."""
|
|
249
|
+
session = get_session(project_dir)
|
|
250
|
+
try:
|
|
251
|
+
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
|
|
252
|
+
if not conversation:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
message = ConversationMessage(
|
|
256
|
+
conversation_id=conversation_id,
|
|
257
|
+
role=role,
|
|
258
|
+
content=content,
|
|
259
|
+
)
|
|
260
|
+
session.add(message)
|
|
261
|
+
|
|
262
|
+
# Update conversation's updated_at timestamp
|
|
263
|
+
conversation.updated_at = _utc_now()
|
|
264
|
+
|
|
265
|
+
# Auto-generate title from first user message if not set
|
|
266
|
+
if not conversation.title and role == "user":
|
|
267
|
+
# Take first 50 chars of first user message as title
|
|
268
|
+
conversation.title = content[:50] + ("..." if len(content) > 50 else "")
|
|
269
|
+
|
|
270
|
+
session.commit()
|
|
271
|
+
session.refresh(message)
|
|
272
|
+
|
|
273
|
+
logger.debug(f"Added {role} message to conversation {conversation_id}")
|
|
274
|
+
return {
|
|
275
|
+
"id": message.id,
|
|
276
|
+
"role": message.role,
|
|
277
|
+
"content": message.content,
|
|
278
|
+
"timestamp": message.timestamp.isoformat() if message.timestamp else None,
|
|
279
|
+
}
|
|
280
|
+
finally:
|
|
281
|
+
session.close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_messages(project_dir: Path, conversation_id: int) -> list[dict]:
|
|
285
|
+
"""Get all messages for a conversation."""
|
|
286
|
+
session = get_session(project_dir)
|
|
287
|
+
try:
|
|
288
|
+
messages = (
|
|
289
|
+
session.query(ConversationMessage)
|
|
290
|
+
.filter(ConversationMessage.conversation_id == conversation_id)
|
|
291
|
+
.order_by(ConversationMessage.timestamp.asc())
|
|
292
|
+
.all()
|
|
293
|
+
)
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
"id": m.id,
|
|
297
|
+
"role": m.role,
|
|
298
|
+
"content": m.content,
|
|
299
|
+
"timestamp": m.timestamp.isoformat() if m.timestamp else None,
|
|
300
|
+
}
|
|
301
|
+
for m in messages
|
|
302
|
+
]
|
|
303
|
+
finally:
|
|
304
|
+
session.close()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chat Session Constants
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Shared constants for all chat session types (assistant, spec, expand).
|
|
6
|
+
|
|
7
|
+
The canonical ``API_ENV_VARS`` list lives in ``env_constants.py`` at the
|
|
8
|
+
project root and is re-exported here for convenience so that existing
|
|
9
|
+
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import AsyncGenerator
|
|
15
|
+
|
|
16
|
+
# -------------------------------------------------------------------
|
|
17
|
+
# Root directory of the autoforge project (repository root).
|
|
18
|
+
# Used throughout the server package whenever the repo root is needed.
|
|
19
|
+
# -------------------------------------------------------------------
|
|
20
|
+
ROOT_DIR = Path(__file__).parent.parent.parent
|
|
21
|
+
|
|
22
|
+
# Ensure the project root is on sys.path so we can import env_constants
|
|
23
|
+
# from the root-level module without requiring a package install.
|
|
24
|
+
_root_str = str(ROOT_DIR)
|
|
25
|
+
if _root_str not in sys.path:
|
|
26
|
+
sys.path.insert(0, _root_str)
|
|
27
|
+
|
|
28
|
+
# -------------------------------------------------------------------
|
|
29
|
+
# Environment variables forwarded to Claude CLI subprocesses.
|
|
30
|
+
# Single source of truth lives in env_constants.py at the project root.
|
|
31
|
+
# Re-exported here so existing ``from .chat_constants import API_ENV_VARS``
|
|
32
|
+
# imports continue to work unchanged.
|
|
33
|
+
# -------------------------------------------------------------------
|
|
34
|
+
from env_constants import API_ENV_VARS # noqa: E402, F401
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
|
38
|
+
"""Yield a single multimodal user message in Claude Agent SDK format.
|
|
39
|
+
|
|
40
|
+
The Claude Agent SDK's ``query()`` method accepts either a plain string
|
|
41
|
+
or an ``AsyncIterable[dict]`` for custom message formats. This helper
|
|
42
|
+
wraps a list of content blocks (text and/or images) in the expected
|
|
43
|
+
envelope.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
content_blocks: List of content-block dicts, e.g.
|
|
47
|
+
``[{"type": "text", "text": "..."}, {"type": "image", ...}]``.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
A single dict representing the user message.
|
|
51
|
+
"""
|
|
52
|
+
yield {
|
|
53
|
+
"type": "user",
|
|
54
|
+
"message": {"role": "user", "content": content_blocks},
|
|
55
|
+
"parent_tool_use_id": None,
|
|
56
|
+
"session_id": "default",
|
|
57
|
+
}
|