a5629295ac
New runtime/code: - src/ask_user_runtime.py - src/team_runtime.py New real tools in src/agent_tools.py: - ask_user_question - team_create - team_delete - team_list - team_get - send_message - team_messages - notebook_edit
387 lines
13 KiB
Python
387 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
|
|
DEFAULT_TEAM_STATE_FILE = Path('.port_sessions') / 'team_runtime.json'
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TeamDefinition:
|
|
name: str
|
|
description: str | None = None
|
|
members: tuple[str, ...] = ()
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
created_at: str = field(default_factory=_utc_now)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'TeamDefinition | None':
|
|
name = payload.get('name')
|
|
if not isinstance(name, str) or not name.strip():
|
|
return None
|
|
description = payload.get('description')
|
|
if description is not None and not isinstance(description, str):
|
|
description = None
|
|
raw_members = payload.get('members', ())
|
|
members = tuple(
|
|
item.strip()
|
|
for item in raw_members
|
|
if isinstance(item, str) and item.strip()
|
|
) if isinstance(raw_members, (list, tuple)) else ()
|
|
metadata = payload.get('metadata', {})
|
|
if not isinstance(metadata, dict):
|
|
metadata = {}
|
|
created_at = payload.get('created_at')
|
|
if not isinstance(created_at, str) or not created_at.strip():
|
|
created_at = _utc_now()
|
|
return cls(
|
|
name=name.strip(),
|
|
description=description.strip() if description else None,
|
|
members=members,
|
|
metadata=dict(metadata),
|
|
created_at=created_at,
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
'name': self.name,
|
|
'description': self.description,
|
|
'members': list(self.members),
|
|
'metadata': dict(self.metadata),
|
|
'created_at': self.created_at,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TeamMessage:
|
|
message_id: str
|
|
team_name: str
|
|
sender: str
|
|
text: str
|
|
recipient: str | None = None
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
created_at: str = field(default_factory=_utc_now)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'TeamMessage | None':
|
|
message_id = payload.get('message_id')
|
|
team_name = payload.get('team_name')
|
|
sender = payload.get('sender')
|
|
text = payload.get('text')
|
|
if not all(isinstance(value, str) and value.strip() for value in (message_id, team_name, sender, text)):
|
|
return None
|
|
recipient = payload.get('recipient')
|
|
if recipient is not None and not isinstance(recipient, str):
|
|
recipient = None
|
|
metadata = payload.get('metadata', {})
|
|
if not isinstance(metadata, dict):
|
|
metadata = {}
|
|
created_at = payload.get('created_at')
|
|
if not isinstance(created_at, str) or not created_at.strip():
|
|
created_at = _utc_now()
|
|
return cls(
|
|
message_id=message_id.strip(),
|
|
team_name=team_name.strip(),
|
|
sender=sender.strip(),
|
|
text=text,
|
|
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
|
|
metadata=dict(metadata),
|
|
created_at=created_at,
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
'message_id': self.message_id,
|
|
'team_name': self.team_name,
|
|
'sender': self.sender,
|
|
'text': self.text,
|
|
'recipient': self.recipient,
|
|
'metadata': dict(self.metadata),
|
|
'created_at': self.created_at,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TeamRuntime:
|
|
cwd: Path
|
|
teams: tuple[TeamDefinition, ...] = field(default_factory=tuple)
|
|
messages: tuple[TeamMessage, ...] = field(default_factory=tuple)
|
|
manifests: tuple[str, ...] = field(default_factory=tuple)
|
|
state_path: Path = field(default_factory=lambda: DEFAULT_TEAM_STATE_FILE.resolve())
|
|
|
|
@classmethod
|
|
def from_workspace(
|
|
cls,
|
|
cwd: Path,
|
|
additional_working_directories: tuple[str, ...] = (),
|
|
) -> 'TeamRuntime':
|
|
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
|
manifest_teams: list[TeamDefinition] = []
|
|
for manifest_path in manifest_paths:
|
|
manifest_teams.extend(_load_teams_from_manifest(manifest_path))
|
|
state_path = cwd.resolve() / DEFAULT_TEAM_STATE_FILE
|
|
payload = _load_state_payload(state_path)
|
|
raw_teams = payload.get('teams')
|
|
raw_messages = payload.get('messages')
|
|
if isinstance(raw_teams, list):
|
|
teams = tuple(
|
|
team
|
|
for team in (
|
|
TeamDefinition.from_dict(item)
|
|
for item in raw_teams
|
|
if isinstance(item, dict)
|
|
)
|
|
if team is not None
|
|
)
|
|
else:
|
|
teams = tuple(manifest_teams)
|
|
messages = tuple(
|
|
message
|
|
for message in (
|
|
TeamMessage.from_dict(item)
|
|
for item in raw_messages
|
|
if isinstance(item, dict)
|
|
)
|
|
if message is not None
|
|
) if isinstance(raw_messages, list) else ()
|
|
return cls(
|
|
cwd=cwd.resolve(),
|
|
teams=teams,
|
|
messages=messages,
|
|
manifests=tuple(str(path) for path in manifest_paths),
|
|
state_path=state_path,
|
|
)
|
|
|
|
def has_team_state(self) -> bool:
|
|
return bool(self.teams or self.messages or self.manifests)
|
|
|
|
def list_teams(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
limit: int | None = None,
|
|
) -> tuple[TeamDefinition, ...]:
|
|
teams = self.teams
|
|
if query:
|
|
needle = query.lower()
|
|
teams = tuple(
|
|
team
|
|
for team in teams
|
|
if needle in team.name.lower()
|
|
or needle in (team.description or '').lower()
|
|
or any(needle in member.lower() for member in team.members)
|
|
)
|
|
teams = tuple(sorted(teams, key=lambda team: team.name.lower()))
|
|
if limit is not None and limit >= 0:
|
|
teams = teams[:limit]
|
|
return teams
|
|
|
|
def get_team(self, name: str) -> TeamDefinition | None:
|
|
needle = name.strip().lower()
|
|
for team in self.teams:
|
|
if team.name.lower() == needle:
|
|
return team
|
|
return None
|
|
|
|
def create_team(
|
|
self,
|
|
name: str,
|
|
*,
|
|
description: str | None = None,
|
|
members: tuple[str, ...] | list[str] = (),
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> TeamDefinition:
|
|
normalized = name.strip()
|
|
if not normalized:
|
|
raise KeyError('team_name')
|
|
if self.get_team(normalized) is not None:
|
|
raise KeyError(normalized)
|
|
team = TeamDefinition(
|
|
name=normalized,
|
|
description=description.strip() if isinstance(description, str) and description.strip() else None,
|
|
members=tuple(
|
|
member.strip()
|
|
for member in members
|
|
if isinstance(member, str) and member.strip()
|
|
),
|
|
metadata=dict(metadata or {}),
|
|
)
|
|
self.teams = tuple(sorted((*self.teams, team), key=lambda item: item.name.lower()))
|
|
self._persist_state()
|
|
return team
|
|
|
|
def delete_team(self, name: str) -> TeamDefinition:
|
|
team = self.get_team(name)
|
|
if team is None:
|
|
raise KeyError(name)
|
|
self.teams = tuple(existing for existing in self.teams if existing.name != team.name)
|
|
self.messages = tuple(message for message in self.messages if message.team_name != team.name)
|
|
self._persist_state()
|
|
return team
|
|
|
|
def send_message(
|
|
self,
|
|
*,
|
|
team_name: str,
|
|
text: str,
|
|
sender: str,
|
|
recipient: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> TeamMessage:
|
|
team = self.get_team(team_name)
|
|
if team is None:
|
|
raise KeyError(team_name)
|
|
message = TeamMessage(
|
|
message_id=f'msg_{uuid4().hex[:10]}',
|
|
team_name=team.name,
|
|
sender=sender.strip(),
|
|
text=text,
|
|
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
|
|
metadata=dict(metadata or {}),
|
|
)
|
|
self.messages = (*self.messages, message)
|
|
self._persist_state()
|
|
return message
|
|
|
|
def render_summary(self) -> str:
|
|
lines = [
|
|
f'Configured teams: {len(self.teams)}',
|
|
f'Message history entries: {len(self.messages)}',
|
|
f'Team manifests: {len(self.manifests)}',
|
|
]
|
|
if self.teams:
|
|
lines.append('- Teams:')
|
|
for team in self.list_teams(limit=10):
|
|
lines.append(f' - {team.name} ({len(team.members)} members)')
|
|
if len(self.teams) > 10:
|
|
lines.append(f' - ... plus {len(self.teams) - 10} more')
|
|
return '\n'.join(lines)
|
|
|
|
def render_team(self, name: str) -> str:
|
|
team = self.get_team(name)
|
|
if team is None:
|
|
raise KeyError(name)
|
|
lines = ['# Team', '', f'- Name: {team.name}']
|
|
if team.description:
|
|
lines.append(f'- Description: {team.description}')
|
|
lines.append(f'- Members: {len(team.members)}')
|
|
if team.members:
|
|
lines.extend(f' - {member}' for member in team.members)
|
|
recent_messages = [message for message in self.messages if message.team_name == team.name][-5:]
|
|
if recent_messages:
|
|
lines.extend(['', '## Recent Messages'])
|
|
for message in recent_messages:
|
|
target = f' -> {message.recipient}' if message.recipient else ''
|
|
lines.append(f'- {message.sender}{target}: {message.text}')
|
|
return '\n'.join(lines)
|
|
|
|
def render_teams_index(self, *, query: str | None = None, limit: int = 50) -> str:
|
|
lines = ['# Teams', '']
|
|
teams = self.list_teams(query=query, limit=limit)
|
|
if not teams:
|
|
lines.append('No local teams are configured.')
|
|
return '\n'.join(lines)
|
|
for team in teams:
|
|
details = [team.name]
|
|
if team.description:
|
|
details.append(team.description)
|
|
if team.members:
|
|
details.append(f'members={",".join(team.members)}')
|
|
lines.append('- ' + ' ; '.join(details))
|
|
return '\n'.join(lines)
|
|
|
|
def render_messages(self, *, team_name: str | None = None, limit: int = 20) -> str:
|
|
lines = ['# Team Messages', '']
|
|
messages = list(self.messages)
|
|
if team_name:
|
|
team = self.get_team(team_name)
|
|
if team is None:
|
|
raise KeyError(team_name)
|
|
messages = [message for message in messages if message.team_name == team.name]
|
|
messages = messages[-limit:]
|
|
if not messages:
|
|
lines.append('No team messages recorded.')
|
|
return '\n'.join(lines)
|
|
for message in messages:
|
|
target = f' -> {message.recipient}' if message.recipient else ''
|
|
lines.append(
|
|
f'- [{message.team_name}] {message.sender}{target}: {message.text}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
def _persist_state(self) -> None:
|
|
payload = {
|
|
'teams': [team.to_dict() for team in self.teams],
|
|
'messages': [message.to_dict() for message in self.messages],
|
|
}
|
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.state_path.write_text(
|
|
json.dumps(payload, ensure_ascii=True, indent=2),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
|
|
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
|
|
candidates = [
|
|
cwd.resolve() / '.claw-teams.json',
|
|
cwd.resolve() / '.claw-team.json',
|
|
cwd.resolve() / '.claude' / 'teams.json',
|
|
]
|
|
for raw_path in additional_working_directories:
|
|
root = Path(raw_path).resolve()
|
|
candidates.extend(
|
|
[
|
|
root / '.claw-teams.json',
|
|
root / '.claw-team.json',
|
|
root / '.claude' / 'teams.json',
|
|
]
|
|
)
|
|
discovered: list[Path] = []
|
|
seen: set[Path] = set()
|
|
for candidate in candidates:
|
|
if not candidate.is_file():
|
|
continue
|
|
resolved = candidate.resolve()
|
|
if resolved in seen:
|
|
continue
|
|
seen.add(resolved)
|
|
discovered.append(resolved)
|
|
return tuple(discovered)
|
|
|
|
|
|
def _load_teams_from_manifest(path: Path) -> list[TeamDefinition]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return []
|
|
teams_payload = payload.get('teams')
|
|
if not isinstance(teams_payload, list):
|
|
return []
|
|
teams: list[TeamDefinition] = []
|
|
for item in teams_payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
team = TeamDefinition.from_dict(item)
|
|
if team is not None:
|
|
teams.append(team)
|
|
return teams
|
|
|
|
|
|
def _load_state_payload(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|