onecoder 0.0.2__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.
- onecoder/agent.py +95 -0
- onecoder/agentic_tool_search/__init__.py +0 -0
- onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
- onecoder/agentic_tool_search/registry.py +33 -0
- onecoder/agents/__init__.py +7 -0
- onecoder/agents/documentation_agent.py +12 -0
- onecoder/agents/file_reader_agent.py +19 -0
- onecoder/agents/file_writer_agent.py +19 -0
- onecoder/agents/orchestrator_agent.py +51 -0
- onecoder/agents/refactoring_agent.py +12 -0
- onecoder/agents/research_agent.py +31 -0
- onecoder/agents/task_suggestion_agent.py +88 -0
- onecoder/alignment.py +236 -0
- onecoder/api.py +162 -0
- onecoder/api_client.py +112 -0
- onecoder/backends/base.py +22 -0
- onecoder/backends/local_tui.py +65 -0
- onecoder/blackboard.py +102 -0
- onecoder/cli.py +108 -0
- onecoder/commands/__init__.py +1 -0
- onecoder/commands/auth.py +78 -0
- onecoder/commands/ci.py +29 -0
- onecoder/commands/delegate.py +557 -0
- onecoder/commands/doctor.py +40 -0
- onecoder/commands/issue.py +136 -0
- onecoder/commands/logs.py +45 -0
- onecoder/commands/project.py +270 -0
- onecoder/commands/server.py +170 -0
- onecoder/config_manager.py +87 -0
- onecoder/constants.py +9 -0
- onecoder/diagnostics/__init__.py +2 -0
- onecoder/diagnostics/env_scan.py +207 -0
- onecoder/discovery.py +101 -0
- onecoder/distillation.py +236 -0
- onecoder/evaluation/__init__.py +1 -0
- onecoder/evaluation/ttu.py +176 -0
- onecoder/governance/__init__.py +0 -0
- onecoder/governance/probllm.py +91 -0
- onecoder/hooks.py +74 -0
- onecoder/ipc_auth.py +200 -0
- onecoder/issues.py +188 -0
- onecoder/jules_client.py +343 -0
- onecoder/knowledge.py +106 -0
- onecoder/llm.py +61 -0
- onecoder/logger.py +42 -0
- onecoder/metrics.py +129 -0
- onecoder/models/delegation.py +46 -0
- onecoder/onboarding.py +264 -0
- onecoder/review.py +233 -0
- onecoder/services/delegation_service.py +209 -0
- onecoder/services/validation_service.py +104 -0
- onecoder/sessions.py +186 -0
- onecoder/sprint_collector.py +165 -0
- onecoder/sync.py +167 -0
- onecoder/tmux.py +86 -0
- onecoder/tools/__init__.py +10 -0
- onecoder/tools/executor.py +53 -0
- onecoder/tools/external_tools.py +106 -0
- onecoder/tools/file_tools.py +77 -0
- onecoder/tools/interface.py +25 -0
- onecoder/tools/jules_tools.py +122 -0
- onecoder/tools/kit_tools.py +122 -0
- onecoder/tools/registry.py +32 -0
- onecoder/tui/__init__.py +5 -0
- onecoder/tui/app.py +263 -0
- onecoder/tui/commands.py +150 -0
- onecoder/tui/widgets.py +92 -0
- onecoder/worktree.py +186 -0
- onecoder-0.0.2.dist-info/METADATA +17 -0
- onecoder-0.0.2.dist-info/RECORD +73 -0
- onecoder-0.0.2.dist-info/WHEEL +5 -0
- onecoder-0.0.2.dist-info/entry_points.txt +2 -0
- onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/sessions.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import copy
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Optional, Dict, List
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from google.adk.sessions.base_session_service import BaseSessionService, GetSessionConfig, ListSessionsResponse
|
|
11
|
+
from google.adk.sessions.session import Session
|
|
12
|
+
from google.adk.events.event import Event
|
|
13
|
+
from google.adk.sessions.state import State
|
|
14
|
+
from google.adk.sessions import _session_util
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
class DurableSessionService(BaseSessionService):
|
|
19
|
+
"""
|
|
20
|
+
A filesystem-backed session service for OneCoder.
|
|
21
|
+
Persists sessions, app state, and user state as JSON files.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, storage_dir: str = ".adk/sessions"):
|
|
25
|
+
self.storage_dir = Path(storage_dir)
|
|
26
|
+
self.sessions_dir = self.storage_dir / "sessions"
|
|
27
|
+
self.app_state_dir = self.storage_dir / "apps"
|
|
28
|
+
self.user_state_dir = self.storage_dir / "users"
|
|
29
|
+
|
|
30
|
+
# Ensure directories exist
|
|
31
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.app_state_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self.user_state_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
def _get_session_path(self, app_name: str, user_id: str, session_id: str) -> Path:
|
|
36
|
+
return self.sessions_dir / app_name / user_id / f"{session_id}.json"
|
|
37
|
+
|
|
38
|
+
def _get_app_state_path(self, app_name: str) -> Path:
|
|
39
|
+
return self.app_state_dir / f"{app_name}.json"
|
|
40
|
+
|
|
41
|
+
def _get_user_state_path(self, app_name: str, user_id: str) -> Path:
|
|
42
|
+
return self.user_state_dir / app_name / f"{user_id}.json"
|
|
43
|
+
|
|
44
|
+
def _save_json(self, path: Path, data: Any):
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with open(path, "w") as f:
|
|
47
|
+
if isinstance(data, BaseModel):
|
|
48
|
+
f.write(data.model_dump_json(by_alias=True, indent=2))
|
|
49
|
+
else:
|
|
50
|
+
json.dump(data, f, indent=2)
|
|
51
|
+
|
|
52
|
+
def _load_json(self, path: Path) -> Optional[Dict[str, Any]]:
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return None
|
|
55
|
+
with open(path, "r") as f:
|
|
56
|
+
return json.load(f)
|
|
57
|
+
|
|
58
|
+
async def create_session(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
app_name: str,
|
|
62
|
+
user_id: str,
|
|
63
|
+
state: Optional[dict[str, Any]] = None,
|
|
64
|
+
session_id: Optional[str] = None,
|
|
65
|
+
) -> Session:
|
|
66
|
+
if not session_id:
|
|
67
|
+
session_id = f"session_{int(time.time())}"
|
|
68
|
+
|
|
69
|
+
session = Session(
|
|
70
|
+
id=session_id,
|
|
71
|
+
app_name=app_name,
|
|
72
|
+
user_id=user_id,
|
|
73
|
+
state=state or {},
|
|
74
|
+
last_update_time=time.time()
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self._save_json(self._get_session_path(app_name, user_id, session_id), session)
|
|
78
|
+
return session
|
|
79
|
+
|
|
80
|
+
async def get_session(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
app_name: str,
|
|
84
|
+
user_id: str,
|
|
85
|
+
session_id: str,
|
|
86
|
+
config: Optional[GetSessionConfig] = None,
|
|
87
|
+
) -> Optional[Session]:
|
|
88
|
+
path = self._get_session_path(app_name, user_id, session_id)
|
|
89
|
+
data = self._load_json(path)
|
|
90
|
+
if not data:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
session = Session.model_validate(data)
|
|
94
|
+
|
|
95
|
+
# Filtering events based on config
|
|
96
|
+
if config:
|
|
97
|
+
if config.num_recent_events:
|
|
98
|
+
session.events = session.events[-config.num_recent_events:]
|
|
99
|
+
if config.after_timestamp:
|
|
100
|
+
session.events = [e for e in session.events if e.timestamp >= config.after_timestamp]
|
|
101
|
+
|
|
102
|
+
return self._merge_state(app_name, user_id, session)
|
|
103
|
+
|
|
104
|
+
def _merge_state(self, app_name: str, user_id: str, session: Session) -> Session:
|
|
105
|
+
# Load and merge app state
|
|
106
|
+
app_state = self._load_json(self._get_app_state_path(app_name)) or {}
|
|
107
|
+
for key, value in app_state.items():
|
|
108
|
+
session.state[State.APP_PREFIX + key] = value
|
|
109
|
+
|
|
110
|
+
# Load and merge user state
|
|
111
|
+
user_state = self._load_json(self._get_user_state_path(app_name, user_id)) or {}
|
|
112
|
+
for key, value in user_state.items():
|
|
113
|
+
session.state[State.USER_PREFIX + key] = value
|
|
114
|
+
|
|
115
|
+
return session
|
|
116
|
+
|
|
117
|
+
async def list_sessions(
|
|
118
|
+
self, *, app_name: str, user_id: Optional[str] = None
|
|
119
|
+
) -> ListSessionsResponse:
|
|
120
|
+
sessions = []
|
|
121
|
+
app_path = self.sessions_dir / app_name
|
|
122
|
+
if not app_path.exists():
|
|
123
|
+
return ListSessionsResponse(sessions=[])
|
|
124
|
+
|
|
125
|
+
user_ids = [user_id] if user_id else [u.name for u in app_path.iterdir() if u.is_dir()]
|
|
126
|
+
|
|
127
|
+
for uid in user_ids:
|
|
128
|
+
user_path = app_path / uid
|
|
129
|
+
if not user_path.exists():
|
|
130
|
+
continue
|
|
131
|
+
for session_file in user_path.glob("*.json"):
|
|
132
|
+
data = self._load_json(session_file)
|
|
133
|
+
if data:
|
|
134
|
+
s = Session.model_validate(data)
|
|
135
|
+
s.events = [] # Following list_sessions contract (no events)
|
|
136
|
+
sessions.append(self._merge_state(app_name, uid, s))
|
|
137
|
+
|
|
138
|
+
return ListSessionsResponse(sessions=sessions)
|
|
139
|
+
|
|
140
|
+
async def delete_session(
|
|
141
|
+
self, *, app_name: str, user_id: str, session_id: str
|
|
142
|
+
) -> None:
|
|
143
|
+
path = self._get_session_path(app_name, user_id, session_id)
|
|
144
|
+
if path.exists():
|
|
145
|
+
path.unlink()
|
|
146
|
+
|
|
147
|
+
async def append_event(self, session: Session, event: Event) -> Event:
|
|
148
|
+
if event.partial:
|
|
149
|
+
return event
|
|
150
|
+
|
|
151
|
+
# Update session object in memory (handles state delta too)
|
|
152
|
+
await super().append_event(session, event)
|
|
153
|
+
session.last_update_time = event.timestamp
|
|
154
|
+
|
|
155
|
+
# Load the stored session to apply events and state deltas
|
|
156
|
+
path = self._get_session_path(session.app_name, session.user_id, session.id)
|
|
157
|
+
data = self._load_json(path)
|
|
158
|
+
if not data:
|
|
159
|
+
logger.warning(f"Session file not found during append_event: {path}")
|
|
160
|
+
# If it's missing, we still save the passed session as a recovery
|
|
161
|
+
self._save_json(path, session)
|
|
162
|
+
return event
|
|
163
|
+
|
|
164
|
+
storage_session = Session.model_validate(data)
|
|
165
|
+
storage_session.events.append(event)
|
|
166
|
+
storage_session.last_update_time = event.timestamp
|
|
167
|
+
|
|
168
|
+
# Apply state deltas to persistent state files
|
|
169
|
+
if event.actions and event.actions.state_delta:
|
|
170
|
+
deltas = _session_util.extract_state_delta(event.actions.state_delta)
|
|
171
|
+
|
|
172
|
+
if deltas["app"]:
|
|
173
|
+
app_state = self._load_json(self._get_app_state_path(session.app_name)) or {}
|
|
174
|
+
app_state.update(deltas["app"])
|
|
175
|
+
self._save_json(self._get_app_state_path(session.app_name), app_state)
|
|
176
|
+
|
|
177
|
+
if deltas["user"]:
|
|
178
|
+
user_state = self._load_json(self._get_user_state_path(session.app_name, session.user_id)) or {}
|
|
179
|
+
user_state.update(deltas["user"])
|
|
180
|
+
self._save_json(self._get_user_state_path(session.app_name, session.user_id), user_state)
|
|
181
|
+
|
|
182
|
+
if deltas["session"]:
|
|
183
|
+
storage_session.state.update(deltas["session"])
|
|
184
|
+
|
|
185
|
+
self._save_json(path, storage_session)
|
|
186
|
+
return event
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint data collection module.
|
|
3
|
+
|
|
4
|
+
Scans .sprint/ directories and extracts sprint and task data for syncing to the API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Dict, Any, Optional
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from ai_sprint.state import SprintStateManager
|
|
14
|
+
HAS_SPRINT_SDK = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_SPRINT_SDK = False
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SprintCollector:
|
|
22
|
+
"""Collects sprint data from .sprint/ directories or via SDK."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, repo_root: Path):
|
|
25
|
+
self.repo_root = repo_root
|
|
26
|
+
self.sprint_dir = repo_root / ".sprint"
|
|
27
|
+
self.state_manager = None
|
|
28
|
+
if HAS_SPRINT_SDK:
|
|
29
|
+
try:
|
|
30
|
+
self.state_manager = SprintStateManager(self.sprint_dir)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
logger.warning(f"Could not initialize SprintStateManager: {e}")
|
|
33
|
+
|
|
34
|
+
def collect_all_sprints(self) -> List[Dict[str, Any]]:
|
|
35
|
+
"""Scans .sprint/ and returns list of sprint data."""
|
|
36
|
+
sprints = []
|
|
37
|
+
|
|
38
|
+
if not self.sprint_dir.exists():
|
|
39
|
+
return sprints
|
|
40
|
+
|
|
41
|
+
for sprint_path in self.sprint_dir.iterdir():
|
|
42
|
+
if not sprint_path.is_dir():
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Skip hidden directories
|
|
46
|
+
if sprint_path.name.startswith('.'):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
sprint_data = self._parse_sprint(sprint_path)
|
|
50
|
+
if sprint_data:
|
|
51
|
+
sprints.append(sprint_data)
|
|
52
|
+
|
|
53
|
+
return sprints
|
|
54
|
+
|
|
55
|
+
def _parse_sprint(self, sprint_path: Path) -> Optional[Dict[str, Any]]:
|
|
56
|
+
"""Parses a single sprint directory."""
|
|
57
|
+
readme = sprint_path / "README.md"
|
|
58
|
+
todo = sprint_path / "TODO.md"
|
|
59
|
+
retro = sprint_path / "RETRO.md"
|
|
60
|
+
|
|
61
|
+
sprint_id = sprint_path.name
|
|
62
|
+
|
|
63
|
+
# Extract goal from README
|
|
64
|
+
goal = ""
|
|
65
|
+
title = sprint_id.replace("-", " ").title()
|
|
66
|
+
|
|
67
|
+
if readme.exists():
|
|
68
|
+
content = readme.read_text()
|
|
69
|
+
|
|
70
|
+
# Extract title from first heading
|
|
71
|
+
title_match = re.search(r'^#\s+Sprint:\s+(.+)$', content, re.MULTILINE)
|
|
72
|
+
if title_match:
|
|
73
|
+
title = title_match.group(1)
|
|
74
|
+
|
|
75
|
+
# Extract goal from ## Goal section
|
|
76
|
+
goal_match = re.search(r'##\s+Goal\s*\n+(.+?)(?:\n\n|\n##|$)', content, re.DOTALL)
|
|
77
|
+
if goal_match:
|
|
78
|
+
goal = goal_match.group(1).strip()
|
|
79
|
+
|
|
80
|
+
# Extract tasks from TODO.md
|
|
81
|
+
tasks = []
|
|
82
|
+
if todo.exists():
|
|
83
|
+
tasks = self._parse_todo(todo, sprint_id)
|
|
84
|
+
|
|
85
|
+
# Determine status
|
|
86
|
+
status = "closed" if retro.exists() else "active"
|
|
87
|
+
|
|
88
|
+
# Extract learnings from RETRO.md if available
|
|
89
|
+
learnings = []
|
|
90
|
+
if retro.exists():
|
|
91
|
+
learnings = self._parse_retro(retro)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"id": sprint_id,
|
|
95
|
+
"title": title,
|
|
96
|
+
"status": status,
|
|
97
|
+
"goals": goal,
|
|
98
|
+
"tasks": tasks,
|
|
99
|
+
"learnings": learnings
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def get_recent_context(self, limit: int = 3) -> List[Dict[str, Any]]:
|
|
103
|
+
"""Returns context from the most recent sprints."""
|
|
104
|
+
all_sprints = self.collect_all_sprints()
|
|
105
|
+
# Sort by sprint ID (assuming lexicographical order roughly matches chronological or use metadata if available)
|
|
106
|
+
# Using simple string sort on ID for now, assuming format '000-name'
|
|
107
|
+
sorted_sprints = sorted(all_sprints, key=lambda x: x['id'], reverse=True)
|
|
108
|
+
return sorted_sprints[:limit]
|
|
109
|
+
|
|
110
|
+
def _parse_retro(self, retro_path: Path) -> List[str]:
|
|
111
|
+
"""Mechanically extracts learnings from RETRO.md."""
|
|
112
|
+
learnings = []
|
|
113
|
+
content = retro_path.read_text()
|
|
114
|
+
capture = False
|
|
115
|
+
for line in content.split("\n"):
|
|
116
|
+
line_stripped = line.strip()
|
|
117
|
+
# Start capturing after these headers
|
|
118
|
+
if line_stripped.startswith("## ") and any(x in line_stripped for x in ["Learnings", "Learning", "Went Well", "To Improve", "Could Be Improved", "Action Items"]):
|
|
119
|
+
capture = True
|
|
120
|
+
continue
|
|
121
|
+
elif line_stripped.startswith("## "):
|
|
122
|
+
capture = False
|
|
123
|
+
|
|
124
|
+
if capture:
|
|
125
|
+
if line_stripped.startswith("- "):
|
|
126
|
+
learnings.append(line_stripped[2:].strip())
|
|
127
|
+
elif re.match(r'^\d+\.\s+', line_stripped):
|
|
128
|
+
learnings.append(re.sub(r'^\d+\.\s+', '', line_stripped).strip())
|
|
129
|
+
return learnings
|
|
130
|
+
|
|
131
|
+
def _parse_todo(self, todo_path: Path, sprint_id: str) -> List[Dict[str, Any]]:
|
|
132
|
+
"""Parses TODO.md and extracts tasks."""
|
|
133
|
+
tasks = []
|
|
134
|
+
content = todo_path.read_text()
|
|
135
|
+
|
|
136
|
+
task_counter = 1
|
|
137
|
+
for line in content.split("\n"):
|
|
138
|
+
line_stripped = line.strip()
|
|
139
|
+
|
|
140
|
+
# Match task lines: - [ ], - [x], - [/]
|
|
141
|
+
if re.match(r'^-\s+\[.\]', line_stripped):
|
|
142
|
+
# Extract checkbox status
|
|
143
|
+
is_done = re.match(r'^-\s+\[x\]', line_stripped, re.IGNORECASE)
|
|
144
|
+
is_in_progress = re.match(r'^-\s+\[/\]', line_stripped)
|
|
145
|
+
|
|
146
|
+
# Extract task title (everything after the checkbox)
|
|
147
|
+
title_match = re.search(r'^-\s+\[.\]\s+(.+)$', line_stripped)
|
|
148
|
+
if not title_match:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
title = title_match.group(1).strip()
|
|
152
|
+
|
|
153
|
+
# Generate task ID
|
|
154
|
+
task_id = f"{sprint_id}-task-{task_counter:03d}"
|
|
155
|
+
|
|
156
|
+
tasks.append({
|
|
157
|
+
"id": task_id,
|
|
158
|
+
"title": title,
|
|
159
|
+
"status": "done" if is_done else ("in-progress" if is_in_progress else "todo"),
|
|
160
|
+
"metadata": {}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
task_counter += 1
|
|
164
|
+
|
|
165
|
+
return tasks
|
onecoder/sync.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
from .api_client import get_api_client
|
|
7
|
+
from .config_manager import config_manager
|
|
8
|
+
from .sprint_collector import SprintCollector
|
|
9
|
+
from .issues import IssueManager
|
|
10
|
+
|
|
11
|
+
class ProjectConfig:
|
|
12
|
+
def __init__(self, repo_root: Path):
|
|
13
|
+
self.repo_root = repo_root
|
|
14
|
+
self.config_file = repo_root / ".onecoder.json"
|
|
15
|
+
|
|
16
|
+
def load(self) -> Dict[str, Any]:
|
|
17
|
+
if not self.config_file.exists():
|
|
18
|
+
return {}
|
|
19
|
+
try:
|
|
20
|
+
with open(self.config_file, "r") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
click.echo(f"Error loading project config: {e}")
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
def save(self, config: Dict[str, Any]):
|
|
27
|
+
try:
|
|
28
|
+
with open(self.config_file, "w") as f:
|
|
29
|
+
json.dump(config, f, indent=4)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
click.echo(f"Error saving project config: {e}")
|
|
32
|
+
|
|
33
|
+
def get_project_id(self) -> Optional[str]:
|
|
34
|
+
# Priority: Env Var > Local Config
|
|
35
|
+
project_id = os.getenv("ONECODER_PROJECT_ID")
|
|
36
|
+
if project_id:
|
|
37
|
+
return project_id
|
|
38
|
+
return self.load().get("project_id")
|
|
39
|
+
|
|
40
|
+
def find_repo_root() -> Path:
|
|
41
|
+
current = Path.cwd()
|
|
42
|
+
while current != current.parent:
|
|
43
|
+
if (current / ".git").exists():
|
|
44
|
+
return current
|
|
45
|
+
current = current.parent
|
|
46
|
+
return Path.cwd()
|
|
47
|
+
|
|
48
|
+
async def sync_sprint(client, project_id: str, sprint: Dict[str, Any]):
|
|
49
|
+
"""Syncs a single sprint to the API."""
|
|
50
|
+
try:
|
|
51
|
+
payload = {
|
|
52
|
+
"sprint": {
|
|
53
|
+
"id": sprint["id"],
|
|
54
|
+
"title": sprint["title"],
|
|
55
|
+
"status": sprint["status"],
|
|
56
|
+
"goals": sprint["goals"]
|
|
57
|
+
},
|
|
58
|
+
"tasks": sprint["tasks"],
|
|
59
|
+
"projectId": project_id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
result = await client.sync_sprint(payload)
|
|
63
|
+
click.secho(f" ✓ {sprint['id']}: {len(sprint['tasks'])} tasks", fg="green")
|
|
64
|
+
return result
|
|
65
|
+
except Exception as e:
|
|
66
|
+
click.secho(f" ✗ {sprint['id']}: {e}", fg="red")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
async def sync_project_context():
|
|
70
|
+
"""Aggregates local context and syncs with the API."""
|
|
71
|
+
repo_root = find_repo_root()
|
|
72
|
+
proj_config = ProjectConfig(repo_root)
|
|
73
|
+
project_id = proj_config.get_project_id()
|
|
74
|
+
|
|
75
|
+
if not project_id:
|
|
76
|
+
click.secho("Error: No project ID found. Run 'onecoder login' or set ONECODER_PROJECT_ID.", fg="red")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
token = config_manager.get_token()
|
|
80
|
+
if not token:
|
|
81
|
+
click.secho("Error: Not logged in. Run 'onecoder login'.", fg="red")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Aggregate context
|
|
85
|
+
spec_path = repo_root / "SPECIFICATION.md"
|
|
86
|
+
gov_path = repo_root / "governance.yaml"
|
|
87
|
+
learnings_path = repo_root / "ANTIGRAVITY.md"
|
|
88
|
+
|
|
89
|
+
sync_data = {
|
|
90
|
+
"metadata": {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if spec_path.exists():
|
|
94
|
+
sync_data["specification"] = spec_path.read_text()
|
|
95
|
+
|
|
96
|
+
if gov_path.exists():
|
|
97
|
+
sync_data["governance"] = gov_path.read_text()
|
|
98
|
+
|
|
99
|
+
if learnings_path.exists():
|
|
100
|
+
sync_data["metadata"]["learnings"] = learnings_path.read_text()
|
|
101
|
+
|
|
102
|
+
# Get project name from folder if not in config
|
|
103
|
+
if not sync_data.get("name"):
|
|
104
|
+
sync_data["name"] = repo_root.name
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
client = get_api_client(token)
|
|
108
|
+
|
|
109
|
+
# Sync project metadata
|
|
110
|
+
result = await client.sync_project(project_id, sync_data)
|
|
111
|
+
click.secho(f"✅ Project context synced successfully for ID: {project_id}", fg="green")
|
|
112
|
+
|
|
113
|
+
# NEW: Sync sprint data
|
|
114
|
+
collector = SprintCollector(repo_root)
|
|
115
|
+
sprints = collector.collect_all_sprints()
|
|
116
|
+
|
|
117
|
+
if sprints:
|
|
118
|
+
click.secho(f"\n📦 Syncing {len(sprints)} sprints...", fg="cyan")
|
|
119
|
+
for sprint in sprints:
|
|
120
|
+
await sync_sprint(client, project_id, sprint)
|
|
121
|
+
click.secho(f"✅ All sprints synced successfully", fg="green")
|
|
122
|
+
else:
|
|
123
|
+
click.secho("\nℹ️ No sprints found to sync", fg="yellow")
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
return result
|
|
127
|
+
except Exception as e:
|
|
128
|
+
click.secho(f"❌ Failed to sync project: {e}", fg="red")
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
async def sync_issues(client, project_id: str):
|
|
132
|
+
"""Syncs local issues to the API."""
|
|
133
|
+
manager = IssueManager()
|
|
134
|
+
issues = manager.get_all_issues()
|
|
135
|
+
|
|
136
|
+
if not issues:
|
|
137
|
+
click.secho("ℹ️ No issues found to sync", fg="yellow")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
click.secho(f"📦 Syncing {len(issues)} issues...", fg="cyan")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
payload = {
|
|
144
|
+
"projectId": project_id,
|
|
145
|
+
"issues": issues
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# We need to add sync_issues to api_client first?
|
|
149
|
+
# Or we can just use client.post directly if method not exists.
|
|
150
|
+
# Let's assume client has a generic method or we add specific one.
|
|
151
|
+
# Actually, let's look at api_client.py later. For now, assuming client.sync_issues exists or using _request.
|
|
152
|
+
# Wait, I should check api_client.py. But for now I'll use a generic call pattern if possible
|
|
153
|
+
# or assume I'll update api_client.py too.
|
|
154
|
+
# Let's just use the direct route for now if client exposes request.
|
|
155
|
+
|
|
156
|
+
# Checking previous code, client seems to have typed methods.
|
|
157
|
+
# I should probably update api_client.py as well.
|
|
158
|
+
# But let's write the call here assuming the method exists, and then go fix api_client.
|
|
159
|
+
|
|
160
|
+
result = await client.sync_issues(payload)
|
|
161
|
+
|
|
162
|
+
stats = result.get("stats", {})
|
|
163
|
+
click.secho(f"✅ Issues synced: {stats.get('upserted', 0)} upserted, {stats.get('errors', 0)} errors", fg="green")
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
click.secho(f"❌ Failed to sync issues: {e}", fg="red")
|
|
167
|
+
|
onecoder/tmux.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class TmuxManager:
|
|
8
|
+
"""
|
|
9
|
+
Manages tmux sessions and panes for delegated tasks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._check_tmux()
|
|
14
|
+
|
|
15
|
+
def _check_tmux(self):
|
|
16
|
+
try:
|
|
17
|
+
subprocess.run(["tmux", "-V"], capture_output=True, check=True)
|
|
18
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
19
|
+
raise RuntimeError("tmux is not installed or not available in PATH")
|
|
20
|
+
|
|
21
|
+
def _run_tmux(self, args: List[str]) -> str:
|
|
22
|
+
command = ["tmux"] + args
|
|
23
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
24
|
+
if result.returncode != 0:
|
|
25
|
+
logger.debug(f"Tmux command failed: {' '.join(command)}\nError: {result.stderr}")
|
|
26
|
+
raise RuntimeError(f"Tmux error: {result.stderr.strip()}")
|
|
27
|
+
return result.stdout.strip()
|
|
28
|
+
|
|
29
|
+
def has_session(self, name: str) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
self._run_tmux(["has-session", "-t", name])
|
|
32
|
+
return True
|
|
33
|
+
except RuntimeError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def create_session(self, name: str, command: Optional[str] = None, cwd: Optional[str] = None) -> None:
|
|
37
|
+
"""Creates a new detached tmux session."""
|
|
38
|
+
args = ["new-session", "-d", "-s", name]
|
|
39
|
+
if cwd:
|
|
40
|
+
args += ["-c", cwd]
|
|
41
|
+
if command:
|
|
42
|
+
args.append(command)
|
|
43
|
+
self._run_tmux(args)
|
|
44
|
+
|
|
45
|
+
def split_window(self, target_session: str, command: Optional[str] = None, horizontal: bool = True) -> None:
|
|
46
|
+
"""Splits the current window of a session."""
|
|
47
|
+
args = ["split-window", "-t", target_session]
|
|
48
|
+
if horizontal:
|
|
49
|
+
args.append("-h")
|
|
50
|
+
else:
|
|
51
|
+
args.append("-v")
|
|
52
|
+
if command:
|
|
53
|
+
args.append(command)
|
|
54
|
+
self._run_tmux(args)
|
|
55
|
+
|
|
56
|
+
def kill_session(self, name: str) -> None:
|
|
57
|
+
"""Kills a tmux session."""
|
|
58
|
+
if self.has_session(name):
|
|
59
|
+
self._run_tmux(["kill-session", "-t", name])
|
|
60
|
+
|
|
61
|
+
def list_sessions(self) -> List[str]:
|
|
62
|
+
"""Lists all tmux sessions."""
|
|
63
|
+
try:
|
|
64
|
+
output = self._run_tmux(["list-sessions", "-F", "#S"])
|
|
65
|
+
return output.split("\n")
|
|
66
|
+
except RuntimeError:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
def send_keys(self, target: str, keys: str, enter: bool = True) -> None:
|
|
70
|
+
"""Sends keys to a tmux session/pane."""
|
|
71
|
+
args = ["send-keys", "-t", target, keys]
|
|
72
|
+
if enter:
|
|
73
|
+
args.append("C-m")
|
|
74
|
+
self._run_tmux(args)
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
# Quick test
|
|
78
|
+
import time
|
|
79
|
+
mgr = TmuxManager()
|
|
80
|
+
session = "test-session"
|
|
81
|
+
print(f"Creating session: {session}")
|
|
82
|
+
mgr.create_session(session, "top")
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
print(f"Sessions: {mgr.list_sessions()}")
|
|
85
|
+
mgr.kill_session(session)
|
|
86
|
+
print("Session killed.")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Dict, Optional, List
|
|
4
|
+
from rank_bm25 import BM25Okapi
|
|
5
|
+
from .registry import registry
|
|
6
|
+
|
|
7
|
+
class DynamicToolExecutor:
|
|
8
|
+
"""
|
|
9
|
+
Manages discovery and execution of tools using BM25 for ranking.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.registry = registry
|
|
13
|
+
|
|
14
|
+
def _tokenize(self, text: str) -> List[str]:
|
|
15
|
+
return re.findall(r'\w+', text.lower())
|
|
16
|
+
|
|
17
|
+
async def find_and_execute_tool(self, query: str, **tool_args) -> Any:
|
|
18
|
+
"""
|
|
19
|
+
Finds the best tool for the query using BM25 and executes it.
|
|
20
|
+
"""
|
|
21
|
+
tools = self.registry.list_tools()
|
|
22
|
+
if not tools:
|
|
23
|
+
return "No tools registered in the system."
|
|
24
|
+
|
|
25
|
+
# Prepare corpus
|
|
26
|
+
tool_descriptions = [tool.description for tool in tools]
|
|
27
|
+
tokenized_corpus = [self._tokenize(doc) for doc in tool_descriptions]
|
|
28
|
+
|
|
29
|
+
# Init BM25
|
|
30
|
+
bm25 = BM25Okapi(tokenized_corpus)
|
|
31
|
+
|
|
32
|
+
# Query
|
|
33
|
+
tokenized_query = self._tokenize(query)
|
|
34
|
+
scores = bm25.get_scores(tokenized_query)
|
|
35
|
+
|
|
36
|
+
# Find best
|
|
37
|
+
best_index = scores.argmax()
|
|
38
|
+
best_score = scores[best_index]
|
|
39
|
+
best_tool = tools[best_index]
|
|
40
|
+
|
|
41
|
+
print(f"[DynamicToolExecutor] Selected tool: '{best_tool.name}' (Score: {best_score:.4f}) for query: '{query}'")
|
|
42
|
+
|
|
43
|
+
# Heuristic threshold could be added here, but for now we just take the top one
|
|
44
|
+
if best_score == 0:
|
|
45
|
+
print(f"[DynamicToolExecutor] Warning: Zero score match for query '{query}'. Defaulting to '{best_tool.name}'")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return await best_tool.execute_async(**tool_args)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return f"Error executing tool '{best_tool.name}': {e}"
|
|
51
|
+
|
|
52
|
+
# Global executor instance
|
|
53
|
+
executor = DynamicToolExecutor()
|