add mcp and online search
This commit is contained in:
+22
-1
@@ -1,5 +1,6 @@
|
||||
"""Python porting workspace for the Claude Code rewrite effort."""
|
||||
|
||||
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
|
||||
from .agent_context import (
|
||||
AgentContextSnapshot,
|
||||
build_context_snapshot,
|
||||
@@ -15,17 +16,20 @@ from .agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
|
||||
from .background_runtime import BackgroundSessionRuntime
|
||||
from .commands import PORTED_COMMANDS, build_command_backlog
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .config_runtime import ConfigMutation, ConfigRuntime
|
||||
from .mcp_runtime import MCPRuntime, MCPResource, MCPServerProfile, MCPTool
|
||||
from .parity_audit import ParityAuditResult, run_parity_audit
|
||||
from .plan_runtime import PlanRuntime, PlanStep
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .port_manifest import PortManifest, build_port_manifest
|
||||
from .query_engine import QueryEnginePort, TurnResult
|
||||
from .runtime import PortRuntime, RuntimeSession
|
||||
from .search_runtime import SearchProviderProfile, SearchResult, SearchRuntime, SearchStatusReport
|
||||
from .session_store import StoredSession, load_session, save_session
|
||||
from .system_init import build_system_init_message
|
||||
from .task import PortingTask
|
||||
from .task_runtime import TaskRuntime
|
||||
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
|
||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||
|
||||
__all__ = [
|
||||
@@ -34,11 +38,20 @@ __all__ = [
|
||||
'AgentPermissions',
|
||||
'AgentRunResult',
|
||||
'AgentRuntimeConfig',
|
||||
'AccountProfile',
|
||||
'AccountRuntime',
|
||||
'AccountSessionState',
|
||||
'AccountStatusReport',
|
||||
'AgentMessage',
|
||||
'AgentSessionState',
|
||||
'BackgroundSessionRuntime',
|
||||
'ConfigMutation',
|
||||
'ConfigRuntime',
|
||||
'LocalCodingAgent',
|
||||
'MCPResource',
|
||||
'MCPRuntime',
|
||||
'MCPServerProfile',
|
||||
'MCPTool',
|
||||
'ModelConfig',
|
||||
'ParityAuditResult',
|
||||
'PlanRuntime',
|
||||
@@ -49,8 +62,13 @@ __all__ = [
|
||||
'PortingTask',
|
||||
'QueryEnginePort',
|
||||
'RuntimeSession',
|
||||
'SearchProviderProfile',
|
||||
'SearchResult',
|
||||
'SearchRuntime',
|
||||
'SearchStatusReport',
|
||||
'StoredSession',
|
||||
'TaskRuntime',
|
||||
'TokenCounterInfo',
|
||||
'TurnResult',
|
||||
'PORTED_COMMANDS',
|
||||
'PORTED_TOOLS',
|
||||
@@ -61,7 +79,10 @@ __all__ = [
|
||||
'build_tool_backlog',
|
||||
'build_tool_context',
|
||||
'clear_context_caches',
|
||||
'clear_token_counter_cache',
|
||||
'count_tokens',
|
||||
'default_tool_registry',
|
||||
'describe_token_counter',
|
||||
'execute_tool',
|
||||
'get_system_context',
|
||||
'get_user_context',
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_ACCOUNT_STATE_DIR = Path('.port_sessions')
|
||||
DEFAULT_ACCOUNT_STATE_FILE = DEFAULT_ACCOUNT_STATE_DIR / 'account_runtime.json'
|
||||
ACCOUNT_MANIFEST_PATHS = (
|
||||
Path('.claw-account.json'),
|
||||
Path('.claude/account.json'),
|
||||
Path('.claude/auth.json'),
|
||||
)
|
||||
CREDENTIAL_ENV_VARS = (
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'LITELLM_MASTER_KEY',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountProfile:
|
||||
name: str
|
||||
provider: str
|
||||
identity: str
|
||||
source_manifest: str
|
||||
description: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountSessionState:
|
||||
provider: str
|
||||
identity: str
|
||||
logged_in: bool
|
||||
logged_in_at: str
|
||||
profile_name: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
source_manifest: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountStatusReport:
|
||||
logged_in: bool
|
||||
detail: str
|
||||
provider: str | None = None
|
||||
identity: str | None = None
|
||||
profile_name: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
source_manifest: str | None = None
|
||||
manifest_count: int = 0
|
||||
profile_count: int = 0
|
||||
credential_env_vars: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f'logged_in={self.logged_in}',
|
||||
f'detail={self.detail}',
|
||||
f'manifest_count={self.manifest_count}',
|
||||
f'profile_count={self.profile_count}',
|
||||
]
|
||||
if self.provider:
|
||||
lines.append(f'provider={self.provider}')
|
||||
if self.identity:
|
||||
lines.append(f'identity={self.identity}')
|
||||
if self.profile_name:
|
||||
lines.append(f'profile={self.profile_name}')
|
||||
if self.org:
|
||||
lines.append(f'org={self.org}')
|
||||
if self.auth_mode:
|
||||
lines.append(f'auth_mode={self.auth_mode}')
|
||||
if self.api_base:
|
||||
lines.append(f'api_base={self.api_base}')
|
||||
if self.source_manifest:
|
||||
lines.append(f'source_manifest={self.source_manifest}')
|
||||
if self.credential_env_vars:
|
||||
lines.append('credential_env=' + ','.join(self.credential_env_vars))
|
||||
if self.metadata:
|
||||
for key, value in sorted(self.metadata.items()):
|
||||
lines.append(f'metadata.{key}={value}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountRuntime:
|
||||
cwd: Path
|
||||
profiles: tuple[AccountProfile, ...] = field(default_factory=tuple)
|
||||
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||
state_path: Path = field(default_factory=lambda: DEFAULT_ACCOUNT_STATE_FILE.resolve())
|
||||
active_session: AccountSessionState | None = None
|
||||
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||
credential_env_vars: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'AccountRuntime':
|
||||
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||
profiles: list[AccountProfile] = []
|
||||
for manifest_path in manifest_paths:
|
||||
profiles.extend(_load_profiles_from_manifest(manifest_path))
|
||||
state_path = (cwd.resolve() / DEFAULT_ACCOUNT_STATE_FILE).resolve()
|
||||
payload = _load_state_payload(state_path)
|
||||
active_session = _session_from_payload(payload.get('active_session'))
|
||||
history_payload = payload.get('history')
|
||||
history = tuple(
|
||||
item for item in history_payload if isinstance(item, dict)
|
||||
) if isinstance(history_payload, list) else ()
|
||||
return cls(
|
||||
cwd=cwd.resolve(),
|
||||
profiles=tuple(profiles),
|
||||
manifests=tuple(str(path) for path in manifest_paths),
|
||||
state_path=state_path,
|
||||
active_session=active_session,
|
||||
history=history,
|
||||
credential_env_vars=_detect_credential_env_vars(),
|
||||
)
|
||||
|
||||
def has_account_state(self) -> bool:
|
||||
return bool(self.profiles or self.active_session is not None or self.credential_env_vars)
|
||||
|
||||
def list_profiles(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[AccountProfile, ...]:
|
||||
profiles = self.profiles
|
||||
if query:
|
||||
needle = query.lower()
|
||||
profiles = tuple(
|
||||
profile
|
||||
for profile in profiles
|
||||
if needle in profile.name.lower()
|
||||
or needle in profile.provider.lower()
|
||||
or needle in profile.identity.lower()
|
||||
or needle in (profile.org or '').lower()
|
||||
)
|
||||
if limit is not None and limit >= 0:
|
||||
profiles = profiles[:limit]
|
||||
return profiles
|
||||
|
||||
def get_profile(self, name_or_identity: str) -> AccountProfile | None:
|
||||
needle = name_or_identity.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
for profile in self.profiles:
|
||||
if profile.name.lower() == needle or profile.identity.lower() == needle:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def login(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
auth_mode: str | None = None,
|
||||
) -> AccountStatusReport:
|
||||
profile = self.get_profile(target)
|
||||
if profile is not None:
|
||||
session = AccountSessionState(
|
||||
provider=profile.provider,
|
||||
identity=profile.identity,
|
||||
logged_in=True,
|
||||
logged_in_at=_utc_now(),
|
||||
profile_name=profile.name,
|
||||
org=profile.org,
|
||||
auth_mode=profile.auth_mode,
|
||||
api_base=profile.api_base,
|
||||
source_manifest=profile.source_manifest,
|
||||
metadata=dict(profile.metadata),
|
||||
)
|
||||
detail = f'Activated account profile {profile.name}'
|
||||
else:
|
||||
session = AccountSessionState(
|
||||
provider=(provider or 'custom').strip() or 'custom',
|
||||
identity=target.strip(),
|
||||
logged_in=True,
|
||||
logged_in_at=_utc_now(),
|
||||
auth_mode=(auth_mode or 'token').strip() or 'token',
|
||||
metadata={'ephemeral': True},
|
||||
)
|
||||
detail = f'Activated ephemeral account identity {target.strip()}'
|
||||
self.active_session = session
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'login',
|
||||
'provider': session.provider,
|
||||
'identity': session.identity,
|
||||
'profile_name': session.profile_name,
|
||||
'logged_in_at': session.logged_in_at,
|
||||
}
|
||||
)
|
||||
self._persist_state()
|
||||
return self.current_report(detail=detail)
|
||||
|
||||
def logout(self, *, reason: str = 'manual_logout') -> AccountStatusReport:
|
||||
previous = self.active_session
|
||||
detail = (
|
||||
f'Logged out {previous.identity}'
|
||||
if previous is not None
|
||||
else 'No active account session was present.'
|
||||
)
|
||||
if previous is not None:
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'logout',
|
||||
'provider': previous.provider,
|
||||
'identity': previous.identity,
|
||||
'profile_name': previous.profile_name,
|
||||
'reason': reason,
|
||||
'logged_out_at': _utc_now(),
|
||||
}
|
||||
)
|
||||
self.active_session = None
|
||||
self._persist_state()
|
||||
return AccountStatusReport(
|
||||
logged_in=False,
|
||||
detail=detail,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
)
|
||||
|
||||
def current_report(self, *, detail: str | None = None) -> AccountStatusReport:
|
||||
if self.active_session is None:
|
||||
return AccountStatusReport(
|
||||
logged_in=False,
|
||||
detail=detail or 'No active account session.',
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
)
|
||||
session = self.active_session
|
||||
return AccountStatusReport(
|
||||
logged_in=session.logged_in,
|
||||
detail=detail or f'Active account session for {session.identity}',
|
||||
provider=session.provider,
|
||||
identity=session.identity,
|
||||
profile_name=session.profile_name,
|
||||
org=session.org,
|
||||
auth_mode=session.auth_mode,
|
||||
api_base=session.api_base,
|
||||
source_manifest=session.source_manifest,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
metadata=dict(session.metadata),
|
||||
)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local account manifests: {len(self.manifests)}',
|
||||
f'Configured account profiles: {len(self.profiles)}',
|
||||
]
|
||||
if self.credential_env_vars:
|
||||
lines.append('- Credential env vars: ' + ', '.join(self.credential_env_vars))
|
||||
for profile in self.profiles[:5]:
|
||||
details = [profile.name, profile.provider, profile.identity]
|
||||
if profile.org:
|
||||
details.append(f'org={profile.org}')
|
||||
if profile.auth_mode:
|
||||
details.append(f'auth_mode={profile.auth_mode}')
|
||||
lines.append('- Profile: ' + ' ; '.join(details))
|
||||
if self.active_session is None:
|
||||
lines.append('- Active account session: none')
|
||||
else:
|
||||
session = self.active_session
|
||||
lines.append(
|
||||
f'- Active account session: {session.provider} / {session.identity}'
|
||||
)
|
||||
if session.profile_name:
|
||||
lines.append(f' - profile: {session.profile_name}')
|
||||
if session.auth_mode:
|
||||
lines.append(f' - auth_mode: {session.auth_mode}')
|
||||
if session.org:
|
||||
lines.append(f' - org: {session.org}')
|
||||
if session.api_base:
|
||||
lines.append(f' - api_base: {session.api_base}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profile(self, name_or_identity: str) -> str:
|
||||
profile = self.get_profile(name_or_identity)
|
||||
if profile is None:
|
||||
return f'# Account Profile\n\nUnknown account profile: {name_or_identity}'
|
||||
lines = [
|
||||
'# Account Profile',
|
||||
'',
|
||||
f'- Name: {profile.name}',
|
||||
f'- Provider: {profile.provider}',
|
||||
f'- Identity: {profile.identity}',
|
||||
f'- Source manifest: {profile.source_manifest}',
|
||||
]
|
||||
if profile.org:
|
||||
lines.append(f'- Org: {profile.org}')
|
||||
if profile.auth_mode:
|
||||
lines.append(f'- Auth mode: {profile.auth_mode}')
|
||||
if profile.api_base:
|
||||
lines.append(f'- API base: {profile.api_base}')
|
||||
if profile.description:
|
||||
lines.extend(['', profile.description])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profiles_index(self, *, query: str | None = None) -> str:
|
||||
profiles = self.list_profiles(query=query, limit=100)
|
||||
lines = ['# Account Profiles', '']
|
||||
if not profiles:
|
||||
lines.append('No local account profiles discovered.')
|
||||
return '\n'.join(lines)
|
||||
for profile in profiles:
|
||||
details = [profile.name, profile.provider, profile.identity]
|
||||
if profile.org:
|
||||
details.append(f'org={profile.org}')
|
||||
if profile.auth_mode:
|
||||
details.append(f'auth_mode={profile.auth_mode}')
|
||||
lines.append('- ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _append_history(self, entry: dict[str, Any]) -> None:
|
||||
self.history = (*self.history, entry)
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
payload = {
|
||||
'active_session': (
|
||||
asdict(self.active_session)
|
||||
if self.active_session is not None
|
||||
else None
|
||||
),
|
||||
'history': list(self.history[-100:]),
|
||||
}
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
def _discover_manifest_paths(
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...],
|
||||
) -> tuple[Path, ...]:
|
||||
candidate_roots = [cwd.resolve()]
|
||||
for raw_path in additional_working_directories:
|
||||
path = Path(raw_path).resolve()
|
||||
if path not in candidate_roots:
|
||||
candidate_roots.append(path)
|
||||
discovered: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for root in candidate_roots:
|
||||
for relative_path in ACCOUNT_MANIFEST_PATHS:
|
||||
path = (root / relative_path).resolve()
|
||||
if path in seen or not path.exists() or not path.is_file():
|
||||
continue
|
||||
seen.add(path)
|
||||
discovered.append(path)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _load_profiles_from_manifest(path: Path) -> list[AccountProfile]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
if isinstance(payload, dict):
|
||||
profiles_payload = payload.get('profiles')
|
||||
if isinstance(profiles_payload, list):
|
||||
return [
|
||||
profile
|
||||
for item in profiles_payload
|
||||
for profile in [_profile_from_payload(item, path)]
|
||||
if profile is not None
|
||||
]
|
||||
single = _profile_from_payload(payload, path)
|
||||
return [single] if single is not None else []
|
||||
return []
|
||||
|
||||
|
||||
def _profile_from_payload(payload: Any, path: Path) -> AccountProfile | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
raw_name = payload.get('name') or payload.get('profile')
|
||||
provider = payload.get('provider')
|
||||
identity = payload.get('identity') or payload.get('email') or payload.get('user')
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
return None
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raw_name = identity
|
||||
metadata = payload.get('metadata')
|
||||
return AccountProfile(
|
||||
name=str(raw_name).strip(),
|
||||
provider=provider.strip(),
|
||||
identity=identity.strip(),
|
||||
source_manifest=str(path),
|
||||
description=_optional_str(payload.get('description')),
|
||||
org=_optional_str(payload.get('org')),
|
||||
auth_mode=_optional_str(payload.get('authMode') or payload.get('auth_mode')),
|
||||
api_base=_optional_str(payload.get('apiBase') or payload.get('api_base')),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
def _session_from_payload(payload: Any) -> AccountSessionState | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
provider = payload.get('provider')
|
||||
identity = payload.get('identity')
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
return None
|
||||
metadata = payload.get('metadata')
|
||||
return AccountSessionState(
|
||||
provider=provider.strip(),
|
||||
identity=identity.strip(),
|
||||
logged_in=bool(payload.get('logged_in', True)),
|
||||
logged_in_at=str(payload.get('logged_in_at', _utc_now())),
|
||||
profile_name=_optional_str(payload.get('profile_name')),
|
||||
org=_optional_str(payload.get('org')),
|
||||
auth_mode=_optional_str(payload.get('auth_mode')),
|
||||
api_base=_optional_str(payload.get('api_base')),
|
||||
source_manifest=_optional_str(payload.get('source_manifest')),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _detect_credential_env_vars() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
key
|
||||
for key in CREDENTIAL_ENV_VARS
|
||||
if isinstance(os.environ.get(key), str) and os.environ.get(key, '').strip()
|
||||
)
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -9,10 +9,14 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from .agent_plugin_cache import load_plugin_cache_summary
|
||||
from .account_runtime import AccountRuntime
|
||||
from .config_runtime import ConfigRuntime
|
||||
from .hook_policy import HookPolicyRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .remote_runtime import RemoteRuntime
|
||||
from .search_runtime import SearchRuntime
|
||||
from .task_runtime import TaskRuntime
|
||||
from .agent_types import AgentRuntimeConfig
|
||||
|
||||
@@ -224,6 +228,18 @@ def _get_user_context_cached(
|
||||
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if mcp_runtime.resources:
|
||||
context['mcpRuntime'] = mcp_runtime.render_summary()
|
||||
remote_runtime = RemoteRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if remote_runtime.has_remote_config():
|
||||
context['remoteRuntime'] = remote_runtime.render_summary()
|
||||
search_runtime = SearchRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if search_runtime.has_search_runtime():
|
||||
context['searchRuntime'] = search_runtime.render_summary()
|
||||
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if account_runtime.has_account_state():
|
||||
context['accountRuntime'] = account_runtime.render_summary()
|
||||
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
|
||||
if config_runtime.has_config():
|
||||
context['configRuntime'] = config_runtime.render_summary()
|
||||
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
|
||||
if plan_runtime.steps:
|
||||
context['planRuntime'] = plan_runtime.render_summary()
|
||||
|
||||
+22
-15
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
|
||||
from .agent_session import AgentMessage, AgentSessionState
|
||||
from .tokenizer_runtime import describe_token_counter, count_tokens
|
||||
|
||||
_PATH_HEADER_RE = re.compile(r'^## ((?:/|[A-Za-z]:[\\/]).+)$', re.MULTILINE)
|
||||
|
||||
@@ -48,12 +48,13 @@ class ContextUsageReport:
|
||||
system_context_entries: tuple[UsageEntry, ...]
|
||||
memory_files: tuple[UsageEntry, ...]
|
||||
message_breakdown: MessageBreakdown
|
||||
token_counter_backend: str
|
||||
token_counter_source: str
|
||||
token_counter_accurate: bool
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, math.ceil(len(text) / 4))
|
||||
def estimate_tokens(text: str, model: str | None = None) -> int:
|
||||
return count_tokens(text, model)
|
||||
|
||||
|
||||
def infer_context_window(model: str) -> int:
|
||||
@@ -78,21 +79,23 @@ def collect_context_usage(
|
||||
strategy: str,
|
||||
) -> ContextUsageReport:
|
||||
raw_max_tokens = infer_context_window(model)
|
||||
token_counter = describe_token_counter(model)
|
||||
count = lambda text: estimate_tokens(text, model) # noqa: E731
|
||||
system_prompt_sections = tuple(
|
||||
UsageEntry(name=_section_name(part, idx), tokens=estimate_tokens(part))
|
||||
UsageEntry(name=_section_name(part, idx), tokens=count(part))
|
||||
for idx, part in enumerate(session.system_prompt_parts, start=1)
|
||||
)
|
||||
system_context_entries = tuple(
|
||||
UsageEntry(name=key, tokens=estimate_tokens(f'{key}: {value}'))
|
||||
UsageEntry(name=key, tokens=count(f'{key}: {value}'))
|
||||
for key, value in session.system_context.items()
|
||||
if value
|
||||
)
|
||||
user_context_entries = tuple(
|
||||
UsageEntry(name=key, tokens=estimate_tokens(_render_user_context_chunk(key, value)))
|
||||
UsageEntry(name=key, tokens=count(_render_user_context_chunk(key, value)))
|
||||
for key, value in session.user_context.items()
|
||||
if value
|
||||
)
|
||||
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd')))
|
||||
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd'), model=model))
|
||||
|
||||
user_context_tokens = sum(entry.tokens for entry in user_context_entries)
|
||||
system_prompt_tokens = (
|
||||
@@ -112,20 +115,20 @@ def collect_context_usage(
|
||||
if _is_user_context_message(session, index, message):
|
||||
continue
|
||||
if message.role == 'user':
|
||||
conversation_user_tokens += estimate_tokens(message.content)
|
||||
conversation_user_tokens += count(message.content)
|
||||
continue
|
||||
if message.role == 'assistant':
|
||||
assistant_tokens += estimate_tokens(message.content)
|
||||
assistant_tokens += count(message.content)
|
||||
for tool_call in message.tool_calls:
|
||||
serialized = json.dumps(tool_call, ensure_ascii=True)
|
||||
tokens = estimate_tokens(serialized)
|
||||
tokens = count(serialized)
|
||||
tool_call_tokens += tokens
|
||||
tool_name = _extract_tool_call_name(tool_call)
|
||||
call_totals = tool_usage.setdefault(tool_name, [0, 0])
|
||||
call_totals[0] += tokens
|
||||
continue
|
||||
if message.role == 'tool':
|
||||
tokens = estimate_tokens(message.content)
|
||||
tokens = count(message.content)
|
||||
tool_result_tokens += tokens
|
||||
result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0])
|
||||
result_totals[1] += tokens
|
||||
@@ -176,6 +179,9 @@ def collect_context_usage(
|
||||
user_context_tokens=user_context_tokens,
|
||||
tool_calls_by_type=tool_calls_by_type,
|
||||
),
|
||||
token_counter_backend=token_counter.backend,
|
||||
token_counter_source=token_counter.source,
|
||||
token_counter_accurate=token_counter.accurate,
|
||||
)
|
||||
|
||||
|
||||
@@ -185,6 +191,7 @@ def format_context_usage(report: ContextUsageReport) -> str:
|
||||
'',
|
||||
f'**Model:** {report.model} ',
|
||||
f'**Estimated tokens:** {_format_tokens(report.total_tokens)} / {_format_tokens(report.raw_max_tokens)} ({report.percentage:.1f}%) ',
|
||||
f'**Token counter:** {report.token_counter_backend} ({report.token_counter_source}){" [accurate]" if report.token_counter_accurate else " [fallback]"} ',
|
||||
f'**Context strategy:** {report.strategy} ',
|
||||
f'**Messages in session:** {report.message_count}',
|
||||
'',
|
||||
@@ -330,7 +337,7 @@ def _is_user_context_message(
|
||||
)
|
||||
|
||||
|
||||
def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
|
||||
def _parse_memory_usage(claude_md: str | None, *, model: str | None = None) -> list[UsageEntry]:
|
||||
if not claude_md:
|
||||
return []
|
||||
matches = list(_PATH_HEADER_RE.finditer(claude_md))
|
||||
@@ -341,7 +348,7 @@ def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md)
|
||||
content = claude_md[start:end].strip()
|
||||
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content)))
|
||||
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content, model)))
|
||||
return entries
|
||||
|
||||
|
||||
|
||||
+56
-2
@@ -92,6 +92,10 @@ def build_system_prompt_parts(
|
||||
get_using_your_tools_section(enabled_tool_names),
|
||||
get_plugin_guidance_section(prompt_context),
|
||||
get_mcp_guidance_section(prompt_context),
|
||||
get_remote_guidance_section(prompt_context),
|
||||
get_search_guidance_section(prompt_context),
|
||||
get_account_guidance_section(prompt_context),
|
||||
get_config_guidance_section(prompt_context),
|
||||
get_plan_guidance_section(prompt_context),
|
||||
get_task_guidance_section(prompt_context),
|
||||
get_hook_policy_guidance_section(prompt_context),
|
||||
@@ -229,13 +233,62 @@ def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
|
||||
if not mcp_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local MCP manifests may expose additional resources through the runtime.',
|
||||
'Local MCP manifests may expose additional resources and transport-backed tools through the runtime.',
|
||||
'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.',
|
||||
'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.',
|
||||
'Use MCP transport tools when a configured MCP server exposes real callable tools that should stay outside the local Python tool registry.',
|
||||
'Treat MCP resource and tool summaries as discoverability hints and prefer reading a specific resource URI or calling a specific MCP tool before relying on its contents.',
|
||||
]
|
||||
return '\n'.join(['# MCP', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_remote_guidance_section(prompt_context: PromptContext) -> str:
|
||||
remote_runtime = prompt_context.user_context.get('remoteRuntime')
|
||||
if not remote_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local remote manifests or an active remote connection may be available in the workspace context.',
|
||||
'Use remote status or remote-connect flows before assuming a specific remote target is active.',
|
||||
'Treat remote summaries as runtime state for the current workspace, including active target, session URL, and remote workspace path when present.',
|
||||
]
|
||||
return '\n'.join(['# Remote', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_search_guidance_section(prompt_context: PromptContext) -> str:
|
||||
search_runtime = prompt_context.user_context.get('searchRuntime')
|
||||
if not search_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local workspace web-search providers may be available through the runtime.',
|
||||
'Use the web_search tool when the task requires discovering external pages rather than fetching a known URL directly.',
|
||||
'Use web_fetch after web_search when you need to inspect the contents of a selected result page.',
|
||||
]
|
||||
return '\n'.join(['# Search', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_account_guidance_section(prompt_context: PromptContext) -> str:
|
||||
account_runtime = prompt_context.user_context.get('accountRuntime')
|
||||
if not account_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local workspace account or auth state may be available through the runtime.',
|
||||
'Use account tools and account slash commands when the task depends on local login state, configured profiles, or auth metadata.',
|
||||
'Treat local account summaries as workspace runtime state, including active identity, configured profiles, and visible credential env vars.',
|
||||
]
|
||||
return '\n'.join(['# Account', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_config_guidance_section(prompt_context: PromptContext) -> str:
|
||||
config_runtime = prompt_context.user_context.get('configRuntime')
|
||||
if not config_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local workspace config and settings files may be available through the runtime.',
|
||||
'Use config tools instead of ad-hoc file edits when the task is specifically about settings or config state.',
|
||||
'Treat the effective config as merged workspace state, and inspect the specific source when override order matters.',
|
||||
]
|
||||
return '\n'.join(['# Config', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_task_guidance_section(prompt_context: PromptContext) -> str:
|
||||
task_runtime = prompt_context.user_context.get('taskRuntime')
|
||||
if not task_runtime:
|
||||
@@ -244,6 +297,7 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
|
||||
'A local runtime task list may be available to track ongoing work.',
|
||||
'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.',
|
||||
'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.',
|
||||
'Use task_next and the richer task state tools when dependencies or blocked work matter.',
|
||||
]
|
||||
return '\n'.join(['# Tasks', *prepend_bullets(items)])
|
||||
|
||||
|
||||
+299
-7
@@ -7,9 +7,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .account_runtime import AccountRuntime
|
||||
from .agent_manager import AgentManager
|
||||
from .agent_context import clear_context_caches
|
||||
from .agent_context import render_context_report as render_agent_context_report
|
||||
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
||||
from .config_runtime import ConfigRuntime
|
||||
from .hook_policy import HookPolicyRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .agent_prompting import (
|
||||
@@ -42,7 +45,10 @@ from .agent_types import (
|
||||
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .remote_runtime import RemoteRuntime
|
||||
from .search_runtime import SearchRuntime
|
||||
from .task_runtime import TaskRuntime
|
||||
from .tokenizer_runtime import describe_token_counter
|
||||
from .session_store import (
|
||||
StoredAgentSession,
|
||||
load_agent_session,
|
||||
@@ -75,6 +81,10 @@ class LocalCodingAgent:
|
||||
plugin_runtime: PluginRuntime | None = None
|
||||
hook_policy_runtime: HookPolicyRuntime | None = None
|
||||
mcp_runtime: MCPRuntime | None = None
|
||||
remote_runtime: RemoteRuntime | None = None
|
||||
search_runtime: SearchRuntime | None = None
|
||||
account_runtime: AccountRuntime | None = None
|
||||
config_runtime: ConfigRuntime | None = None
|
||||
plan_runtime: PlanRuntime | None = None
|
||||
task_runtime: TaskRuntime | None = None
|
||||
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||||
@@ -104,6 +114,23 @@ class LocalCodingAgent:
|
||||
self.runtime_config.cwd,
|
||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||
)
|
||||
if self.remote_runtime is None:
|
||||
self.remote_runtime = RemoteRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||
)
|
||||
if self.search_runtime is None:
|
||||
self.search_runtime = SearchRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||
)
|
||||
if self.account_runtime is None:
|
||||
self.account_runtime = AccountRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||
)
|
||||
if self.config_runtime is None:
|
||||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||||
if self.plan_runtime is None:
|
||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||
if self.task_runtime is None:
|
||||
@@ -120,12 +147,17 @@ class LocalCodingAgent:
|
||||
self.client = OpenAICompatClient(self.model_config)
|
||||
self.tool_context = build_tool_context(
|
||||
self.runtime_config,
|
||||
tool_registry=self.tool_registry,
|
||||
extra_env=(
|
||||
self.hook_policy_runtime.safe_env()
|
||||
if self.hook_policy_runtime is not None
|
||||
else None
|
||||
),
|
||||
search_runtime=self.search_runtime,
|
||||
account_runtime=self.account_runtime,
|
||||
config_runtime=self.config_runtime,
|
||||
mcp_runtime=self.mcp_runtime,
|
||||
remote_runtime=self.remote_runtime,
|
||||
plan_runtime=self.plan_runtime,
|
||||
task_runtime=self.task_runtime,
|
||||
)
|
||||
@@ -919,6 +951,7 @@ class LocalCodingAgent:
|
||||
'preflight_count': len(plugin_preflight_messages),
|
||||
}
|
||||
)
|
||||
self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result)
|
||||
history_entry = self._build_file_history_entry(
|
||||
tool_call=tool_call,
|
||||
tool_result=tool_result,
|
||||
@@ -1270,9 +1303,9 @@ class LocalCodingAgent:
|
||||
if current_total <= target_tokens and not reactive:
|
||||
break
|
||||
message = session.messages[index]
|
||||
original_tokens = estimate_tokens(message.content)
|
||||
original_tokens = estimate_tokens(message.content, self.model_config.model)
|
||||
replacement = self._build_snipped_message_content(message)
|
||||
replacement_tokens = estimate_tokens(replacement)
|
||||
replacement_tokens = estimate_tokens(replacement, self.model_config.model)
|
||||
if replacement_tokens >= original_tokens:
|
||||
continue
|
||||
session.tombstone_message(
|
||||
@@ -2832,28 +2865,194 @@ class LocalCodingAgent:
|
||||
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
||||
return '\n'.join(['# Memory', '', claude_md])
|
||||
|
||||
def render_account_report(self, profile: str | None = None) -> str:
|
||||
if self.account_runtime is None:
|
||||
return '# Account\n\nNo local account runtime is available.'
|
||||
if profile:
|
||||
return self.account_runtime.render_profile(profile)
|
||||
return '\n'.join(['# Account', '', self.account_runtime.render_summary()])
|
||||
|
||||
def render_search_report(
|
||||
self,
|
||||
query: str | None = None,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
max_results: int = 5,
|
||||
domains: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||||
return (
|
||||
'# Search\n\nNo local search provider is available. '
|
||||
'Add a .claw-search.json or .claude/search.json manifest, '
|
||||
'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.'
|
||||
)
|
||||
if query:
|
||||
try:
|
||||
return self.search_runtime.render_search_results(
|
||||
query,
|
||||
provider_name=provider,
|
||||
max_results=max_results,
|
||||
domains=domains,
|
||||
timeout_seconds=self.runtime_config.command_timeout_seconds,
|
||||
)
|
||||
except (KeyError, LookupError, OSError, ValueError) as exc:
|
||||
return f'# Search\n\nSearch failed: {exc}'
|
||||
if provider:
|
||||
return self.search_runtime.render_provider(provider)
|
||||
return '\n'.join(['# Search', '', self.search_runtime.render_summary()])
|
||||
|
||||
def render_search_providers_report(self, query: str | None = None) -> str:
|
||||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||||
return '# Search Providers\n\nNo local search providers discovered.'
|
||||
return self.search_runtime.render_providers_index(query=query)
|
||||
|
||||
def render_search_activate_report(self, provider: str) -> str:
|
||||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||||
return '# Search\n\nNo local search provider is available.'
|
||||
try:
|
||||
report = self.search_runtime.activate_provider(provider)
|
||||
except KeyError:
|
||||
return f'# Search\n\nUnknown search provider: {provider}'
|
||||
clear_context_caches()
|
||||
self.tool_context = replace(
|
||||
self.tool_context,
|
||||
search_runtime=self.search_runtime,
|
||||
)
|
||||
return '\n'.join(['# Search', '', report.as_text()])
|
||||
|
||||
def render_account_profiles_report(self, query: str | None = None) -> str:
|
||||
if self.account_runtime is None:
|
||||
return '# Account Profiles\n\nNo local account runtime is available.'
|
||||
return self.account_runtime.render_profiles_index(query=query)
|
||||
|
||||
def render_account_login_report(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
auth_mode: str | None = None,
|
||||
) -> str:
|
||||
if self.account_runtime is None:
|
||||
return '# Account\n\nNo local account runtime is available.'
|
||||
report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode)
|
||||
clear_context_caches()
|
||||
return '\n'.join(['# Account', '', report.as_text()])
|
||||
|
||||
def render_account_logout_report(self) -> str:
|
||||
if self.account_runtime is None:
|
||||
return '# Account\n\nNo local account runtime is available.'
|
||||
report = self.account_runtime.logout(reason='slash_or_cli_logout')
|
||||
clear_context_caches()
|
||||
return '\n'.join(['# Account', '', report.as_text()])
|
||||
|
||||
def render_config_report(self) -> str:
|
||||
if self.config_runtime is None:
|
||||
return '# Config\n\nNo local config runtime is available.'
|
||||
return '\n'.join(['# Config', '', self.config_runtime.render_summary()])
|
||||
|
||||
def render_config_effective_report(self) -> str:
|
||||
if self.config_runtime is None:
|
||||
return '# Config Effective\n\nNo local config runtime is available.'
|
||||
return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()])
|
||||
|
||||
def render_config_source_report(self, source: str) -> str:
|
||||
if self.config_runtime is None:
|
||||
return '# Config Source\n\nNo local config runtime is available.'
|
||||
return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)])
|
||||
|
||||
def render_config_value_report(self, key_path: str, source: str | None = None) -> str:
|
||||
if self.config_runtime is None:
|
||||
return '# Config Value\n\nNo local config runtime is available.'
|
||||
try:
|
||||
rendered = self.config_runtime.render_value(key_path, source=source)
|
||||
except KeyError as exc:
|
||||
label = source if source is not None else key_path
|
||||
return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}'
|
||||
return '\n'.join(['# Config Value', '', rendered])
|
||||
|
||||
def render_mcp_report(self, query: str | None = None) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP\n\nNo local MCP manifests or resources discovered.'
|
||||
return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.'
|
||||
if query:
|
||||
return self.mcp_runtime.render_resource_index(query=query)
|
||||
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
|
||||
|
||||
def render_remote_report(self, target: str | None = None) -> str:
|
||||
if self.remote_runtime is None:
|
||||
return '# Remote\n\nNo local remote runtime is available.'
|
||||
if target:
|
||||
report = self.remote_runtime.connect(target)
|
||||
clear_context_caches()
|
||||
return '\n'.join(['# Remote', '', report.as_text()])
|
||||
return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()])
|
||||
|
||||
def render_remote_mode_report(self, target: str, *, mode: str) -> str:
|
||||
if self.remote_runtime is None:
|
||||
return '# Remote\n\nNo local remote runtime is available.'
|
||||
report = self.remote_runtime.connect(target, mode=mode)
|
||||
clear_context_caches()
|
||||
return '\n'.join(['# Remote', '', report.as_text()])
|
||||
|
||||
def render_remote_profiles_report(self, query: str | None = None) -> str:
|
||||
if self.remote_runtime is None:
|
||||
return '# Remote Profiles\n\nNo local remote runtime is available.'
|
||||
return self.remote_runtime.render_profiles_index(query=query)
|
||||
|
||||
def render_remote_disconnect_report(self) -> str:
|
||||
if self.remote_runtime is None:
|
||||
return '# Remote\n\nNo local remote runtime is available.'
|
||||
report = self.remote_runtime.disconnect()
|
||||
clear_context_caches()
|
||||
return '\n'.join(['# Remote', '', report.as_text()])
|
||||
|
||||
def render_mcp_resources_report(self, query: str | None = None) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP Resources\n\nNo local MCP manifests or resources discovered.'
|
||||
return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.'
|
||||
return self.mcp_runtime.render_resource_index(query=query)
|
||||
|
||||
def render_mcp_resource_report(self, uri: str) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP Resource\n\nNo local MCP manifests or resources discovered.'
|
||||
return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.'
|
||||
return self.mcp_runtime.render_resource(uri)
|
||||
|
||||
def render_mcp_tools_report(
|
||||
self,
|
||||
query: str | None = None,
|
||||
*,
|
||||
server: str | None = None,
|
||||
) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.'
|
||||
return self.mcp_runtime.render_tool_index(query=query, server_name=server)
|
||||
|
||||
def render_mcp_call_tool_report(
|
||||
self,
|
||||
tool_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
server: str | None = None,
|
||||
) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.'
|
||||
try:
|
||||
return self.mcp_runtime.render_tool_call(
|
||||
tool_name,
|
||||
arguments=arguments,
|
||||
server_name=server,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return f'# MCP Tool Result\n\n{exc}'
|
||||
|
||||
def render_tasks_report(self, status: str | None = None) -> str:
|
||||
if self.task_runtime is None:
|
||||
return '# Tasks\n\nNo local task runtime is available.'
|
||||
return self.task_runtime.render_tasks(status=status)
|
||||
|
||||
def render_next_tasks_report(self) -> str:
|
||||
if self.task_runtime is None:
|
||||
return '# Next Tasks\n\nNo local task runtime is available.'
|
||||
return self.task_runtime.render_next_tasks()
|
||||
|
||||
def render_plan_report(self) -> str:
|
||||
if self.plan_runtime is None:
|
||||
return '# Plan\n\nNo local plan runtime is available.'
|
||||
@@ -2891,10 +3090,12 @@ class LocalCodingAgent:
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_status_report(self) -> str:
|
||||
token_counter = describe_token_counter(self.model_config.model)
|
||||
lines = [
|
||||
'# Status',
|
||||
'',
|
||||
f'- Model: {self.model_config.model}',
|
||||
f'- Token counter: {token_counter.backend} ({token_counter.source})',
|
||||
f'- Registered tools: {len(self.tool_registry)}',
|
||||
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
|
||||
f'- Session ID: {self.active_session_id or "none"}',
|
||||
@@ -2904,8 +3105,37 @@ class LocalCodingAgent:
|
||||
lines.append(
|
||||
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
|
||||
)
|
||||
if self.mcp_runtime is not None and self.mcp_runtime.resources:
|
||||
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
|
||||
if self.mcp_runtime is not None:
|
||||
if self.mcp_runtime.resources:
|
||||
lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}')
|
||||
if self.mcp_runtime.servers:
|
||||
lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}')
|
||||
if self.remote_runtime is not None and self.remote_runtime.has_remote_config():
|
||||
lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}')
|
||||
if self.remote_runtime.active_connection is not None:
|
||||
connection = self.remote_runtime.active_connection
|
||||
lines.append(
|
||||
f'- Active remote: {connection.mode} -> {connection.target}'
|
||||
)
|
||||
if self.search_runtime is not None and self.search_runtime.has_search_runtime():
|
||||
lines.append(f'- Search providers: {len(self.search_runtime.providers)}')
|
||||
active_provider = self.search_runtime.current_provider()
|
||||
if active_provider is not None:
|
||||
lines.append(
|
||||
f'- Active search provider: {active_provider.name} ({active_provider.provider})'
|
||||
)
|
||||
if self.account_runtime is not None and self.account_runtime.has_account_state():
|
||||
lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}')
|
||||
if self.account_runtime.active_session is not None:
|
||||
session = self.account_runtime.active_session
|
||||
lines.append(
|
||||
f'- Active account: {session.provider} -> {session.identity}'
|
||||
)
|
||||
if self.config_runtime is not None and self.config_runtime.has_config():
|
||||
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
|
||||
lines.append(
|
||||
f'- Effective config keys: {len(self.config_runtime.list_keys())}'
|
||||
)
|
||||
if self.plan_runtime is not None and self.plan_runtime.steps:
|
||||
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
||||
if self.task_runtime is not None and self.task_runtime.tasks:
|
||||
@@ -2945,6 +3175,68 @@ class LocalCodingAgent:
|
||||
)
|
||||
self.resume_source_session_id = None
|
||||
|
||||
def _refresh_runtime_views_for_tool_result(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_result: ToolExecutionResult,
|
||||
) -> None:
|
||||
if not tool_result.ok:
|
||||
return
|
||||
refresh_tool_names = {
|
||||
'update_plan',
|
||||
'plan_clear',
|
||||
'task_create',
|
||||
'task_update',
|
||||
'task_start',
|
||||
'task_complete',
|
||||
'task_block',
|
||||
'task_cancel',
|
||||
'todo_write',
|
||||
'search_activate_provider',
|
||||
'remote_connect',
|
||||
'remote_disconnect',
|
||||
'account_login',
|
||||
'account_logout',
|
||||
'config_set',
|
||||
}
|
||||
if tool_name not in refresh_tool_names:
|
||||
return
|
||||
clear_context_caches()
|
||||
additional_dirs = tuple(
|
||||
str(path) for path in self.runtime_config.additional_working_directories
|
||||
)
|
||||
if tool_name.startswith('remote_'):
|
||||
self.remote_runtime = RemoteRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
additional_working_directories=additional_dirs,
|
||||
)
|
||||
if tool_name.startswith('search_'):
|
||||
self.search_runtime = SearchRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
additional_working_directories=additional_dirs,
|
||||
)
|
||||
if tool_name.startswith('account_'):
|
||||
self.account_runtime = AccountRuntime.from_workspace(
|
||||
self.runtime_config.cwd,
|
||||
additional_working_directories=additional_dirs,
|
||||
)
|
||||
if tool_name == 'config_set':
|
||||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||||
if tool_name.startswith('task_') or tool_name == 'todo_write':
|
||||
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
|
||||
if tool_name.startswith('plan_') or tool_name == 'update_plan':
|
||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||
self.tool_context = replace(
|
||||
self.tool_context,
|
||||
tool_registry=self.tool_registry,
|
||||
search_runtime=self.search_runtime,
|
||||
account_runtime=self.account_runtime,
|
||||
config_runtime=self.config_runtime,
|
||||
remote_runtime=self.remote_runtime,
|
||||
plan_runtime=self.plan_runtime,
|
||||
task_runtime=self.task_runtime,
|
||||
)
|
||||
|
||||
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
|
||||
if self.plugin_runtime is None:
|
||||
return prompt
|
||||
|
||||
+193
-1
@@ -114,6 +114,66 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Show discovered local MCP manifests and resource counts.',
|
||||
handler=_handle_mcp,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('search',),
|
||||
description='Show search runtime status, list or activate providers, or run a real web search query.',
|
||||
handler=_handle_search,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('remote',),
|
||||
description='Show local remote runtime status or activate a remote target/profile.',
|
||||
handler=_handle_remote,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('account',),
|
||||
description='Show local account runtime status or configured account profiles.',
|
||||
handler=_handle_account,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('login',),
|
||||
description='Activate a local account profile or ephemeral identity.',
|
||||
handler=_handle_login,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('logout',),
|
||||
description='Clear the active local account session.',
|
||||
handler=_handle_logout,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('config', 'settings'),
|
||||
description='Show local config runtime state, effective config, config sources, or a config value.',
|
||||
handler=_handle_config,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('remotes',),
|
||||
description='List configured local remote profiles.',
|
||||
handler=_handle_remotes,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('ssh',),
|
||||
description='Activate a local SSH remote target/profile.',
|
||||
handler=_handle_ssh,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('teleport',),
|
||||
description='Activate a local teleport remote target/profile.',
|
||||
handler=_handle_teleport,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('direct-connect',),
|
||||
description='Activate a local direct-connect remote target/profile.',
|
||||
handler=_handle_direct_connect,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('deep-link',),
|
||||
description='Activate a local deep-link remote target/profile.',
|
||||
handler=_handle_deep_link,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('disconnect', 'remote-disconnect'),
|
||||
description='Disconnect the active local remote runtime target.',
|
||||
handler=_handle_remote_disconnect,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('resources',),
|
||||
description='List local MCP resources, optionally filtered by a query string.',
|
||||
@@ -129,6 +189,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Show the local runtime task list, optionally filtered by status.',
|
||||
handler=_handle_tasks,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('task-next', 'next-task'),
|
||||
description='Show the next actionable tasks from the local runtime task list.',
|
||||
handler=_handle_task_next,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('plan', 'planner'),
|
||||
description='Show the current local runtime plan.',
|
||||
@@ -221,8 +286,131 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
|
||||
|
||||
|
||||
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
command = args.strip()
|
||||
if not command:
|
||||
return _local_result(input_text, agent.render_mcp_report())
|
||||
if command == 'tools':
|
||||
return _local_result(input_text, agent.render_mcp_tools_report())
|
||||
if command.startswith('tools '):
|
||||
query = command.split(' ', 1)[1].strip()
|
||||
return _local_result(input_text, agent.render_mcp_tools_report(query or None))
|
||||
if command.startswith('tool '):
|
||||
tool_name = command.split(' ', 1)[1].strip()
|
||||
if not tool_name:
|
||||
return _local_result(input_text, 'Usage: /mcp tool <tool-name>')
|
||||
return _local_result(input_text, agent.render_mcp_call_tool_report(tool_name))
|
||||
return _local_result(input_text, agent.render_mcp_report(command))
|
||||
|
||||
|
||||
def _handle_search(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
command = args.strip()
|
||||
if not command:
|
||||
return _local_result(input_text, agent.render_search_report())
|
||||
if command == 'providers':
|
||||
return _local_result(input_text, agent.render_search_providers_report())
|
||||
if command.startswith('providers '):
|
||||
query = command.split(' ', 1)[1].strip()
|
||||
return _local_result(input_text, agent.render_search_providers_report(query or None))
|
||||
if command.startswith('provider '):
|
||||
provider = command.split(' ', 1)[1].strip()
|
||||
if not provider:
|
||||
return _local_result(input_text, 'Usage: /search provider <name>')
|
||||
return _local_result(input_text, agent.render_search_report(provider=provider))
|
||||
if command.startswith('use '):
|
||||
provider = command.split(' ', 1)[1].strip()
|
||||
if not provider:
|
||||
return _local_result(input_text, 'Usage: /search use <name>')
|
||||
return _local_result(input_text, agent.render_search_activate_report(provider))
|
||||
return _local_result(input_text, agent.render_search_report(command))
|
||||
|
||||
|
||||
def _handle_remote(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
target = args or None
|
||||
return _local_result(input_text, agent.render_remote_report(target))
|
||||
|
||||
|
||||
def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
command = args.strip()
|
||||
if not command:
|
||||
return _local_result(input_text, agent.render_account_report())
|
||||
if command == 'profiles':
|
||||
return _local_result(input_text, agent.render_account_profiles_report())
|
||||
if command.startswith('profile '):
|
||||
profile = command.split(' ', 1)[1].strip()
|
||||
if not profile:
|
||||
return _local_result(input_text, 'Usage: /account profile <name>')
|
||||
return _local_result(input_text, agent.render_account_report(profile))
|
||||
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
|
||||
|
||||
|
||||
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
target = args.strip()
|
||||
if not target:
|
||||
return _local_result(input_text, 'Usage: /login <profile-or-identity>')
|
||||
return _local_result(input_text, agent.render_account_login_report(target))
|
||||
|
||||
|
||||
def _handle_logout(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_account_logout_report())
|
||||
|
||||
|
||||
def _handle_config(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
command = args.strip()
|
||||
if not command:
|
||||
return _local_result(input_text, agent.render_config_report())
|
||||
if command == 'effective':
|
||||
return _local_result(input_text, agent.render_config_effective_report())
|
||||
if command.startswith('source '):
|
||||
source = command.split(' ', 1)[1].strip()
|
||||
if not source:
|
||||
return _local_result(input_text, 'Usage: /config source <source-name>')
|
||||
return _local_result(input_text, agent.render_config_source_report(source))
|
||||
if command.startswith('get '):
|
||||
key_path = command.split(' ', 1)[1].strip()
|
||||
if not key_path:
|
||||
return _local_result(input_text, 'Usage: /config get <key-path>')
|
||||
return _local_result(input_text, agent.render_config_value_report(key_path))
|
||||
return _local_result(
|
||||
input_text,
|
||||
'Usage: /config [effective|source <name>|get <key-path>]',
|
||||
)
|
||||
|
||||
|
||||
def _handle_remotes(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
query = args or None
|
||||
return _local_result(input_text, agent.render_mcp_report(query))
|
||||
return _local_result(input_text, agent.render_remote_profiles_report(query))
|
||||
|
||||
|
||||
def _handle_ssh(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /ssh <target-or-profile>')
|
||||
return _local_result(input_text, agent.render_remote_mode_report(args, mode='ssh'))
|
||||
|
||||
|
||||
def _handle_teleport(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /teleport <target-or-profile>')
|
||||
return _local_result(input_text, agent.render_remote_mode_report(args, mode='teleport'))
|
||||
|
||||
|
||||
def _handle_direct_connect(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /direct-connect <target-or-profile>')
|
||||
return _local_result(input_text, agent.render_remote_mode_report(args, mode='direct-connect'))
|
||||
|
||||
|
||||
def _handle_deep_link(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /deep-link <target-or-profile>')
|
||||
return _local_result(input_text, agent.render_remote_mode_report(args, mode='deep-link'))
|
||||
|
||||
|
||||
def _handle_remote_disconnect(
|
||||
agent: 'LocalCodingAgent',
|
||||
_args: str,
|
||||
input_text: str,
|
||||
) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_remote_disconnect_report())
|
||||
|
||||
|
||||
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
@@ -241,6 +429,10 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas
|
||||
return _local_result(input_text, agent.render_tasks_report(status))
|
||||
|
||||
|
||||
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_next_tasks_report())
|
||||
|
||||
|
||||
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_plan_report())
|
||||
|
||||
|
||||
+1015
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_CONFIG_DIR = Path('.claude')
|
||||
PROJECT_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.json'
|
||||
LOCAL_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.local.json'
|
||||
LEGACY_CONFIG_PATHS = (
|
||||
Path('.claw-config.json'),
|
||||
Path('.codex-config.json'),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigSource:
|
||||
name: str
|
||||
path: str
|
||||
settings: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigMutation:
|
||||
source_name: str
|
||||
key_path: str
|
||||
store_path: str
|
||||
before_sha256: str | None
|
||||
after_sha256: str
|
||||
before_preview: str | None
|
||||
after_preview: str
|
||||
effective_key_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigRuntime:
|
||||
cwd: Path
|
||||
sources: tuple[ConfigSource, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(cls, cwd: Path) -> 'ConfigRuntime':
|
||||
root = cwd.resolve()
|
||||
sources: list[ConfigSource] = []
|
||||
for source_name, path in _discover_source_paths(root):
|
||||
payload = _load_json_object(path)
|
||||
if payload is None:
|
||||
continue
|
||||
sources.append(
|
||||
ConfigSource(
|
||||
name=source_name,
|
||||
path=str(path),
|
||||
settings=payload,
|
||||
)
|
||||
)
|
||||
return cls(cwd=root, sources=tuple(sources))
|
||||
|
||||
def has_config(self) -> bool:
|
||||
return bool(self.sources)
|
||||
|
||||
def effective_settings(self) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for source in self.sources:
|
||||
merged = _deep_merge(merged, source.settings)
|
||||
return merged
|
||||
|
||||
def list_keys(
|
||||
self,
|
||||
*,
|
||||
source: str | None = None,
|
||||
prefix: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
payload = self._payload_for_source(source)
|
||||
flattened = sorted(_flatten_keys(payload))
|
||||
if prefix:
|
||||
flattened = [key for key in flattened if key.startswith(prefix)]
|
||||
if limit is not None and limit >= 0:
|
||||
flattened = flattened[:limit]
|
||||
return tuple(flattened)
|
||||
|
||||
def get_value(
|
||||
self,
|
||||
key_path: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> Any:
|
||||
payload = self._payload_for_source(source)
|
||||
return _get_nested_value(payload, key_path)
|
||||
|
||||
def set_value(
|
||||
self,
|
||||
key_path: str,
|
||||
value: Any,
|
||||
*,
|
||||
source: str = 'local',
|
||||
) -> ConfigMutation:
|
||||
resolved_source, path = self._resolve_writable_source(source)
|
||||
before_payload = _load_json_object(path) or {}
|
||||
before_text = path.read_text(encoding='utf-8') if path.exists() else None
|
||||
updated_payload = json.loads(json.dumps(before_payload))
|
||||
_set_nested_value(updated_payload, key_path, value)
|
||||
after_text = json.dumps(updated_payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(after_text, encoding='utf-8')
|
||||
refreshed = ConfigRuntime.from_workspace(self.cwd)
|
||||
self.sources = refreshed.sources
|
||||
return ConfigMutation(
|
||||
source_name=resolved_source,
|
||||
key_path=key_path,
|
||||
store_path=str(path),
|
||||
before_sha256=_sha256_or_none(before_text),
|
||||
after_sha256=_sha256(after_text),
|
||||
before_preview=_preview(before_text),
|
||||
after_preview=_preview(after_text),
|
||||
effective_key_count=len(_flatten_keys(self.effective_settings())),
|
||||
)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Config sources: {len(self.sources)}',
|
||||
f'Effective keys: {len(_flatten_keys(self.effective_settings()))}',
|
||||
]
|
||||
if not self.sources:
|
||||
lines.append('- No local config files discovered.')
|
||||
lines.append(f'- Project settings path: {(self.cwd / PROJECT_SETTINGS_PATH).resolve()}')
|
||||
lines.append(f'- Local settings path: {(self.cwd / LOCAL_SETTINGS_PATH).resolve()}')
|
||||
return '\n'.join(lines)
|
||||
for source in self.sources:
|
||||
lines.append(
|
||||
f'- {source.name}: {source.path} ({len(_flatten_keys(source.settings))} key(s))'
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_keys(
|
||||
self,
|
||||
*,
|
||||
source: str | None = None,
|
||||
prefix: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> str:
|
||||
keys = self.list_keys(source=source, prefix=prefix, limit=limit)
|
||||
if not keys:
|
||||
return '(no config keys)'
|
||||
return '\n'.join(keys)
|
||||
|
||||
def render_value(
|
||||
self,
|
||||
key_path: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
self.get_value(key_path, source=source),
|
||||
ensure_ascii=True,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
def render_effective_config(self) -> str:
|
||||
return json.dumps(self.effective_settings(), ensure_ascii=True, indent=2, sort_keys=True)
|
||||
|
||||
def render_source(self, source: str) -> str:
|
||||
resolved = self._find_source(source)
|
||||
if resolved is None:
|
||||
return f'# Config\n\nUnknown config source: {source}'
|
||||
return json.dumps(resolved.settings, ensure_ascii=True, indent=2, sort_keys=True)
|
||||
|
||||
def _payload_for_source(self, source: str | None) -> dict[str, Any]:
|
||||
if source is None:
|
||||
return self.effective_settings()
|
||||
resolved = self._find_source(source)
|
||||
if resolved is None:
|
||||
raise KeyError(source)
|
||||
return resolved.settings
|
||||
|
||||
def _find_source(self, source: str) -> ConfigSource | None:
|
||||
alias = _normalize_source_name(source)
|
||||
for config_source in self.sources:
|
||||
if _normalize_source_name(config_source.name) == alias:
|
||||
return config_source
|
||||
return None
|
||||
|
||||
def _resolve_writable_source(self, source: str) -> tuple[str, Path]:
|
||||
alias = _normalize_source_name(source)
|
||||
if alias in {'project', 'project-settings', 'settings'}:
|
||||
return 'project', (self.cwd / PROJECT_SETTINGS_PATH).resolve()
|
||||
if alias in {'local', 'local-settings'}:
|
||||
return 'local', (self.cwd / LOCAL_SETTINGS_PATH).resolve()
|
||||
if alias in {'legacy', 'legacy-project'}:
|
||||
return 'legacy', (self.cwd / LEGACY_CONFIG_PATHS[0]).resolve()
|
||||
raise KeyError(source)
|
||||
|
||||
|
||||
def _discover_source_paths(cwd: Path) -> tuple[tuple[str, Path], ...]:
|
||||
candidates = [
|
||||
('legacy-claw', (cwd / LEGACY_CONFIG_PATHS[0]).resolve()),
|
||||
('legacy-codex', (cwd / LEGACY_CONFIG_PATHS[1]).resolve()),
|
||||
('project', (cwd / PROJECT_SETTINGS_PATH).resolve()),
|
||||
('local', (cwd / LOCAL_SETTINGS_PATH).resolve()),
|
||||
]
|
||||
discovered: list[tuple[str, Path]] = []
|
||||
seen: set[Path] = set()
|
||||
for source_name, path in candidates:
|
||||
if path in seen or not path.exists() or not path.is_file():
|
||||
continue
|
||||
seen.add(path)
|
||||
discovered.append((source_name, path))
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _load_json_object(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return dict(payload) if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _flatten_keys(payload: dict[str, Any], *, prefix: str = '') -> tuple[str, ...]:
|
||||
keys: list[str] = []
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
combined = f'{prefix}.{key}' if prefix else key
|
||||
keys.append(combined)
|
||||
if isinstance(value, dict):
|
||||
keys.extend(_flatten_keys(value, prefix=combined))
|
||||
return tuple(keys)
|
||||
|
||||
|
||||
def _get_nested_value(payload: dict[str, Any], key_path: str) -> Any:
|
||||
current: Any = payload
|
||||
for segment in _split_key_path(key_path):
|
||||
if not isinstance(current, dict) or segment not in current:
|
||||
raise KeyError(key_path)
|
||||
current = current[segment]
|
||||
return current
|
||||
|
||||
|
||||
def _set_nested_value(payload: dict[str, Any], key_path: str, value: Any) -> None:
|
||||
current: dict[str, Any] = payload
|
||||
segments = _split_key_path(key_path)
|
||||
for segment in segments[:-1]:
|
||||
child = current.get(segment)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
current[segment] = child
|
||||
current = child
|
||||
current[segments[-1]] = value
|
||||
|
||||
|
||||
def _split_key_path(key_path: str) -> tuple[str, ...]:
|
||||
segments = tuple(
|
||||
segment.strip()
|
||||
for segment in key_path.split('.')
|
||||
if segment.strip()
|
||||
)
|
||||
if not segments:
|
||||
raise KeyError(key_path)
|
||||
return segments
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in overlay.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _normalize_source_name(source: str) -> str:
|
||||
return source.strip().lower().replace('_', '-')
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _sha256_or_none(text: str | None) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
return _sha256(text)
|
||||
|
||||
|
||||
def _preview(text: str | None, limit: int = 220) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
stripped = text.strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[:limit] + '...'
|
||||
+344
-53
@@ -9,6 +9,7 @@ import json
|
||||
from typing import Callable
|
||||
|
||||
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
||||
from .account_runtime import AccountRuntime
|
||||
from .agent_runtime import LocalCodingAgent
|
||||
from .agent_types import (
|
||||
AgentPermissions,
|
||||
@@ -21,12 +22,21 @@ from .agent_types import (
|
||||
from .bootstrap_graph import build_bootstrap_graph
|
||||
from .command_graph import build_command_graph
|
||||
from .commands import execute_command, get_command, get_commands, render_command_index
|
||||
from .direct_modes import run_deep_link, run_direct_connect
|
||||
from .config_runtime import ConfigRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .parity_audit import run_parity_audit
|
||||
from .permissions import ToolPermissionContext
|
||||
from .port_manifest import build_port_manifest
|
||||
from .query_engine import QueryEnginePort
|
||||
from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
|
||||
from .remote_runtime import (
|
||||
RemoteRuntime,
|
||||
run_deep_link_mode,
|
||||
run_direct_connect_mode,
|
||||
run_remote_mode,
|
||||
run_ssh_mode,
|
||||
run_teleport_mode,
|
||||
)
|
||||
from .search_runtime import SearchRuntime
|
||||
from .runtime import PortRuntime
|
||||
from .session_store import (
|
||||
StoredAgentSession,
|
||||
@@ -248,6 +258,58 @@ def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument('--scratchpad-root')
|
||||
|
||||
|
||||
def _launch_background_agent(args: argparse.Namespace) -> int:
|
||||
background_runtime = BackgroundSessionRuntime()
|
||||
background_id = background_runtime.create_id()
|
||||
forwarded_args: list[str] = []
|
||||
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
|
||||
forwarded_args.extend(['--background-root', str(background_runtime.root)])
|
||||
command = build_background_worker_command(
|
||||
background_id=background_id,
|
||||
prompt=args.prompt,
|
||||
forwarded_args=forwarded_args,
|
||||
)
|
||||
record = background_runtime.launch(
|
||||
command,
|
||||
prompt=args.prompt,
|
||||
workspace_cwd=Path(args.cwd).resolve(),
|
||||
model=args.model,
|
||||
background_id=background_id,
|
||||
process_cwd=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
print('# Background Session')
|
||||
print(f'background_id={record.background_id}')
|
||||
print(f'pid={record.pid}')
|
||||
print(f'log_path={record.log_path}')
|
||||
print(f'record_path={record.record_path}')
|
||||
return 0
|
||||
|
||||
|
||||
def _run_background_worker(args: argparse.Namespace) -> int:
|
||||
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
|
||||
exit_code = 1
|
||||
stop_reason = 'worker_failed'
|
||||
session_id = None
|
||||
session_path = None
|
||||
try:
|
||||
agent = _build_agent(args)
|
||||
result = agent.run(args.prompt)
|
||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||
exit_code = 0
|
||||
stop_reason = result.stop_reason or 'completed'
|
||||
session_id = result.session_id
|
||||
session_path = result.session_path
|
||||
return 0
|
||||
finally:
|
||||
background_runtime.mark_finished(
|
||||
args.background_id,
|
||||
exit_code=exit_code,
|
||||
stop_reason=stop_reason,
|
||||
session_id=session_id,
|
||||
session_path=session_path,
|
||||
)
|
||||
|
||||
|
||||
def _build_resumed_agent(args: argparse.Namespace) -> tuple[LocalCodingAgent, StoredAgentSession]:
|
||||
stored_session = load_agent_session(args.session_id)
|
||||
model_config = deserialize_model_config(stored_session.model_config)
|
||||
@@ -519,14 +581,86 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching')
|
||||
remote_parser.add_argument('target')
|
||||
remote_parser.add_argument('--cwd', default='.')
|
||||
ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching')
|
||||
ssh_parser.add_argument('target')
|
||||
ssh_parser.add_argument('--cwd', default='.')
|
||||
teleport_parser = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching')
|
||||
teleport_parser.add_argument('target')
|
||||
teleport_parser.add_argument('--cwd', default='.')
|
||||
direct_parser = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching')
|
||||
direct_parser.add_argument('target')
|
||||
direct_parser.add_argument('--cwd', default='.')
|
||||
deep_link_parser = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching')
|
||||
deep_link_parser.add_argument('target')
|
||||
deep_link_parser.add_argument('--cwd', default='.')
|
||||
remote_status_parser = subparsers.add_parser('remote-status', help='show local remote runtime status')
|
||||
remote_status_parser.add_argument('--cwd', default='.')
|
||||
remote_profiles_parser = subparsers.add_parser('remote-profiles', help='list configured local remote profiles')
|
||||
remote_profiles_parser.add_argument('--cwd', default='.')
|
||||
remote_profiles_parser.add_argument('--query')
|
||||
remote_disconnect_parser = subparsers.add_parser('remote-disconnect', help='disconnect the active local remote target')
|
||||
remote_disconnect_parser.add_argument('--cwd', default='.')
|
||||
account_status_parser = subparsers.add_parser('account-status', help='show local account runtime status')
|
||||
account_status_parser.add_argument('--cwd', default='.')
|
||||
account_profiles_parser = subparsers.add_parser('account-profiles', help='list configured local account profiles')
|
||||
account_profiles_parser.add_argument('--cwd', default='.')
|
||||
account_profiles_parser.add_argument('--query')
|
||||
account_login_parser = subparsers.add_parser('account-login', help='activate a local account profile or ephemeral identity')
|
||||
account_login_parser.add_argument('target')
|
||||
account_login_parser.add_argument('--provider')
|
||||
account_login_parser.add_argument('--auth-mode')
|
||||
account_login_parser.add_argument('--cwd', default='.')
|
||||
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
|
||||
account_logout_parser.add_argument('--cwd', default='.')
|
||||
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
|
||||
search_status_parser.add_argument('--cwd', default='.')
|
||||
search_status_parser.add_argument('--provider')
|
||||
search_providers_parser = subparsers.add_parser('search-providers', help='list configured local search providers')
|
||||
search_providers_parser.add_argument('--cwd', default='.')
|
||||
search_providers_parser.add_argument('--query')
|
||||
search_activate_parser = subparsers.add_parser('search-activate', help='set the active local search provider')
|
||||
search_activate_parser.add_argument('provider')
|
||||
search_activate_parser.add_argument('--cwd', default='.')
|
||||
search_parser = subparsers.add_parser('search', help='run a real web search against the configured local search runtime')
|
||||
search_parser.add_argument('query')
|
||||
search_parser.add_argument('--cwd', default='.')
|
||||
search_parser.add_argument('--provider')
|
||||
search_parser.add_argument('--max-results', type=int, default=5)
|
||||
search_parser.add_argument('--domain', action='append', default=[])
|
||||
mcp_status_parser = subparsers.add_parser('mcp-status', help='show local MCP runtime status')
|
||||
mcp_status_parser.add_argument('--cwd', default='.')
|
||||
mcp_resources_parser = subparsers.add_parser('mcp-resources', help='list MCP resources discovered through local manifests and transport-backed servers')
|
||||
mcp_resources_parser.add_argument('--cwd', default='.')
|
||||
mcp_resources_parser.add_argument('--query')
|
||||
mcp_resource_parser = subparsers.add_parser('mcp-resource', help='read an MCP resource by URI')
|
||||
mcp_resource_parser.add_argument('uri')
|
||||
mcp_resource_parser.add_argument('--cwd', default='.')
|
||||
mcp_tools_parser = subparsers.add_parser('mcp-tools', help='list MCP tools exposed by configured MCP servers')
|
||||
mcp_tools_parser.add_argument('--cwd', default='.')
|
||||
mcp_tools_parser.add_argument('--query')
|
||||
mcp_tools_parser.add_argument('--server')
|
||||
mcp_call_tool_parser = subparsers.add_parser('mcp-call-tool', help='call an MCP tool exposed by a configured MCP server')
|
||||
mcp_call_tool_parser.add_argument('tool_name')
|
||||
mcp_call_tool_parser.add_argument('--arguments-json', default='{}')
|
||||
mcp_call_tool_parser.add_argument('--server')
|
||||
mcp_call_tool_parser.add_argument('--cwd', default='.')
|
||||
config_status_parser = subparsers.add_parser('config-status', help='show local workspace config runtime summary')
|
||||
config_status_parser.add_argument('--cwd', default='.')
|
||||
config_effective_parser = subparsers.add_parser('config-effective', help='render the merged effective local workspace config')
|
||||
config_effective_parser.add_argument('--cwd', default='.')
|
||||
config_source_parser = subparsers.add_parser('config-source', help='render a specific local config source')
|
||||
config_source_parser.add_argument('source')
|
||||
config_source_parser.add_argument('--cwd', default='.')
|
||||
config_get_parser = subparsers.add_parser('config-get', help='read a local config value by dotted key path')
|
||||
config_get_parser.add_argument('key_path')
|
||||
config_get_parser.add_argument('--source')
|
||||
config_get_parser.add_argument('--cwd', default='.')
|
||||
config_set_parser = subparsers.add_parser('config-set', help='write a local config value by dotted key path')
|
||||
config_set_parser.add_argument('key_path')
|
||||
config_set_parser.add_argument('value_json')
|
||||
config_set_parser.add_argument('--source', default='local')
|
||||
config_set_parser.add_argument('--cwd', default='.')
|
||||
|
||||
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
|
||||
show_command.add_argument('name')
|
||||
@@ -575,6 +709,38 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
|
||||
kill_parser.add_argument('background_id')
|
||||
|
||||
daemon_parser = subparsers.add_parser('daemon', help='manage local daemon-style background agent sessions')
|
||||
daemon_subparsers = daemon_parser.add_subparsers(dest='daemon_command')
|
||||
daemon_subparsers.required = True
|
||||
|
||||
daemon_start_parser = daemon_subparsers.add_parser('start', help='launch a local daemon-style background agent session')
|
||||
daemon_start_parser.add_argument('prompt')
|
||||
daemon_start_parser.add_argument('--max-turns', type=int, default=12)
|
||||
daemon_start_parser.add_argument('--show-transcript', action='store_true')
|
||||
_add_agent_common_args(daemon_start_parser, include_backend=True)
|
||||
|
||||
daemon_worker_parser = daemon_subparsers.add_parser('worker', help=argparse.SUPPRESS)
|
||||
daemon_worker_parser.add_argument('background_id')
|
||||
daemon_worker_parser.add_argument('prompt')
|
||||
daemon_worker_parser.add_argument('--background-root', required=True)
|
||||
daemon_worker_parser.add_argument('--max-turns', type=int, default=12)
|
||||
daemon_worker_parser.add_argument('--show-transcript', action='store_true')
|
||||
_add_agent_common_args(daemon_worker_parser, include_backend=True)
|
||||
|
||||
daemon_ps_parser = daemon_subparsers.add_parser('ps', help='list local daemon-style background sessions')
|
||||
daemon_ps_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
daemon_logs_parser = daemon_subparsers.add_parser('logs', help='show logs for a local daemon-style background session')
|
||||
daemon_logs_parser.add_argument('background_id')
|
||||
daemon_logs_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
daemon_attach_parser = daemon_subparsers.add_parser('attach', help='show the current output snapshot for a local daemon-style background session')
|
||||
daemon_attach_parser.add_argument('background_id')
|
||||
daemon_attach_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
daemon_kill_parser = daemon_subparsers.add_parser('kill', help='stop a local daemon-style background session')
|
||||
daemon_kill_parser.add_argument('background_id')
|
||||
|
||||
chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
|
||||
chat_parser.add_argument('prompt', nargs='?')
|
||||
chat_parser.add_argument('--resume-session-id')
|
||||
@@ -687,19 +853,155 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}')
|
||||
return 0
|
||||
if args.command == 'remote-mode':
|
||||
print(run_remote_mode(args.target).as_text())
|
||||
print(run_remote_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
|
||||
return 0
|
||||
if args.command == 'ssh-mode':
|
||||
print(run_ssh_mode(args.target).as_text())
|
||||
print(run_ssh_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
|
||||
return 0
|
||||
if args.command == 'teleport-mode':
|
||||
print(run_teleport_mode(args.target).as_text())
|
||||
print(run_teleport_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
|
||||
return 0
|
||||
if args.command == 'direct-connect-mode':
|
||||
print(run_direct_connect(args.target).as_text())
|
||||
print(run_direct_connect_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
|
||||
return 0
|
||||
if args.command == 'deep-link-mode':
|
||||
print(run_deep_link(args.target).as_text())
|
||||
print(run_deep_link_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
|
||||
return 0
|
||||
if args.command == 'remote-status':
|
||||
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print('# Remote')
|
||||
print()
|
||||
print(runtime.render_summary())
|
||||
return 0
|
||||
if args.command == 'remote-profiles':
|
||||
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_profiles_index(query=args.query))
|
||||
return 0
|
||||
if args.command == 'remote-disconnect':
|
||||
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.disconnect().as_text())
|
||||
return 0
|
||||
if args.command == 'account-status':
|
||||
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print('# Account')
|
||||
print()
|
||||
print(runtime.render_summary())
|
||||
return 0
|
||||
if args.command == 'account-profiles':
|
||||
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_profiles_index(query=args.query))
|
||||
return 0
|
||||
if args.command == 'account-login':
|
||||
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(
|
||||
runtime.login(
|
||||
args.target,
|
||||
provider=args.provider,
|
||||
auth_mode=args.auth_mode,
|
||||
).as_text()
|
||||
)
|
||||
return 0
|
||||
if args.command == 'account-logout':
|
||||
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.logout().as_text())
|
||||
return 0
|
||||
if args.command == 'search-status':
|
||||
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
if args.provider:
|
||||
print(runtime.render_provider(args.provider))
|
||||
else:
|
||||
print('# Search')
|
||||
print()
|
||||
print(runtime.render_summary())
|
||||
return 0
|
||||
if args.command == 'search-providers':
|
||||
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_providers_index(query=args.query))
|
||||
return 0
|
||||
if args.command == 'search-activate':
|
||||
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
try:
|
||||
report = runtime.activate_provider(args.provider)
|
||||
except KeyError:
|
||||
print(f'Unknown search provider: {args.provider}')
|
||||
return 1
|
||||
print(report.as_text())
|
||||
return 0
|
||||
if args.command == 'search':
|
||||
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
try:
|
||||
output = runtime.render_search_results(
|
||||
args.query,
|
||||
provider_name=args.provider,
|
||||
max_results=args.max_results,
|
||||
domains=tuple(args.domain),
|
||||
)
|
||||
except (KeyError, LookupError, OSError, ValueError) as exc:
|
||||
print(f'Search failed: {exc}')
|
||||
return 1
|
||||
print(output)
|
||||
return 0
|
||||
if args.command == 'mcp-status':
|
||||
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print('# MCP')
|
||||
print()
|
||||
print(runtime.render_summary())
|
||||
return 0
|
||||
if args.command == 'mcp-resources':
|
||||
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_resource_index(query=args.query))
|
||||
return 0
|
||||
if args.command == 'mcp-resource':
|
||||
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_resource(args.uri))
|
||||
return 0
|
||||
if args.command == 'mcp-tools':
|
||||
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_tool_index(query=args.query, server_name=args.server))
|
||||
return 0
|
||||
if args.command == 'mcp-call-tool':
|
||||
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
arguments = json.loads(args.arguments_json)
|
||||
if not isinstance(arguments, dict):
|
||||
print('arguments-json must decode to a JSON object')
|
||||
return 1
|
||||
print(
|
||||
runtime.render_tool_call(
|
||||
args.tool_name,
|
||||
arguments=arguments,
|
||||
server_name=args.server,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.command == 'config-status':
|
||||
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print('# Config')
|
||||
print()
|
||||
print(runtime.render_summary())
|
||||
return 0
|
||||
if args.command == 'config-effective':
|
||||
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_effective_config())
|
||||
return 0
|
||||
if args.command == 'config-source':
|
||||
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_source(args.source))
|
||||
return 0
|
||||
if args.command == 'config-get':
|
||||
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
print(runtime.render_value(args.key_path, source=args.source))
|
||||
return 0
|
||||
if args.command == 'config-set':
|
||||
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
|
||||
value = json.loads(args.value_json)
|
||||
mutation = runtime.set_value(args.key_path, value, source=args.source)
|
||||
print('# Config')
|
||||
print()
|
||||
print(f'source={mutation.source_name}')
|
||||
print(f'key_path={mutation.key_path}')
|
||||
print(f'store_path={mutation.store_path}')
|
||||
print(f'effective_key_count={mutation.effective_key_count}')
|
||||
print(runtime.render_value(args.key_path))
|
||||
return 0
|
||||
if args.command == 'show-command':
|
||||
module = get_command(args.name)
|
||||
@@ -729,53 +1031,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||
return 0
|
||||
if args.command == 'agent-bg':
|
||||
background_runtime = BackgroundSessionRuntime()
|
||||
background_id = background_runtime.create_id()
|
||||
forwarded_args: list[str] = []
|
||||
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
|
||||
forwarded_args.extend(['--background-root', str(background_runtime.root)])
|
||||
command = build_background_worker_command(
|
||||
background_id=background_id,
|
||||
prompt=args.prompt,
|
||||
forwarded_args=forwarded_args,
|
||||
)
|
||||
record = background_runtime.launch(
|
||||
command,
|
||||
prompt=args.prompt,
|
||||
workspace_cwd=Path(args.cwd).resolve(),
|
||||
model=args.model,
|
||||
background_id=background_id,
|
||||
process_cwd=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
print('# Background Session')
|
||||
print(f'background_id={record.background_id}')
|
||||
print(f'pid={record.pid}')
|
||||
print(f'log_path={record.log_path}')
|
||||
print(f'record_path={record.record_path}')
|
||||
return 0
|
||||
return _launch_background_agent(args)
|
||||
if args.command == 'agent-bg-worker':
|
||||
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
|
||||
exit_code = 1
|
||||
stop_reason = 'worker_failed'
|
||||
session_id = None
|
||||
session_path = None
|
||||
try:
|
||||
agent = _build_agent(args)
|
||||
result = agent.run(args.prompt)
|
||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||
exit_code = 0
|
||||
stop_reason = result.stop_reason or 'completed'
|
||||
session_id = result.session_id
|
||||
session_path = result.session_path
|
||||
return 0
|
||||
finally:
|
||||
background_runtime.mark_finished(
|
||||
args.background_id,
|
||||
exit_code=exit_code,
|
||||
stop_reason=stop_reason,
|
||||
session_id=session_id,
|
||||
session_path=session_path,
|
||||
)
|
||||
return _run_background_worker(args)
|
||||
if args.command == 'agent-ps':
|
||||
print(BackgroundSessionRuntime().render_ps())
|
||||
return 0
|
||||
@@ -804,6 +1062,39 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if record.exit_code is not None:
|
||||
print(f'exit_code={record.exit_code}')
|
||||
return 0
|
||||
if args.command == 'daemon':
|
||||
if args.daemon_command == 'start':
|
||||
return _launch_background_agent(args)
|
||||
if args.daemon_command == 'worker':
|
||||
return _run_background_worker(args)
|
||||
if args.daemon_command == 'ps':
|
||||
print(BackgroundSessionRuntime().render_ps())
|
||||
return 0
|
||||
if args.daemon_command == 'logs':
|
||||
print(
|
||||
BackgroundSessionRuntime().render_logs(
|
||||
args.background_id,
|
||||
tail=args.tail,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.daemon_command == 'attach':
|
||||
print(
|
||||
BackgroundSessionRuntime().render_attach(
|
||||
args.background_id,
|
||||
tail=args.tail,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.daemon_command == 'kill':
|
||||
record = BackgroundSessionRuntime().kill(args.background_id)
|
||||
print('# Background Session')
|
||||
print(f'background_id={record.background_id}')
|
||||
print(f'status={record.status}')
|
||||
print(f'pid={record.pid}')
|
||||
if record.exit_code is not None:
|
||||
print(f'exit_code={record.exit_code}')
|
||||
return 0
|
||||
if args.command == 'agent-chat':
|
||||
agent = _build_agent(args)
|
||||
return _run_agent_chat_loop(
|
||||
|
||||
+655
-50
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
MCP_PROTOCOL_VERSION = '2025-11-25'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPResource:
|
||||
uri: str
|
||||
@@ -19,9 +26,33 @@ class MCPResource:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPTool:
|
||||
name: str
|
||||
server_name: str
|
||||
source_manifest: str
|
||||
description: str | None = None
|
||||
input_schema: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPServerProfile:
|
||||
name: str
|
||||
source_manifest: str
|
||||
transport: str
|
||||
command: str | None = None
|
||||
args: tuple[str, ...] = ()
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
cwd: str | None = None
|
||||
description: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPRuntime:
|
||||
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
|
||||
servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
@@ -30,77 +61,165 @@ class MCPRuntime:
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'MCPRuntime':
|
||||
resources: list[MCPResource] = []
|
||||
servers: list[MCPServerProfile] = []
|
||||
for path in _discover_manifest_paths(cwd, additional_working_directories):
|
||||
resources.extend(_load_resources_from_manifest(path))
|
||||
return cls(resources=tuple(resources))
|
||||
manifest_resources, manifest_servers = _load_manifest(path)
|
||||
resources.extend(manifest_resources)
|
||||
servers.extend(manifest_servers)
|
||||
return cls(
|
||||
resources=tuple(resources),
|
||||
servers=tuple(_dedupe_servers(servers)),
|
||||
)
|
||||
|
||||
@property
|
||||
def manifests(self) -> tuple[str, ...]:
|
||||
seen: list[str] = []
|
||||
for resource in self.resources:
|
||||
if resource.source_manifest not in seen:
|
||||
seen.append(resource.source_manifest)
|
||||
for entry in [*self.resources, *self.servers]:
|
||||
source_manifest = entry.source_manifest
|
||||
if source_manifest not in seen:
|
||||
seen.append(source_manifest)
|
||||
return tuple(seen)
|
||||
|
||||
def has_transport_servers(self) -> bool:
|
||||
return any(server.transport == 'stdio' for server in self.servers)
|
||||
|
||||
def list_resources(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[MCPResource, ...]:
|
||||
resources = self.resources
|
||||
if query:
|
||||
needle = query.lower()
|
||||
resources = tuple(
|
||||
resource
|
||||
for resource in resources
|
||||
if needle in resource.uri.lower()
|
||||
or needle in resource.server_name.lower()
|
||||
or needle in (resource.name or '').lower()
|
||||
or needle in (resource.description or '').lower()
|
||||
)
|
||||
resources = list(self.resources)
|
||||
resources.extend(self._list_remote_resources())
|
||||
filtered = _filter_resources(tuple(resources), query=query)
|
||||
if limit is not None and limit >= 0:
|
||||
resources = resources[:limit]
|
||||
return resources
|
||||
filtered = filtered[:limit]
|
||||
return filtered
|
||||
|
||||
def get_resource(self, uri: str) -> MCPResource | None:
|
||||
for resource in self.resources:
|
||||
if resource.uri == uri:
|
||||
return resource
|
||||
for resource in self._list_remote_resources():
|
||||
if resource.uri == uri:
|
||||
return resource
|
||||
return None
|
||||
|
||||
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
||||
resource = self.get_resource(uri)
|
||||
if resource is None:
|
||||
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
|
||||
if resource.inline_text is not None:
|
||||
return _truncate(resource.inline_text, max_chars)
|
||||
if resource.resolved_path is None:
|
||||
raise FileNotFoundError(f'MCP resource has no readable content: {uri}')
|
||||
path = Path(resource.resolved_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
||||
text = path.read_text(encoding='utf-8', errors='replace')
|
||||
return _truncate(text, max_chars)
|
||||
for resource in self.resources:
|
||||
if resource.uri != uri:
|
||||
continue
|
||||
if resource.inline_text is not None:
|
||||
return _truncate(resource.inline_text, max_chars)
|
||||
if resource.resolved_path is not None:
|
||||
path = Path(resource.resolved_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
||||
text = path.read_text(encoding='utf-8', errors='replace')
|
||||
return _truncate(text, max_chars)
|
||||
last_error: Exception | None = None
|
||||
candidate_servers: list[MCPServerProfile] = []
|
||||
discovered = self.get_resource(uri)
|
||||
if discovered is not None:
|
||||
server = self.get_server(discovered.server_name)
|
||||
if server is not None:
|
||||
candidate_servers.append(server)
|
||||
for server in self.servers:
|
||||
if server.transport != 'stdio':
|
||||
continue
|
||||
if all(existing.name != server.name for existing in candidate_servers):
|
||||
candidate_servers.append(server)
|
||||
for server in candidate_servers:
|
||||
try:
|
||||
result = _request_stdio(server, 'resources/read', {'uri': uri})
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
rendered = _render_resource_contents(result.get('contents'))
|
||||
if rendered:
|
||||
return _truncate(rendered, max_chars)
|
||||
if last_error is not None:
|
||||
raise FileNotFoundError(f'Unable to read MCP resource {uri}: {last_error}') from last_error
|
||||
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
|
||||
|
||||
def list_tools(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
server_name: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[MCPTool, ...]:
|
||||
tools = self._list_remote_tools(server_name=server_name)
|
||||
if query:
|
||||
needle = query.lower()
|
||||
tools = tuple(
|
||||
tool
|
||||
for tool in tools
|
||||
if needle in tool.name.lower()
|
||||
or needle in (tool.description or '').lower()
|
||||
or needle in tool.server_name.lower()
|
||||
)
|
||||
if limit is not None and limit >= 0:
|
||||
tools = tools[:limit]
|
||||
return tools
|
||||
|
||||
def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
server_name: str | None = None,
|
||||
max_chars: int = 12000,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
tool = self._resolve_tool(tool_name, server_name=server_name)
|
||||
server = self.get_server(tool.server_name)
|
||||
if server is None:
|
||||
raise FileNotFoundError(f'Unknown MCP server: {tool.server_name}')
|
||||
payload = {
|
||||
'name': tool.name,
|
||||
'arguments': dict(arguments or {}),
|
||||
}
|
||||
result = _request_stdio(server, 'tools/call', payload)
|
||||
rendered = _truncate(_render_tool_call_result(result), max_chars)
|
||||
metadata = {
|
||||
'server_name': tool.server_name,
|
||||
'tool_name': tool.name,
|
||||
'is_error': bool(result.get('isError')),
|
||||
}
|
||||
return rendered, metadata
|
||||
|
||||
def get_server(self, name: str) -> MCPServerProfile | None:
|
||||
needle = name.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
for server in self.servers:
|
||||
if server.name.lower() == needle:
|
||||
return server
|
||||
return None
|
||||
|
||||
def render_summary(self) -> str:
|
||||
if not self.resources:
|
||||
return 'No local MCP manifests or resources discovered.'
|
||||
if not self.resources and not self.servers:
|
||||
return 'No local MCP manifests, servers, or resources discovered.'
|
||||
lines = [
|
||||
f'Local MCP manifests: {len(self.manifests)}',
|
||||
f'Local MCP resources: {len(self.resources)}',
|
||||
f'Configured MCP servers: {len(self.servers)}',
|
||||
]
|
||||
transport_counts: dict[str, int] = {}
|
||||
for server in self.servers:
|
||||
transport_counts[server.transport] = transport_counts.get(server.transport, 0) + 1
|
||||
for transport, count in sorted(transport_counts.items()):
|
||||
lines.append(f'- {transport}: {count} server(s)')
|
||||
by_server: dict[str, int] = {}
|
||||
for resource in self.resources:
|
||||
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
|
||||
for server_name, count in sorted(by_server.items()):
|
||||
lines.append(f'- {server_name}: {count} resource(s)')
|
||||
for manifest in self.manifests[:10]:
|
||||
manifest_name = Path(manifest).name
|
||||
manifest_count = sum(
|
||||
1 for resource in self.resources if resource.source_manifest == manifest
|
||||
)
|
||||
lines.append(f'- {manifest_name}: {manifest_count} resource(s)')
|
||||
lines.append(f'- local resources for {server_name}: {count}')
|
||||
for server in self.servers[:10]:
|
||||
details = [server.name, server.transport]
|
||||
if server.command:
|
||||
details.append(server.command)
|
||||
lines.append('- Server: ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_resource_index(
|
||||
@@ -122,6 +241,10 @@ class MCPRuntime:
|
||||
details.append(f'mime={resource.mime_type}')
|
||||
if resource.resolved_path:
|
||||
details.append(f'path={resource.resolved_path}')
|
||||
elif resource.inline_text is not None:
|
||||
details.append('source=inline')
|
||||
else:
|
||||
details.append('source=transport')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@@ -144,6 +267,93 @@ class MCPRuntime:
|
||||
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_tool_index(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
server_name: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
tools = self.list_tools(query=query, server_name=server_name, limit=limit)
|
||||
if not tools:
|
||||
return '# MCP Tools\n\nNo matching MCP tools discovered.'
|
||||
lines = ['# MCP Tools', '']
|
||||
for tool in tools:
|
||||
details = [tool.name, f'server={tool.server_name}']
|
||||
if tool.description:
|
||||
details.append(tool.description)
|
||||
lines.append('- ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
server_name: str | None = None,
|
||||
max_chars: int = 12000,
|
||||
) -> str:
|
||||
content, metadata = self.call_tool(
|
||||
tool_name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
lines = [
|
||||
'# MCP Tool Result',
|
||||
'',
|
||||
f'- Tool: {tool_name}',
|
||||
f'- Server: {metadata["server_name"]}',
|
||||
f'- is_error: {metadata["is_error"]}',
|
||||
'',
|
||||
content,
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _list_remote_resources(self) -> tuple[MCPResource, ...]:
|
||||
discovered: list[MCPResource] = []
|
||||
for server in self.servers:
|
||||
if server.transport != 'stdio':
|
||||
continue
|
||||
try:
|
||||
result = _request_stdio(server, 'resources/list', {})
|
||||
except OSError:
|
||||
continue
|
||||
for item in _extract_remote_resources(server, result):
|
||||
discovered.append(item)
|
||||
return tuple(discovered)
|
||||
|
||||
def _list_remote_tools(self, *, server_name: str | None = None) -> tuple[MCPTool, ...]:
|
||||
discovered: list[MCPTool] = []
|
||||
candidate_servers = (
|
||||
[self.get_server(server_name)] if server_name else list(self.servers)
|
||||
)
|
||||
for server in candidate_servers:
|
||||
if server is None or server.transport != 'stdio':
|
||||
continue
|
||||
try:
|
||||
result = _request_stdio(server, 'tools/list', {})
|
||||
except OSError:
|
||||
continue
|
||||
for item in _extract_remote_tools(server, result):
|
||||
discovered.append(item)
|
||||
return tuple(discovered)
|
||||
|
||||
def _resolve_tool(self, tool_name: str, server_name: str | None = None) -> MCPTool:
|
||||
tools = self.list_tools(server_name=server_name)
|
||||
matches = [tool for tool in tools if tool.name == tool_name]
|
||||
if server_name:
|
||||
if not matches:
|
||||
raise FileNotFoundError(f'Unknown MCP tool: {tool_name} on server {server_name}')
|
||||
return matches[0]
|
||||
if not matches:
|
||||
raise FileNotFoundError(f'Unknown MCP tool: {tool_name}')
|
||||
if len(matches) > 1:
|
||||
raise FileNotFoundError(
|
||||
f'MCP tool {tool_name} exists on multiple servers. Pass server_name to disambiguate.'
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _discover_manifest_paths(
|
||||
cwd: Path,
|
||||
@@ -176,15 +386,17 @@ def _discover_manifest_paths(
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
|
||||
def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return [], []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
return [], []
|
||||
|
||||
resources: list[MCPResource] = []
|
||||
servers: list[MCPServerProfile] = []
|
||||
|
||||
if isinstance(payload.get('resources'), list):
|
||||
resources.extend(
|
||||
_extract_resources(
|
||||
@@ -193,21 +405,86 @@ def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
|
||||
manifest_path=path,
|
||||
)
|
||||
)
|
||||
servers = payload.get('servers')
|
||||
if isinstance(servers, list):
|
||||
for item in servers:
|
||||
|
||||
raw_servers = payload.get('servers')
|
||||
if isinstance(raw_servers, list):
|
||||
for item in raw_servers:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get('name')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
server_name = name.strip()
|
||||
raw_resources = item.get('resources')
|
||||
if not isinstance(raw_resources, list):
|
||||
if isinstance(raw_resources, list):
|
||||
resources.extend(
|
||||
_extract_resources(server_name, raw_resources, manifest_path=path)
|
||||
)
|
||||
server = _extract_server_profile(server_name, item, manifest_path=path)
|
||||
if server is not None:
|
||||
servers.append(server)
|
||||
|
||||
raw_mcp_servers = payload.get('mcpServers')
|
||||
if isinstance(raw_mcp_servers, dict):
|
||||
for server_name, item in raw_mcp_servers.items():
|
||||
if not isinstance(server_name, str) or not server_name.strip():
|
||||
continue
|
||||
resources.extend(
|
||||
_extract_resources(name.strip(), raw_resources, manifest_path=path)
|
||||
)
|
||||
return resources
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server = _extract_server_profile(server_name.strip(), item, manifest_path=path)
|
||||
if server is not None:
|
||||
servers.append(server)
|
||||
|
||||
return resources, servers
|
||||
|
||||
|
||||
def _extract_server_profile(
|
||||
server_name: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
manifest_path: Path,
|
||||
) -> MCPServerProfile | None:
|
||||
command = payload.get('command')
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
return None
|
||||
args = payload.get('args', ())
|
||||
if not isinstance(args, list):
|
||||
args = ()
|
||||
normalized_args = tuple(
|
||||
item for item in args if isinstance(item, str)
|
||||
)
|
||||
env = payload.get('env')
|
||||
normalized_env = {
|
||||
key: value
|
||||
for key, value in (env.items() if isinstance(env, dict) else [])
|
||||
if isinstance(key, str) and isinstance(value, str)
|
||||
}
|
||||
cwd = payload.get('cwd')
|
||||
resolved_cwd: str | None = None
|
||||
if isinstance(cwd, str) and cwd.strip():
|
||||
candidate = Path(cwd).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = manifest_path.parent / candidate
|
||||
resolved_cwd = str(candidate.resolve())
|
||||
description = payload.get('description') if isinstance(payload.get('description'), str) else None
|
||||
transport = payload.get('transport')
|
||||
if not isinstance(transport, str) or not transport.strip():
|
||||
transport = 'stdio'
|
||||
transport = transport.strip().lower()
|
||||
if transport != 'stdio':
|
||||
return None
|
||||
metadata = payload.get('metadata')
|
||||
return MCPServerProfile(
|
||||
name=server_name,
|
||||
source_manifest=str(manifest_path),
|
||||
transport=transport,
|
||||
command=command.strip(),
|
||||
args=normalized_args,
|
||||
env=normalized_env,
|
||||
cwd=resolved_cwd,
|
||||
description=description,
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _extract_resources(
|
||||
@@ -267,6 +544,334 @@ def _extract_resources(
|
||||
return resources
|
||||
|
||||
|
||||
def _extract_remote_resources(
|
||||
server: MCPServerProfile,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[MCPResource, ...]:
|
||||
raw_resources = payload.get('resources')
|
||||
if not isinstance(raw_resources, list):
|
||||
return ()
|
||||
resources: list[MCPResource] = []
|
||||
for item in raw_resources:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uri = item.get('uri')
|
||||
if not isinstance(uri, str) or not uri.strip():
|
||||
continue
|
||||
resources.append(
|
||||
MCPResource(
|
||||
uri=uri.strip(),
|
||||
server_name=server.name,
|
||||
source_manifest=server.source_manifest,
|
||||
name=item.get('name') if isinstance(item.get('name'), str) else None,
|
||||
description=(
|
||||
item.get('description')
|
||||
if isinstance(item.get('description'), str)
|
||||
else None
|
||||
),
|
||||
mime_type=(
|
||||
item.get('mimeType')
|
||||
if isinstance(item.get('mimeType'), str)
|
||||
else item.get('mime_type')
|
||||
if isinstance(item.get('mime_type'), str)
|
||||
else None
|
||||
),
|
||||
metadata={
|
||||
'transport': server.transport,
|
||||
'server_command': server.command,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(resources)
|
||||
|
||||
|
||||
def _extract_remote_tools(
|
||||
server: MCPServerProfile,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[MCPTool, ...]:
|
||||
raw_tools = payload.get('tools')
|
||||
if not isinstance(raw_tools, list):
|
||||
return ()
|
||||
tools: list[MCPTool] = []
|
||||
for item in raw_tools:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get('name')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
input_schema = item.get('inputSchema')
|
||||
if not isinstance(input_schema, dict):
|
||||
input_schema = item.get('input_schema')
|
||||
tools.append(
|
||||
MCPTool(
|
||||
name=name.strip(),
|
||||
server_name=server.name,
|
||||
source_manifest=server.source_manifest,
|
||||
description=(
|
||||
item.get('description')
|
||||
if isinstance(item.get('description'), str)
|
||||
else None
|
||||
),
|
||||
input_schema=dict(input_schema) if isinstance(input_schema, dict) else {},
|
||||
metadata={
|
||||
'transport': server.transport,
|
||||
'server_command': server.command,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(tools)
|
||||
|
||||
|
||||
def _filter_resources(
|
||||
resources: tuple[MCPResource, ...],
|
||||
*,
|
||||
query: str | None = None,
|
||||
) -> tuple[MCPResource, ...]:
|
||||
if not query:
|
||||
return resources
|
||||
needle = query.lower()
|
||||
return tuple(
|
||||
resource
|
||||
for resource in resources
|
||||
if needle in resource.uri.lower()
|
||||
or needle in resource.server_name.lower()
|
||||
or needle in (resource.name or '').lower()
|
||||
or needle in (resource.description or '').lower()
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_servers(servers: list[MCPServerProfile]) -> list[MCPServerProfile]:
|
||||
seen: set[tuple[str, str, str | None, tuple[str, ...]]] = set()
|
||||
deduped: list[MCPServerProfile] = []
|
||||
for server in servers:
|
||||
key = (server.name.lower(), server.transport, server.command, server.args)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(server)
|
||||
return deduped
|
||||
|
||||
|
||||
def _request_stdio(
|
||||
server: MCPServerProfile,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
with _StdioMCPConnection(server, timeout_seconds=timeout_seconds) as connection:
|
||||
return connection.request(method, params)
|
||||
|
||||
|
||||
class _StdioMCPConnection:
|
||||
def __init__(self, server: MCPServerProfile, *, timeout_seconds: float = 10.0) -> None:
|
||||
self.server = server
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.process: subprocess.Popen[str] | None = None
|
||||
self.selector: selectors.BaseSelector | None = None
|
||||
self.stderr_lines: list[str] = []
|
||||
self._request_id = 0
|
||||
|
||||
def __enter__(self) -> '_StdioMCPConnection':
|
||||
try:
|
||||
command = [self.server.command or '', *self.server.args]
|
||||
if not command[0]:
|
||||
raise OSError(f'MCP server {self.server.name} has no executable command')
|
||||
env = os.environ.copy()
|
||||
env.update(self.server.env)
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
cwd=self.server.cwd or None,
|
||||
env=env,
|
||||
)
|
||||
self.selector = selectors.DefaultSelector()
|
||||
assert self.process.stdout is not None
|
||||
assert self.process.stderr is not None
|
||||
self.selector.register(self.process.stdout, selectors.EVENT_READ, data='stdout')
|
||||
self.selector.register(self.process.stderr, selectors.EVENT_READ, data='stderr')
|
||||
self._initialize()
|
||||
return self
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
process = self.process
|
||||
if self.selector is not None:
|
||||
try:
|
||||
self.selector.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.selector = None
|
||||
if process is None:
|
||||
return
|
||||
try:
|
||||
if process.stdin is not None:
|
||||
process.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=1.0)
|
||||
for stream_name in ('stdout', 'stderr'):
|
||||
stream = getattr(process, stream_name, None)
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
self._request_id += 1
|
||||
request_id = self._request_id
|
||||
self._send(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'id': request_id,
|
||||
'method': method,
|
||||
'params': params,
|
||||
}
|
||||
)
|
||||
response = self._await_response(request_id)
|
||||
error = response.get('error')
|
||||
if isinstance(error, dict):
|
||||
message = error.get('message')
|
||||
raise OSError(
|
||||
f'MCP {method} failed for server {self.server.name}: {message or error}'
|
||||
)
|
||||
result = response.get('result')
|
||||
if not isinstance(result, dict):
|
||||
return {}
|
||||
return result
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self._request_id += 1
|
||||
request_id = self._request_id
|
||||
self._send(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'id': request_id,
|
||||
'method': 'initialize',
|
||||
'params': {
|
||||
'protocolVersion': MCP_PROTOCOL_VERSION,
|
||||
'capabilities': {},
|
||||
'clientInfo': {
|
||||
'name': 'claw-code-agent',
|
||||
'version': '0.1.0',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
response = self._await_response(request_id)
|
||||
error = response.get('error')
|
||||
if isinstance(error, dict):
|
||||
raise OSError(
|
||||
f'MCP initialize failed for server {self.server.name}: {error.get("message") or error}'
|
||||
)
|
||||
self._send(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'notifications/initialized',
|
||||
'params': {},
|
||||
}
|
||||
)
|
||||
|
||||
def _send(self, payload: dict[str, Any]) -> None:
|
||||
if self.process is None or self.process.stdin is None:
|
||||
raise OSError(f'MCP server {self.server.name} is not running')
|
||||
self.process.stdin.write(json.dumps(payload, ensure_ascii=True) + '\n')
|
||||
self.process.stdin.flush()
|
||||
|
||||
def _await_response(self, request_id: int) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + self.timeout_seconds
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
stderr = '\n'.join(self.stderr_lines[-5:])
|
||||
raise TimeoutError(
|
||||
f'Timed out waiting for MCP response from {self.server.name}'
|
||||
+ (f' stderr={stderr}' if stderr else '')
|
||||
)
|
||||
if self.selector is None:
|
||||
raise OSError(f'MCP selector is not available for {self.server.name}')
|
||||
events = self.selector.select(timeout=remaining)
|
||||
if not events:
|
||||
continue
|
||||
for key, _mask in events:
|
||||
stream_name = key.data
|
||||
line = key.fileobj.readline()
|
||||
if not line:
|
||||
continue
|
||||
if stream_name == 'stderr':
|
||||
self.stderr_lines.append(line.rstrip())
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get('id') == request_id:
|
||||
return payload
|
||||
|
||||
|
||||
def _render_resource_contents(contents: Any) -> str:
|
||||
if not isinstance(contents, list):
|
||||
return ''
|
||||
parts: list[str] = []
|
||||
for item in contents:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get('text')
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
continue
|
||||
blob = item.get('blob')
|
||||
if isinstance(blob, str):
|
||||
mime_type = item.get('mimeType') if isinstance(item.get('mimeType'), str) else 'application/octet-stream'
|
||||
parts.append(f'[blob:{mime_type}] {blob}')
|
||||
continue
|
||||
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
|
||||
return '\n\n'.join(parts).strip()
|
||||
|
||||
|
||||
def _render_tool_call_result(result: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
content = result.get('content')
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get('text')
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
continue
|
||||
structured = item.get('structuredContent')
|
||||
if structured is not None:
|
||||
parts.append(json.dumps(structured, ensure_ascii=True, indent=2))
|
||||
continue
|
||||
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
|
||||
structured_content = result.get('structuredContent')
|
||||
if structured_content is not None:
|
||||
parts.append(json.dumps(structured_content, ensure_ascii=True, indent=2))
|
||||
if not parts:
|
||||
parts.append(json.dumps(result, ensure_ascii=True, indent=2))
|
||||
return '\n\n'.join(part for part in parts if part).strip()
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
|
||||
+80
-3
@@ -11,7 +11,13 @@ from .task_runtime import TaskRuntime
|
||||
|
||||
|
||||
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
|
||||
VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed')
|
||||
VALID_PLAN_STATUSES = (
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'blocked',
|
||||
'cancelled',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -21,6 +27,9 @@ class PlanStep:
|
||||
task_id: str | None = None
|
||||
description: str | None = None
|
||||
priority: str | None = None
|
||||
active_form: str | None = None
|
||||
owner: str | None = None
|
||||
depends_on: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -29,6 +38,9 @@ class PlanStep:
|
||||
'task_id': self.task_id,
|
||||
'description': self.description,
|
||||
'priority': self.priority,
|
||||
'active_form': self.active_form,
|
||||
'owner': self.owner,
|
||||
'depends_on': list(self.depends_on),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -53,6 +65,19 @@ class PlanStep:
|
||||
and payload.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
active_form=(
|
||||
str(payload.get('active_form'))
|
||||
if isinstance(payload.get('active_form'), str)
|
||||
and payload.get('active_form').strip()
|
||||
else None
|
||||
),
|
||||
owner=(
|
||||
str(payload.get('owner'))
|
||||
if isinstance(payload.get('owner'), str)
|
||||
and payload.get('owner').strip()
|
||||
else None
|
||||
),
|
||||
depends_on=_normalize_id_list(payload.get('depends_on')),
|
||||
)
|
||||
|
||||
|
||||
@@ -147,6 +172,19 @@ class PlanRuntime:
|
||||
and item.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
active_form=(
|
||||
item.get('active_form').strip()
|
||||
if isinstance(item.get('active_form'), str)
|
||||
and item.get('active_form').strip()
|
||||
else None
|
||||
),
|
||||
owner=(
|
||||
item.get('owner').strip()
|
||||
if isinstance(item.get('owner'), str)
|
||||
and item.get('owner').strip()
|
||||
else None
|
||||
),
|
||||
depends_on=_normalize_id_list(item.get('depends_on')),
|
||||
)
|
||||
)
|
||||
mutation = self._persist(
|
||||
@@ -158,6 +196,10 @@ class PlanRuntime:
|
||||
),
|
||||
)
|
||||
if sync_tasks and task_runtime is not None:
|
||||
dependents: dict[str, list[str]] = {}
|
||||
for step in self.steps:
|
||||
for dependency in step.depends_on:
|
||||
dependents.setdefault(dependency, []).append(step.task_id or '')
|
||||
task_items = [
|
||||
{
|
||||
'task_id': step.task_id or f'plan_{index}',
|
||||
@@ -165,6 +207,14 @@ class PlanRuntime:
|
||||
'description': step.description,
|
||||
'status': _plan_status_to_task_status(step.status),
|
||||
'priority': step.priority,
|
||||
'active_form': step.active_form,
|
||||
'owner': step.owner,
|
||||
'blocked_by': list(step.depends_on),
|
||||
'blocks': sorted(
|
||||
dependency
|
||||
for dependency in dependents.get(step.task_id or f'plan_{index}', [])
|
||||
if dependency
|
||||
),
|
||||
}
|
||||
for index, step in enumerate(self.steps, start=1)
|
||||
]
|
||||
@@ -238,6 +288,12 @@ class PlanRuntime:
|
||||
lines.append('- ' + '; '.join(details))
|
||||
if step.description:
|
||||
lines.append(f' description: {step.description}')
|
||||
if step.active_form:
|
||||
lines.append(f' active_form: {step.active_form}')
|
||||
if step.owner:
|
||||
lines.append(f' owner: {step.owner}')
|
||||
if step.depends_on:
|
||||
lines.append(f" depends_on: {', '.join(step.depends_on)}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _persist(
|
||||
@@ -297,6 +353,7 @@ def _normalize_plan_status(value: Any) -> str:
|
||||
'complete': 'completed',
|
||||
'in-progress': 'in_progress',
|
||||
'in progress': 'in_progress',
|
||||
'blocked_on': 'blocked',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_PLAN_STATUSES:
|
||||
@@ -306,10 +363,30 @@ def _normalize_plan_status(value: Any) -> str:
|
||||
|
||||
def _plan_status_to_task_status(status: str) -> str:
|
||||
if status == 'completed':
|
||||
return 'done'
|
||||
return 'completed'
|
||||
if status == 'in_progress':
|
||||
return 'in_progress'
|
||||
return 'todo'
|
||||
if status == 'blocked':
|
||||
return 'blocked'
|
||||
if status == 'cancelled':
|
||||
return 'cancelled'
|
||||
return 'pending'
|
||||
|
||||
|
||||
def _normalize_id_list(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
normalized.append(text)
|
||||
seen.add(text)
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def _snapshot_text(text: str, limit: int = 240) -> str:
|
||||
|
||||
+571
-25
@@ -1,25 +1,571 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModeReport:
|
||||
mode: str
|
||||
connected: bool
|
||||
detail: str
|
||||
|
||||
def as_text(self) -> str:
|
||||
return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}'
|
||||
|
||||
|
||||
def run_remote_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}')
|
||||
|
||||
|
||||
def run_ssh_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}')
|
||||
|
||||
|
||||
def run_teleport_mode(target: str) -> RuntimeModeReport:
|
||||
return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}')
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_REMOTE_STATE_DIR = Path('.port_sessions')
|
||||
DEFAULT_REMOTE_STATE_FILE = DEFAULT_REMOTE_STATE_DIR / 'remote_runtime.json'
|
||||
SUPPORTED_REMOTE_MODES = (
|
||||
'remote',
|
||||
'ssh',
|
||||
'teleport',
|
||||
'direct-connect',
|
||||
'deep-link',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteProfile:
|
||||
name: str
|
||||
mode: str
|
||||
target: str
|
||||
source_manifest: str
|
||||
description: str | None = None
|
||||
workspace_cwd: str | None = None
|
||||
session_url: str | None = None
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteConnectionState:
|
||||
mode: str
|
||||
target: str
|
||||
connected: bool
|
||||
connected_at: str
|
||||
profile_name: str | None = None
|
||||
workspace_cwd: str | None = None
|
||||
session_url: str | None = None
|
||||
source_manifest: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModeReport:
|
||||
mode: str
|
||||
connected: bool
|
||||
detail: str
|
||||
target: str | None = None
|
||||
profile_name: str | None = None
|
||||
workspace_cwd: str | None = None
|
||||
session_url: str | None = None
|
||||
source_manifest: str | None = None
|
||||
manifest_count: int = 0
|
||||
profile_count: int = 0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f'mode={self.mode}',
|
||||
f'connected={self.connected}',
|
||||
f'detail={self.detail}',
|
||||
]
|
||||
if self.target:
|
||||
lines.append(f'target={self.target}')
|
||||
if self.profile_name:
|
||||
lines.append(f'profile={self.profile_name}')
|
||||
if self.workspace_cwd:
|
||||
lines.append(f'workspace_cwd={self.workspace_cwd}')
|
||||
if self.session_url:
|
||||
lines.append(f'session_url={self.session_url}')
|
||||
if self.source_manifest:
|
||||
lines.append(f'source_manifest={self.source_manifest}')
|
||||
lines.append(f'manifest_count={self.manifest_count}')
|
||||
lines.append(f'profile_count={self.profile_count}')
|
||||
if self.metadata:
|
||||
for key, value in sorted(self.metadata.items()):
|
||||
lines.append(f'metadata.{key}={value}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteRuntime:
|
||||
cwd: Path
|
||||
profiles: tuple[RemoteProfile, ...] = field(default_factory=tuple)
|
||||
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||
state_path: Path = field(default_factory=lambda: DEFAULT_REMOTE_STATE_FILE.resolve())
|
||||
active_connection: RemoteConnectionState | None = None
|
||||
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'RemoteRuntime':
|
||||
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||
profiles: list[RemoteProfile] = []
|
||||
for manifest_path in manifest_paths:
|
||||
profiles.extend(_load_profiles_from_manifest(manifest_path))
|
||||
state_path = cwd.resolve() / DEFAULT_REMOTE_STATE_FILE
|
||||
payload = _load_state_payload(state_path)
|
||||
active_connection = _connection_from_payload(payload.get('active_connection'))
|
||||
history_payload = payload.get('history')
|
||||
history = tuple(
|
||||
item for item in history_payload if isinstance(item, dict)
|
||||
) if isinstance(history_payload, list) else ()
|
||||
return cls(
|
||||
cwd=cwd.resolve(),
|
||||
profiles=tuple(profiles),
|
||||
manifests=tuple(str(path) for path in manifest_paths),
|
||||
state_path=state_path,
|
||||
active_connection=active_connection,
|
||||
history=history,
|
||||
)
|
||||
|
||||
def has_remote_config(self) -> bool:
|
||||
return bool(self.profiles or self.active_connection is not None)
|
||||
|
||||
def list_profiles(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
mode: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[RemoteProfile, ...]:
|
||||
profiles = self.profiles
|
||||
if query:
|
||||
needle = query.lower()
|
||||
profiles = tuple(
|
||||
profile
|
||||
for profile in profiles
|
||||
if needle in profile.name.lower()
|
||||
or needle in profile.mode.lower()
|
||||
or needle in profile.target.lower()
|
||||
or needle in (profile.description or '').lower()
|
||||
)
|
||||
if mode:
|
||||
profiles = tuple(profile for profile in profiles if profile.mode == _normalize_mode(mode))
|
||||
if limit is not None and limit >= 0:
|
||||
profiles = profiles[:limit]
|
||||
return profiles
|
||||
|
||||
def get_profile(self, name_or_target: str) -> RemoteProfile | None:
|
||||
needle = name_or_target.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
for profile in self.profiles:
|
||||
if profile.name.lower() == needle or profile.target.lower() == needle:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def connect(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
) -> RuntimeModeReport:
|
||||
normalized_mode = _normalize_mode(mode or 'remote')
|
||||
profile = self.get_profile(target)
|
||||
if profile is not None:
|
||||
normalized_mode = _normalize_mode(profile.mode or normalized_mode)
|
||||
connection = RemoteConnectionState(
|
||||
mode=normalized_mode,
|
||||
target=profile.target,
|
||||
connected=True,
|
||||
connected_at=_utc_now(),
|
||||
profile_name=profile.name,
|
||||
workspace_cwd=profile.workspace_cwd,
|
||||
session_url=profile.session_url,
|
||||
source_manifest=profile.source_manifest,
|
||||
metadata=dict(profile.metadata),
|
||||
)
|
||||
detail = f'Activated remote profile {profile.name}'
|
||||
else:
|
||||
connection = RemoteConnectionState(
|
||||
mode=normalized_mode,
|
||||
target=target.strip(),
|
||||
connected=True,
|
||||
connected_at=_utc_now(),
|
||||
metadata={'ephemeral': True},
|
||||
)
|
||||
detail = f'Activated {normalized_mode} target {target.strip()}'
|
||||
self.active_connection = connection
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'connect',
|
||||
'mode': connection.mode,
|
||||
'target': connection.target,
|
||||
'profile_name': connection.profile_name,
|
||||
'connected_at': connection.connected_at,
|
||||
}
|
||||
)
|
||||
self._persist_state()
|
||||
return self.current_report(detail=detail)
|
||||
|
||||
def disconnect(self, *, reason: str = 'manual_disconnect') -> RuntimeModeReport:
|
||||
previous = self.active_connection
|
||||
detail = (
|
||||
f'Disconnected {previous.mode} target {previous.target}'
|
||||
if previous is not None
|
||||
else 'No active remote connection was present.'
|
||||
)
|
||||
if previous is not None:
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'disconnect',
|
||||
'mode': previous.mode,
|
||||
'target': previous.target,
|
||||
'profile_name': previous.profile_name,
|
||||
'reason': reason,
|
||||
'disconnected_at': _utc_now(),
|
||||
}
|
||||
)
|
||||
self.active_connection = None
|
||||
self._persist_state()
|
||||
return RuntimeModeReport(
|
||||
mode=previous.mode if previous is not None else 'remote',
|
||||
connected=False,
|
||||
detail=detail,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
)
|
||||
|
||||
def current_report(self, *, detail: str | None = None) -> RuntimeModeReport:
|
||||
if self.active_connection is None:
|
||||
return RuntimeModeReport(
|
||||
mode='remote',
|
||||
connected=False,
|
||||
detail=detail or 'No active remote connection.',
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
)
|
||||
connection = self.active_connection
|
||||
return RuntimeModeReport(
|
||||
mode=connection.mode,
|
||||
connected=connection.connected,
|
||||
detail=detail or f'Active {connection.mode} connection for {connection.target}',
|
||||
target=connection.target,
|
||||
profile_name=connection.profile_name,
|
||||
workspace_cwd=connection.workspace_cwd,
|
||||
session_url=connection.session_url,
|
||||
source_manifest=connection.source_manifest,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
metadata=dict(connection.metadata),
|
||||
)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local remote manifests: {len(self.manifests)}',
|
||||
f'Configured remote profiles: {len(self.profiles)}',
|
||||
]
|
||||
if self.active_connection is None:
|
||||
lines.append('- Active remote connection: none')
|
||||
else:
|
||||
connection = self.active_connection
|
||||
active = f'- Active remote connection: {connection.mode} -> {connection.target}'
|
||||
if connection.profile_name:
|
||||
active += f' (profile={connection.profile_name})'
|
||||
lines.append(active)
|
||||
if connection.workspace_cwd:
|
||||
lines.append(f'- Active remote workspace: {connection.workspace_cwd}')
|
||||
if connection.session_url:
|
||||
lines.append(f'- Active remote session URL: {connection.session_url}')
|
||||
for profile in self.profiles[:10]:
|
||||
parts = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
|
||||
if profile.workspace_cwd:
|
||||
parts.append(f'workspace={profile.workspace_cwd}')
|
||||
if profile.session_url:
|
||||
parts.append(f'session_url={profile.session_url}')
|
||||
lines.append('- ' + '; '.join(parts))
|
||||
if self.history:
|
||||
lines.append(f'- Runtime history entries: {len(self.history)}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profiles_index(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
mode: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
profiles = self.list_profiles(query=query, mode=mode, limit=limit)
|
||||
if not profiles:
|
||||
return '# Remote Profiles\n\nNo matching remote profiles discovered.'
|
||||
lines = ['# Remote Profiles', '']
|
||||
for profile in profiles:
|
||||
details = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
|
||||
if profile.workspace_cwd:
|
||||
details.append(f'workspace={profile.workspace_cwd}')
|
||||
if profile.session_url:
|
||||
details.append(f'session_url={profile.session_url}')
|
||||
if profile.description:
|
||||
details.append(f'description={profile.description}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profile(self, name_or_target: str) -> str:
|
||||
profile = self.get_profile(name_or_target)
|
||||
if profile is None:
|
||||
return f'# Remote Profile\n\nUnknown remote profile: {name_or_target}'
|
||||
lines = [
|
||||
'# Remote Profile',
|
||||
'',
|
||||
f'- Name: {profile.name}',
|
||||
f'- Mode: {profile.mode}',
|
||||
f'- Target: {profile.target}',
|
||||
f'- Source manifest: {profile.source_manifest}',
|
||||
]
|
||||
if profile.description:
|
||||
lines.append(f'- Description: {profile.description}')
|
||||
if profile.workspace_cwd:
|
||||
lines.append(f'- Workspace: {profile.workspace_cwd}')
|
||||
if profile.session_url:
|
||||
lines.append(f'- Session URL: {profile.session_url}')
|
||||
if profile.env:
|
||||
lines.append('- Environment values:')
|
||||
lines.extend(f' - {key}={value}' for key, value in sorted(profile.env.items()))
|
||||
if profile.metadata:
|
||||
lines.append('- Metadata:')
|
||||
lines.extend(f' - {key}={value}' for key, value in sorted(profile.metadata.items()))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
payload = {
|
||||
'active_connection': (
|
||||
asdict(self.active_connection) if self.active_connection is not None else None
|
||||
),
|
||||
'history': list(self.history),
|
||||
}
|
||||
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 _append_history(self, entry: dict[str, Any]) -> None:
|
||||
merged = [*self.history, dict(entry)]
|
||||
self.history = tuple(merged[-40:])
|
||||
|
||||
|
||||
def run_remote_mode(
|
||||
target: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> RuntimeModeReport:
|
||||
runtime = RemoteRuntime.from_workspace(
|
||||
cwd or Path.cwd(),
|
||||
additional_working_directories=additional_working_directories,
|
||||
)
|
||||
return runtime.connect(target, mode='remote')
|
||||
|
||||
|
||||
def run_ssh_mode(
|
||||
target: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> RuntimeModeReport:
|
||||
runtime = RemoteRuntime.from_workspace(
|
||||
cwd or Path.cwd(),
|
||||
additional_working_directories=additional_working_directories,
|
||||
)
|
||||
return runtime.connect(target, mode='ssh')
|
||||
|
||||
|
||||
def run_teleport_mode(
|
||||
target: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> RuntimeModeReport:
|
||||
runtime = RemoteRuntime.from_workspace(
|
||||
cwd or Path.cwd(),
|
||||
additional_working_directories=additional_working_directories,
|
||||
)
|
||||
return runtime.connect(target, mode='teleport')
|
||||
|
||||
|
||||
def run_direct_connect_mode(
|
||||
target: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> RuntimeModeReport:
|
||||
runtime = RemoteRuntime.from_workspace(
|
||||
cwd or Path.cwd(),
|
||||
additional_working_directories=additional_working_directories,
|
||||
)
|
||||
return runtime.connect(target, mode='direct-connect')
|
||||
|
||||
|
||||
def run_deep_link_mode(
|
||||
target: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> RuntimeModeReport:
|
||||
runtime = RemoteRuntime.from_workspace(
|
||||
cwd or Path.cwd(),
|
||||
additional_working_directories=additional_working_directories,
|
||||
)
|
||||
return runtime.connect(target, mode='deep-link')
|
||||
|
||||
|
||||
def _discover_manifest_paths(
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...],
|
||||
) -> tuple[Path, ...]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
|
||||
def remember(path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen or not resolved.exists() or not resolved.is_file():
|
||||
return
|
||||
seen.add(resolved)
|
||||
candidates.append(resolved)
|
||||
|
||||
roots: list[Path] = []
|
||||
current = cwd.resolve()
|
||||
while True:
|
||||
roots.append(current)
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
roots.extend(Path(path).resolve() for path in additional_working_directories)
|
||||
|
||||
for root in roots:
|
||||
remember(root / '.claw-remote.json')
|
||||
remember(root / '.remote.json')
|
||||
remember(root / '.codex-remote.json')
|
||||
remember(root / 'remote.json')
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _load_profiles_from_manifest(path: Path) -> list[RemoteProfile]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
profiles: list[RemoteProfile] = []
|
||||
raw_profiles = payload.get('profiles')
|
||||
if isinstance(raw_profiles, list):
|
||||
profiles.extend(_extract_profiles(raw_profiles, manifest_path=path))
|
||||
elif _looks_like_profile(payload):
|
||||
profile = _profile_from_item(payload, manifest_path=path)
|
||||
if profile is not None:
|
||||
profiles.append(profile)
|
||||
remotes = payload.get('remotes')
|
||||
if isinstance(remotes, list):
|
||||
profiles.extend(_extract_profiles(remotes, manifest_path=path))
|
||||
return profiles
|
||||
|
||||
|
||||
def _extract_profiles(raw_profiles: list[Any], *, manifest_path: Path) -> list[RemoteProfile]:
|
||||
profiles: list[RemoteProfile] = []
|
||||
seen_names: set[str] = set()
|
||||
for item in raw_profiles:
|
||||
profile = _profile_from_item(item, manifest_path=manifest_path)
|
||||
if profile is None or profile.name.lower() in seen_names:
|
||||
continue
|
||||
seen_names.add(profile.name.lower())
|
||||
profiles.append(profile)
|
||||
return profiles
|
||||
|
||||
|
||||
def _profile_from_item(item: Any, *, manifest_path: Path) -> RemoteProfile | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
name = item.get('name')
|
||||
target = item.get('target')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
return None
|
||||
mode = _normalize_mode(str(item.get('mode', 'remote')))
|
||||
workspace_cwd = _optional_string(
|
||||
item.get('workspaceCwd')
|
||||
if item.get('workspaceCwd') is not None
|
||||
else item.get('workspace_cwd')
|
||||
)
|
||||
session_url = _optional_string(
|
||||
item.get('sessionUrl')
|
||||
if item.get('sessionUrl') is not None
|
||||
else item.get('session_url')
|
||||
)
|
||||
description = _optional_string(item.get('description'))
|
||||
env = item.get('env')
|
||||
metadata = item.get('metadata')
|
||||
return RemoteProfile(
|
||||
name=name.strip(),
|
||||
mode=mode,
|
||||
target=target.strip(),
|
||||
source_manifest=str(manifest_path),
|
||||
description=description,
|
||||
workspace_cwd=workspace_cwd,
|
||||
session_url=session_url,
|
||||
env=(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in env.items()
|
||||
if isinstance(key, str) and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
if isinstance(env, dict)
|
||||
else {}
|
||||
),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_profile(payload: dict[str, Any]) -> bool:
|
||||
return isinstance(payload.get('name'), str) and isinstance(payload.get('target'), str)
|
||||
|
||||
|
||||
def _load_state_payload(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _connection_from_payload(payload: Any) -> RemoteConnectionState | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
mode = _optional_string(payload.get('mode'))
|
||||
target = _optional_string(payload.get('target'))
|
||||
connected_at = _optional_string(payload.get('connected_at'))
|
||||
if mode is None or target is None or connected_at is None:
|
||||
return None
|
||||
metadata = payload.get('metadata')
|
||||
return RemoteConnectionState(
|
||||
mode=_normalize_mode(mode),
|
||||
target=target,
|
||||
connected=bool(payload.get('connected', True)),
|
||||
connected_at=connected_at,
|
||||
profile_name=_optional_string(payload.get('profile_name')),
|
||||
workspace_cwd=_optional_string(payload.get('workspace_cwd')),
|
||||
session_url=_optional_string(payload.get('session_url')),
|
||||
source_manifest=_optional_string(payload.get('source_manifest')),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str) -> str:
|
||||
normalized = mode.strip().lower().replace('_', '-')
|
||||
if normalized == 'direct':
|
||||
normalized = 'direct-connect'
|
||||
if normalized == 'deeplink':
|
||||
normalized = 'deep-link'
|
||||
if normalized not in SUPPORTED_REMOTE_MODES:
|
||||
return 'remote'
|
||||
return normalized
|
||||
|
||||
|
||||
def _optional_string(value: Any) -> str | None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import parse, request
|
||||
|
||||
|
||||
DEFAULT_SEARCH_STATE_DIR = Path('.port_sessions')
|
||||
DEFAULT_SEARCH_STATE_FILE = DEFAULT_SEARCH_STATE_DIR / 'search_runtime.json'
|
||||
SEARCH_MANIFEST_PATHS = (
|
||||
Path('.claw-search.json'),
|
||||
Path('.claude/search.json'),
|
||||
)
|
||||
DEFAULT_SEARXNG_BASE_URL = 'http://127.0.0.1:8080'
|
||||
DEFAULT_BRAVE_BASE_URL = 'https://api.search.brave.com/res/v1/web/search'
|
||||
DEFAULT_TAVILY_BASE_URL = 'https://api.tavily.com/search'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchProviderProfile:
|
||||
name: str
|
||||
provider: str
|
||||
source_manifest: str
|
||||
base_url: str
|
||||
api_key_env: str | None = None
|
||||
description: str | None = None
|
||||
default_max_results: int = 5
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchResult:
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
provider_name: str
|
||||
rank: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchStatusReport:
|
||||
configured: bool
|
||||
detail: str
|
||||
provider_name: str | None = None
|
||||
provider_kind: str | None = None
|
||||
base_url: str | None = None
|
||||
manifest_count: int = 0
|
||||
provider_count: int = 0
|
||||
api_key_env: str | None = None
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f'configured={self.configured}',
|
||||
f'detail={self.detail}',
|
||||
f'manifest_count={self.manifest_count}',
|
||||
f'provider_count={self.provider_count}',
|
||||
]
|
||||
if self.provider_name:
|
||||
lines.append(f'provider={self.provider_name}')
|
||||
if self.provider_kind:
|
||||
lines.append(f'provider_kind={self.provider_kind}')
|
||||
if self.base_url:
|
||||
lines.append(f'base_url={self.base_url}')
|
||||
if self.api_key_env:
|
||||
lines.append(f'api_key_env={self.api_key_env}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchRuntime:
|
||||
cwd: Path
|
||||
providers: tuple[SearchProviderProfile, ...] = field(default_factory=tuple)
|
||||
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||
state_path: Path = field(default_factory=lambda: DEFAULT_SEARCH_STATE_FILE.resolve())
|
||||
active_provider_name: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'SearchRuntime':
|
||||
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||
providers: list[SearchProviderProfile] = []
|
||||
for manifest_path in manifest_paths:
|
||||
providers.extend(_load_profiles_from_manifest(manifest_path))
|
||||
providers.extend(_load_profiles_from_env())
|
||||
providers = _dedupe_profiles(providers)
|
||||
state_path = (cwd.resolve() / DEFAULT_SEARCH_STATE_FILE).resolve()
|
||||
payload = _load_state_payload(state_path)
|
||||
active_provider_name = payload.get('active_provider_name')
|
||||
if not isinstance(active_provider_name, str):
|
||||
active_provider_name = None
|
||||
return cls(
|
||||
cwd=cwd.resolve(),
|
||||
providers=tuple(providers),
|
||||
manifests=tuple(str(path) for path in manifest_paths),
|
||||
state_path=state_path,
|
||||
active_provider_name=active_provider_name,
|
||||
)
|
||||
|
||||
def has_search_runtime(self) -> bool:
|
||||
return bool(self.providers)
|
||||
|
||||
def list_providers(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[SearchProviderProfile, ...]:
|
||||
providers = self.providers
|
||||
if query:
|
||||
needle = query.lower()
|
||||
providers = tuple(
|
||||
provider
|
||||
for provider in providers
|
||||
if needle in provider.name.lower()
|
||||
or needle in provider.provider.lower()
|
||||
or needle in provider.base_url.lower()
|
||||
or needle in (provider.description or '').lower()
|
||||
)
|
||||
if limit is not None and limit >= 0:
|
||||
providers = providers[:limit]
|
||||
return providers
|
||||
|
||||
def get_provider(self, name: str) -> SearchProviderProfile | None:
|
||||
needle = name.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
for provider in self.providers:
|
||||
if provider.name.lower() == needle:
|
||||
return provider
|
||||
return None
|
||||
|
||||
def current_provider(self) -> SearchProviderProfile | None:
|
||||
if self.active_provider_name:
|
||||
active = self.get_provider(self.active_provider_name)
|
||||
if active is not None:
|
||||
return active
|
||||
env_default = os.environ.get('CLAW_SEARCH_PROVIDER')
|
||||
if isinstance(env_default, str) and env_default.strip():
|
||||
active = self.get_provider(env_default.strip())
|
||||
if active is not None:
|
||||
return active
|
||||
return self.providers[0] if self.providers else None
|
||||
|
||||
def activate_provider(self, name: str) -> SearchStatusReport:
|
||||
provider = self.get_provider(name)
|
||||
if provider is None:
|
||||
raise KeyError(name)
|
||||
self.active_provider_name = provider.name
|
||||
self._persist_state()
|
||||
return SearchStatusReport(
|
||||
configured=True,
|
||||
detail=f'Activated search provider {provider.name}',
|
||||
provider_name=provider.name,
|
||||
provider_kind=provider.provider,
|
||||
base_url=provider.base_url,
|
||||
manifest_count=len(self.manifests),
|
||||
provider_count=len(self.providers),
|
||||
api_key_env=provider.api_key_env,
|
||||
)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local search manifests: {len(self.manifests)}',
|
||||
f'Configured search providers: {len(self.providers)}',
|
||||
]
|
||||
current = self.current_provider()
|
||||
if current is None:
|
||||
lines.append('- Active search provider: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append(f'- Active search provider: {current.name} ({current.provider})')
|
||||
for provider in self.providers[:5]:
|
||||
details = [provider.name, provider.provider, provider.base_url]
|
||||
if provider.api_key_env:
|
||||
details.append(f'api_key_env={provider.api_key_env}')
|
||||
lines.append('- Provider: ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_providers_index(self, *, query: str | None = None) -> str:
|
||||
providers = self.list_providers(query=query, limit=100)
|
||||
lines = ['# Search Providers', '']
|
||||
if not providers:
|
||||
lines.append('No local search providers discovered.')
|
||||
return '\n'.join(lines)
|
||||
for provider in providers:
|
||||
details = [provider.name, provider.provider, provider.base_url]
|
||||
if provider.api_key_env:
|
||||
details.append(f'api_key_env={provider.api_key_env}')
|
||||
lines.append('- ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_provider(self, name: str) -> str:
|
||||
provider = self.get_provider(name)
|
||||
if provider is None:
|
||||
return f'# Search Provider\n\nUnknown search provider: {name}'
|
||||
lines = [
|
||||
'# Search Provider',
|
||||
'',
|
||||
f'- Name: {provider.name}',
|
||||
f'- Provider: {provider.provider}',
|
||||
f'- Base URL: {provider.base_url}',
|
||||
f'- Source manifest: {provider.source_manifest}',
|
||||
]
|
||||
if provider.api_key_env:
|
||||
lines.append(f'- API key env: {provider.api_key_env}')
|
||||
if provider.description:
|
||||
lines.extend(['', provider.description])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_search_results(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
max_results: int = 5,
|
||||
domains: tuple[str, ...] = (),
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> str:
|
||||
provider, results = self.search(
|
||||
query,
|
||||
provider_name=provider_name,
|
||||
max_results=max_results,
|
||||
domains=domains,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
lines = ['# Web Search', '']
|
||||
lines.append(f'- Provider: {provider.name} ({provider.provider})')
|
||||
lines.append(f'- Query: {query}')
|
||||
lines.append(f'- Results: {len(results)}')
|
||||
lines.append('')
|
||||
if not results:
|
||||
lines.append('No search results.')
|
||||
return '\n'.join(lines)
|
||||
for result in results:
|
||||
lines.append(f'{result.rank}. {result.title}')
|
||||
lines.append(f' {result.url}')
|
||||
if result.snippet:
|
||||
lines.append(f' {result.snippet}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
max_results: int = 5,
|
||||
domains: tuple[str, ...] = (),
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> tuple[SearchProviderProfile, tuple[SearchResult, ...]]:
|
||||
provider = self._resolve_provider(provider_name)
|
||||
backend = provider.provider.lower()
|
||||
if backend == 'searxng':
|
||||
results = _search_searxng(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
|
||||
elif backend == 'brave':
|
||||
results = _search_brave(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
|
||||
elif backend == 'tavily':
|
||||
results = _search_tavily(provider, query, max_results=max_results, domains=domains, timeout_seconds=timeout_seconds)
|
||||
else:
|
||||
raise ValueError(f'Unsupported search provider: {provider.provider}')
|
||||
if domains:
|
||||
results = tuple(result for result in results if _matches_domains(result.url, domains))
|
||||
return provider, tuple(results[:max_results])
|
||||
|
||||
def _resolve_provider(self, provider_name: str | None) -> SearchProviderProfile:
|
||||
if provider_name:
|
||||
provider = self.get_provider(provider_name)
|
||||
if provider is None:
|
||||
raise KeyError(provider_name)
|
||||
return provider
|
||||
provider = self.current_provider()
|
||||
if provider is None:
|
||||
raise LookupError('No local search provider is configured.')
|
||||
return provider
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
payload = {'active_provider_name': self.active_provider_name}
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
|
||||
candidate_roots = [cwd.resolve()]
|
||||
for raw_path in additional_working_directories:
|
||||
path = Path(raw_path).resolve()
|
||||
if path not in candidate_roots:
|
||||
candidate_roots.append(path)
|
||||
discovered: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for root in candidate_roots:
|
||||
for relative_path in SEARCH_MANIFEST_PATHS:
|
||||
path = (root / relative_path).resolve()
|
||||
if path in seen or not path.exists() or not path.is_file():
|
||||
continue
|
||||
seen.add(path)
|
||||
discovered.append(path)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _load_profiles_from_manifest(path: Path) -> list[SearchProviderProfile]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
if isinstance(payload, dict):
|
||||
providers_payload = payload.get('providers')
|
||||
if isinstance(providers_payload, list):
|
||||
return [
|
||||
provider
|
||||
for item in providers_payload
|
||||
for provider in [_provider_from_payload(item, path)]
|
||||
if provider is not None
|
||||
]
|
||||
single = _provider_from_payload(payload, path)
|
||||
return [single] if single is not None else []
|
||||
return []
|
||||
|
||||
|
||||
def _provider_from_payload(payload: Any, path: Path) -> SearchProviderProfile | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
name = payload.get('name')
|
||||
provider = payload.get('provider')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
normalized_provider = provider.strip().lower()
|
||||
base_url = _optional_str(payload.get('baseUrl') or payload.get('base_url')) or _default_base_url(normalized_provider)
|
||||
if base_url is None:
|
||||
return None
|
||||
api_key_env = _optional_str(payload.get('apiKeyEnv') or payload.get('api_key_env')) or _default_api_env(normalized_provider)
|
||||
description = _optional_str(payload.get('description'))
|
||||
default_max_results = payload.get('defaultMaxResults') or payload.get('default_max_results') or 5
|
||||
if isinstance(default_max_results, bool) or not isinstance(default_max_results, int):
|
||||
default_max_results = 5
|
||||
metadata = payload.get('metadata')
|
||||
return SearchProviderProfile(
|
||||
name=name.strip(),
|
||||
provider=normalized_provider,
|
||||
source_manifest=str(path),
|
||||
base_url=base_url,
|
||||
api_key_env=api_key_env,
|
||||
description=description,
|
||||
default_max_results=max(default_max_results, 1),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _load_profiles_from_env() -> list[SearchProviderProfile]:
|
||||
providers: list[SearchProviderProfile] = []
|
||||
searxng_base = os.environ.get('SEARXNG_BASE_URL')
|
||||
if isinstance(searxng_base, str) and searxng_base.strip():
|
||||
providers.append(
|
||||
SearchProviderProfile(
|
||||
name='searxng',
|
||||
provider='searxng',
|
||||
source_manifest='env:SEARXNG_BASE_URL',
|
||||
base_url=searxng_base.strip(),
|
||||
)
|
||||
)
|
||||
brave_key = os.environ.get('BRAVE_SEARCH_API_KEY')
|
||||
if isinstance(brave_key, str) and brave_key.strip():
|
||||
providers.append(
|
||||
SearchProviderProfile(
|
||||
name='brave',
|
||||
provider='brave',
|
||||
source_manifest='env:BRAVE_SEARCH_API_KEY',
|
||||
base_url=DEFAULT_BRAVE_BASE_URL,
|
||||
api_key_env='BRAVE_SEARCH_API_KEY',
|
||||
)
|
||||
)
|
||||
tavily_key = os.environ.get('TAVILY_API_KEY')
|
||||
if isinstance(tavily_key, str) and tavily_key.strip():
|
||||
providers.append(
|
||||
SearchProviderProfile(
|
||||
name='tavily',
|
||||
provider='tavily',
|
||||
source_manifest='env:TAVILY_API_KEY',
|
||||
base_url=DEFAULT_TAVILY_BASE_URL,
|
||||
api_key_env='TAVILY_API_KEY',
|
||||
)
|
||||
)
|
||||
return providers
|
||||
|
||||
|
||||
def _dedupe_profiles(providers: list[SearchProviderProfile]) -> list[SearchProviderProfile]:
|
||||
seen: set[str] = set()
|
||||
deduped: list[SearchProviderProfile] = []
|
||||
for provider in providers:
|
||||
key = provider.name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(provider)
|
||||
return deduped
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
def _default_base_url(provider: str) -> str | None:
|
||||
if provider == 'searxng':
|
||||
return DEFAULT_SEARXNG_BASE_URL
|
||||
if provider == 'brave':
|
||||
return DEFAULT_BRAVE_BASE_URL
|
||||
if provider == 'tavily':
|
||||
return DEFAULT_TAVILY_BASE_URL
|
||||
return None
|
||||
|
||||
|
||||
def _default_api_env(provider: str) -> str | None:
|
||||
if provider == 'brave':
|
||||
return 'BRAVE_SEARCH_API_KEY'
|
||||
if provider == 'tavily':
|
||||
return 'TAVILY_API_KEY'
|
||||
return None
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _search_searxng(
|
||||
provider: SearchProviderProfile,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[SearchResult, ...]:
|
||||
endpoint = provider.base_url.rstrip('/')
|
||||
if not endpoint.endswith('/search'):
|
||||
endpoint += '/search'
|
||||
url = endpoint + '?' + parse.urlencode(
|
||||
{
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
}
|
||||
)
|
||||
req = request.Request(url, headers={'User-Agent': 'claw-code-agent/1.0'})
|
||||
with request.urlopen(req, timeout=timeout_seconds) as response:
|
||||
payload = json.loads(response.read().decode('utf-8', errors='replace'))
|
||||
results = payload.get('results')
|
||||
if not isinstance(results, list):
|
||||
return ()
|
||||
rendered: list[SearchResult] = []
|
||||
for index, item in enumerate(results[:max_results], start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url_value = item.get('url')
|
||||
title = item.get('title')
|
||||
snippet = item.get('content') or item.get('snippet') or ''
|
||||
if not isinstance(url_value, str) or not url_value.strip():
|
||||
continue
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
title = url_value
|
||||
rendered.append(
|
||||
SearchResult(
|
||||
title=title.strip(),
|
||||
url=url_value.strip(),
|
||||
snippet=str(snippet).strip(),
|
||||
provider_name=provider.name,
|
||||
rank=index,
|
||||
)
|
||||
)
|
||||
return tuple(rendered)
|
||||
|
||||
|
||||
def _search_brave(
|
||||
provider: SearchProviderProfile,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[SearchResult, ...]:
|
||||
api_key = _require_api_key(provider)
|
||||
url = provider.base_url + '?' + parse.urlencode({'q': query, 'count': max_results})
|
||||
req = request.Request(
|
||||
url,
|
||||
headers={
|
||||
'User-Agent': 'claw-code-agent/1.0',
|
||||
'X-Subscription-Token': api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout_seconds) as response:
|
||||
payload = json.loads(response.read().decode('utf-8', errors='replace'))
|
||||
results = payload.get('web', {}).get('results')
|
||||
if not isinstance(results, list):
|
||||
return ()
|
||||
rendered: list[SearchResult] = []
|
||||
for index, item in enumerate(results[:max_results], start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url_value = item.get('url')
|
||||
title = item.get('title')
|
||||
snippet = item.get('description') or ''
|
||||
if not isinstance(url_value, str) or not url_value.strip():
|
||||
continue
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
title = url_value
|
||||
rendered.append(
|
||||
SearchResult(
|
||||
title=title.strip(),
|
||||
url=url_value.strip(),
|
||||
snippet=str(snippet).strip(),
|
||||
provider_name=provider.name,
|
||||
rank=index,
|
||||
)
|
||||
)
|
||||
return tuple(rendered)
|
||||
|
||||
|
||||
def _search_tavily(
|
||||
provider: SearchProviderProfile,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int,
|
||||
domains: tuple[str, ...],
|
||||
timeout_seconds: float,
|
||||
) -> tuple[SearchResult, ...]:
|
||||
api_key = _require_api_key(provider)
|
||||
payload = {
|
||||
'api_key': api_key,
|
||||
'query': query,
|
||||
'max_results': max_results,
|
||||
}
|
||||
if domains:
|
||||
payload['include_domains'] = list(domains)
|
||||
data = json.dumps(payload, ensure_ascii=True).encode('utf-8')
|
||||
req = request.Request(
|
||||
provider.base_url,
|
||||
data=data,
|
||||
headers={
|
||||
'User-Agent': 'claw-code-agent/1.0',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout_seconds) as response:
|
||||
body = json.loads(response.read().decode('utf-8', errors='replace'))
|
||||
results = body.get('results')
|
||||
if not isinstance(results, list):
|
||||
return ()
|
||||
rendered: list[SearchResult] = []
|
||||
for index, item in enumerate(results[:max_results], start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url_value = item.get('url')
|
||||
title = item.get('title')
|
||||
snippet = item.get('content') or ''
|
||||
if not isinstance(url_value, str) or not url_value.strip():
|
||||
continue
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
title = url_value
|
||||
rendered.append(
|
||||
SearchResult(
|
||||
title=title.strip(),
|
||||
url=url_value.strip(),
|
||||
snippet=str(snippet).strip(),
|
||||
provider_name=provider.name,
|
||||
rank=index,
|
||||
)
|
||||
)
|
||||
return tuple(rendered)
|
||||
|
||||
|
||||
def _require_api_key(provider: SearchProviderProfile) -> str:
|
||||
if provider.api_key_env is None:
|
||||
raise LookupError(f'Search provider {provider.name} does not define an API key env var.')
|
||||
value = os.environ.get(provider.api_key_env)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LookupError(
|
||||
f'Search provider {provider.name} requires env var {provider.api_key_env}.'
|
||||
)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _matches_domains(url: str, domains: tuple[str, ...]) -> bool:
|
||||
hostname = parse.urlparse(url).hostname or ''
|
||||
hostname = hostname.lower()
|
||||
for domain in domains:
|
||||
normalized = domain.strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if hostname == normalized or hostname.endswith('.' + normalized):
|
||||
return True
|
||||
return False
|
||||
+58
-6
@@ -5,16 +5,27 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
|
||||
VALID_TASK_STATUSES = (
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'blocked',
|
||||
'cancelled',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortingTask:
|
||||
task_id: str
|
||||
title: str
|
||||
status: str = 'todo'
|
||||
status: str = 'pending'
|
||||
description: str | None = None
|
||||
priority: str | None = None
|
||||
active_form: str | None = None
|
||||
owner: str | None = None
|
||||
blocks: tuple[str, ...] = ()
|
||||
blocked_by: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
@@ -29,6 +40,11 @@ class PortingTask:
|
||||
'status': self.status,
|
||||
'description': self.description,
|
||||
'priority': self.priority,
|
||||
'active_form': self.active_form,
|
||||
'owner': self.owner,
|
||||
'blocks': list(self.blocks),
|
||||
'blocked_by': list(self.blocked_by),
|
||||
'metadata': dict(self.metadata),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
}
|
||||
@@ -49,6 +65,25 @@ class PortingTask:
|
||||
if isinstance(payload.get('priority'), str)
|
||||
else None
|
||||
),
|
||||
active_form=(
|
||||
str(payload.get('active_form'))
|
||||
if isinstance(payload.get('active_form'), str)
|
||||
and payload.get('active_form')
|
||||
else None
|
||||
),
|
||||
owner=(
|
||||
str(payload.get('owner'))
|
||||
if isinstance(payload.get('owner'), str)
|
||||
and payload.get('owner')
|
||||
else None
|
||||
),
|
||||
blocks=_normalize_string_tuple(payload.get('blocks')),
|
||||
blocked_by=_normalize_string_tuple(payload.get('blocked_by')),
|
||||
metadata=(
|
||||
dict(payload.get('metadata'))
|
||||
if isinstance(payload.get('metadata'), dict)
|
||||
else {}
|
||||
),
|
||||
created_at=(
|
||||
str(payload.get('created_at'))
|
||||
if isinstance(payload.get('created_at'), str)
|
||||
@@ -68,11 +103,28 @@ def _normalize_task_status(value: Any) -> str:
|
||||
aliases = {
|
||||
'in-progress': 'in_progress',
|
||||
'in progress': 'in_progress',
|
||||
'complete': 'done',
|
||||
'completed': 'done',
|
||||
'open': 'todo',
|
||||
'complete': 'completed',
|
||||
'done': 'completed',
|
||||
'todo': 'pending',
|
||||
'open': 'pending',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_TASK_STATUSES:
|
||||
return lowered
|
||||
return 'todo'
|
||||
return 'pending'
|
||||
|
||||
|
||||
def _normalize_string_tuple(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
normalized.append(text)
|
||||
seen.add(text)
|
||||
return tuple(normalized)
|
||||
|
||||
+328
-11
@@ -12,6 +12,8 @@ from .task import PortingTask, VALID_TASK_STATUSES
|
||||
|
||||
|
||||
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
|
||||
ACTIONABLE_TASK_STATUSES = ('pending', 'in_progress')
|
||||
TERMINAL_TASK_STATUSES = ('completed', 'cancelled')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -57,16 +59,38 @@ class TaskRuntime:
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
actionable_only: bool = False,
|
||||
limit: int | None = None,
|
||||
) -> tuple[PortingTask, ...]:
|
||||
tasks = self.tasks
|
||||
if status:
|
||||
normalized = _normalize_status(status)
|
||||
tasks = tuple(task for task in tasks if task.status == normalized)
|
||||
if owner:
|
||||
tasks = tuple(task for task in tasks if task.owner == owner)
|
||||
if actionable_only:
|
||||
actionable_ids = {task.task_id for task in self.next_tasks(limit=None)}
|
||||
tasks = tuple(task for task in tasks if task.task_id in actionable_ids)
|
||||
tasks = tuple(_sort_tasks(tasks))
|
||||
if limit is not None and limit >= 0:
|
||||
tasks = tasks[:limit]
|
||||
return tasks
|
||||
|
||||
def next_tasks(self, *, limit: int | None = 10) -> tuple[PortingTask, ...]:
|
||||
tasks_by_id = {task.task_id: task for task in self.tasks}
|
||||
actionable: list[PortingTask] = []
|
||||
for task in self.tasks:
|
||||
if task.status not in ACTIONABLE_TASK_STATUSES:
|
||||
continue
|
||||
unresolved = _unresolved_dependencies(task, tasks_by_id)
|
||||
if task.status == 'in_progress' or not unresolved:
|
||||
actionable.append(task)
|
||||
actionable = list(_sort_tasks(actionable))
|
||||
if limit is not None and limit >= 0:
|
||||
actionable = actionable[:limit]
|
||||
return tuple(actionable)
|
||||
|
||||
def get_task(self, task_id: str) -> PortingTask | None:
|
||||
for task in self.tasks:
|
||||
if task.task_id == task_id:
|
||||
@@ -78,9 +102,14 @@ class TaskRuntime:
|
||||
*,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
status: str = 'todo',
|
||||
status: str = 'pending',
|
||||
priority: str | None = None,
|
||||
task_id: str | None = None,
|
||||
active_form: str | None = None,
|
||||
owner: str | None = None,
|
||||
blocks: tuple[str, ...] | list[str] = (),
|
||||
blocked_by: tuple[str, ...] | list[str] = (),
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> TaskMutation:
|
||||
task = PortingTask(
|
||||
task_id=task_id or f'task_{uuid4().hex[:10]}',
|
||||
@@ -88,6 +117,15 @@ class TaskRuntime:
|
||||
description=description.strip() if isinstance(description, str) and description.strip() else None,
|
||||
status=_normalize_status(status),
|
||||
priority=priority.strip() if isinstance(priority, str) and priority.strip() else None,
|
||||
active_form=(
|
||||
active_form.strip()
|
||||
if isinstance(active_form, str) and active_form.strip()
|
||||
else None
|
||||
),
|
||||
owner=owner.strip() if isinstance(owner, str) and owner.strip() else None,
|
||||
blocks=_normalize_id_list(blocks),
|
||||
blocked_by=_normalize_id_list(blocked_by),
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
return self._persist((*self.tasks, task), task=task)
|
||||
|
||||
@@ -99,10 +137,19 @@ class TaskRuntime:
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
active_form: str | None = None,
|
||||
owner: str | None = None,
|
||||
blocks: tuple[str, ...] | list[str] | None = None,
|
||||
blocked_by: tuple[str, ...] | list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
merge_metadata: bool = False,
|
||||
) -> TaskMutation:
|
||||
existing = self.get_task(task_id)
|
||||
if existing is None:
|
||||
raise KeyError(task_id)
|
||||
updated_metadata = dict(existing.metadata)
|
||||
if metadata is not None:
|
||||
updated_metadata = {**updated_metadata, **metadata} if merge_metadata else dict(metadata)
|
||||
updated = replace(
|
||||
existing,
|
||||
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
|
||||
@@ -119,11 +166,133 @@ class TaskRuntime:
|
||||
else None if priority == ''
|
||||
else existing.priority
|
||||
),
|
||||
active_form=(
|
||||
active_form.strip()
|
||||
if isinstance(active_form, str) and active_form.strip()
|
||||
else None if active_form == ''
|
||||
else existing.active_form
|
||||
),
|
||||
owner=(
|
||||
owner.strip()
|
||||
if isinstance(owner, str) and owner.strip()
|
||||
else None if owner == ''
|
||||
else existing.owner
|
||||
),
|
||||
blocks=(
|
||||
_normalize_id_list(blocks)
|
||||
if blocks is not None
|
||||
else existing.blocks
|
||||
),
|
||||
blocked_by=(
|
||||
_normalize_id_list(blocked_by)
|
||||
if blocked_by is not None
|
||||
else existing.blocked_by
|
||||
),
|
||||
metadata=updated_metadata,
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
|
||||
return self._persist(tasks, task=updated)
|
||||
|
||||
def start_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
owner: str | None = None,
|
||||
active_form: str | None = None,
|
||||
) -> TaskMutation:
|
||||
existing = self.get_task(task_id)
|
||||
if existing is None:
|
||||
raise KeyError(task_id)
|
||||
unresolved = _unresolved_dependencies(existing, {task.task_id: task for task in self.tasks})
|
||||
metadata = dict(existing.metadata)
|
||||
if unresolved:
|
||||
metadata['blocked_reason'] = f'waiting_on:{",".join(unresolved)}'
|
||||
return self.update_task(
|
||||
task_id,
|
||||
status='blocked',
|
||||
owner=owner if owner is not None else existing.owner,
|
||||
active_form=active_form if active_form is not None else existing.active_form,
|
||||
metadata=metadata,
|
||||
)
|
||||
if 'blocked_reason' in metadata:
|
||||
metadata.pop('blocked_reason')
|
||||
return self.update_task(
|
||||
task_id,
|
||||
status='in_progress',
|
||||
owner=owner if owner is not None else existing.owner,
|
||||
active_form=active_form if active_form is not None else existing.active_form,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def complete_task(self, task_id: str) -> TaskMutation:
|
||||
mutation = self.update_task(task_id, status='completed')
|
||||
completed_ids = {task.task_id for task in self.tasks if task.status in TERMINAL_TASK_STATUSES}
|
||||
updated_tasks: list[PortingTask] = []
|
||||
changed = False
|
||||
for task in self.tasks:
|
||||
if task.status != 'blocked':
|
||||
updated_tasks.append(task)
|
||||
continue
|
||||
unresolved = tuple(
|
||||
dependency
|
||||
for dependency in task.blocked_by
|
||||
if dependency not in completed_ids
|
||||
)
|
||||
if unresolved:
|
||||
updated_tasks.append(task)
|
||||
continue
|
||||
metadata = dict(task.metadata)
|
||||
metadata.pop('blocked_reason', None)
|
||||
updated_tasks.append(
|
||||
replace(
|
||||
task,
|
||||
status='pending',
|
||||
metadata=metadata,
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
return self._persist(tuple(updated_tasks), task=self.get_task(task_id))
|
||||
return mutation
|
||||
|
||||
def block_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
blocked_by: tuple[str, ...] | list[str] | None = None,
|
||||
reason: str | None = None,
|
||||
) -> TaskMutation:
|
||||
existing = self.get_task(task_id)
|
||||
if existing is None:
|
||||
raise KeyError(task_id)
|
||||
merged_blocked_by = tuple(existing.blocked_by)
|
||||
if blocked_by is not None:
|
||||
merged_blocked_by = _merge_ids(existing.blocked_by, _normalize_id_list(blocked_by))
|
||||
metadata = dict(existing.metadata)
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
metadata['blocked_reason'] = reason.strip()
|
||||
return self.update_task(
|
||||
task_id,
|
||||
status='blocked',
|
||||
blocked_by=merged_blocked_by,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def cancel_task(self, task_id: str, *, reason: str | None = None) -> TaskMutation:
|
||||
existing = self.get_task(task_id)
|
||||
if existing is None:
|
||||
raise KeyError(task_id)
|
||||
metadata = dict(existing.metadata)
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
metadata['cancel_reason'] = reason.strip()
|
||||
return self.update_task(
|
||||
task_id,
|
||||
status='cancelled',
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
|
||||
tasks: list[PortingTask] = []
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -157,6 +326,25 @@ class TaskRuntime:
|
||||
and item.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
active_form=(
|
||||
item.get('active_form').strip()
|
||||
if isinstance(item.get('active_form'), str)
|
||||
and item.get('active_form').strip()
|
||||
else None
|
||||
),
|
||||
owner=(
|
||||
item.get('owner').strip()
|
||||
if isinstance(item.get('owner'), str)
|
||||
and item.get('owner').strip()
|
||||
else None
|
||||
),
|
||||
blocks=_normalize_id_list(item.get('blocks', [])),
|
||||
blocked_by=_normalize_id_list(item.get('blocked_by', [])),
|
||||
metadata=(
|
||||
dict(item.get('metadata'))
|
||||
if isinstance(item.get('metadata'), dict)
|
||||
else {}
|
||||
),
|
||||
created_at=(
|
||||
str(item.get('created_at'))
|
||||
if isinstance(item.get('created_at'), str)
|
||||
@@ -181,26 +369,55 @@ class TaskRuntime:
|
||||
'- Status counts: '
|
||||
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
|
||||
)
|
||||
actionable = self.next_tasks(limit=None)
|
||||
if actionable:
|
||||
lines.append(f'- Actionable tasks: {len(actionable)}')
|
||||
blocked = [task for task in self.tasks if task.status == 'blocked']
|
||||
if blocked:
|
||||
lines.append(f'- Blocked tasks: {len(blocked)}')
|
||||
if self.tasks:
|
||||
preview = ', '.join(task.title for task in self.tasks[:4])
|
||||
preview = ', '.join(task.title for task in _sort_tasks(self.tasks)[:4])
|
||||
if len(self.tasks) > 4:
|
||||
preview += f', ... (+{len(self.tasks) - 4} more)'
|
||||
lines.append(f'- Task preview: {preview}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str:
|
||||
tasks = self.list_tasks(status=status, limit=limit)
|
||||
def render_tasks(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
actionable_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
tasks = self.list_tasks(
|
||||
status=status,
|
||||
owner=owner,
|
||||
actionable_only=actionable_only,
|
||||
limit=limit,
|
||||
)
|
||||
if not tasks:
|
||||
return '# Tasks\n\nNo tasks are currently stored.'
|
||||
lines = ['# Tasks', '']
|
||||
if actionable_only:
|
||||
lines.append('Showing actionable tasks only.')
|
||||
lines.append('')
|
||||
for task in tasks:
|
||||
details = [task.task_id, f'status={task.status}']
|
||||
if task.priority:
|
||||
details.append(f'priority={task.priority}')
|
||||
if task.owner:
|
||||
details.append(f'owner={task.owner}')
|
||||
details.append(f'title={task.title}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
if task.description:
|
||||
lines.append(f' description: {task.description}')
|
||||
if task.active_form:
|
||||
lines.append(f' active_form: {task.active_form}')
|
||||
if task.blocked_by:
|
||||
lines.append(f" blocked_by: {', '.join(task.blocked_by)}")
|
||||
if task.blocks:
|
||||
lines.append(f" blocks: {', '.join(task.blocks)}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_task(self, task_id: str) -> str:
|
||||
@@ -216,22 +433,52 @@ class TaskRuntime:
|
||||
]
|
||||
if task.priority:
|
||||
lines.append(f'- Priority: {task.priority}')
|
||||
if task.owner:
|
||||
lines.append(f'- Owner: {task.owner}')
|
||||
if task.active_form:
|
||||
lines.append(f'- Active Form: {task.active_form}')
|
||||
if task.description:
|
||||
lines.append(f'- Description: {task.description}')
|
||||
if task.blocked_by:
|
||||
lines.append(f"- Blocked By: {', '.join(task.blocked_by)}")
|
||||
if task.blocks:
|
||||
lines.append(f"- Blocks: {', '.join(task.blocks)}")
|
||||
if task.metadata:
|
||||
lines.append('- Metadata:')
|
||||
for key, value in sorted(task.metadata.items()):
|
||||
lines.append(f' - {key}={value}')
|
||||
lines.append(f'- Updated: {task.updated_at}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_next_tasks(self, *, limit: int = 10) -> str:
|
||||
tasks = self.next_tasks(limit=limit)
|
||||
if not tasks:
|
||||
return '# Next Tasks\n\nNo actionable tasks are currently available.'
|
||||
lines = ['# Next Tasks', '']
|
||||
for task in tasks:
|
||||
details = [task.task_id, f'status={task.status}', f'title={task.title}']
|
||||
if task.owner:
|
||||
details.append(f'owner={task.owner}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
unresolved = _unresolved_dependencies(task, {item.task_id: item for item in self.tasks})
|
||||
if unresolved:
|
||||
lines.append(f" unresolved_dependencies: {', '.join(unresolved)}")
|
||||
if task.active_form:
|
||||
lines.append(f' active_form: {task.active_form}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _persist(
|
||||
self,
|
||||
tasks: tuple[PortingTask, ...],
|
||||
*,
|
||||
task: PortingTask | None,
|
||||
) -> TaskMutation:
|
||||
before_text = self._serialize_payload(self.tasks)
|
||||
before_tasks = self.tasks
|
||||
before_text = self._serialize_payload(before_tasks)
|
||||
before_preview = _snapshot_text(before_text)
|
||||
before_sha256 = (
|
||||
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
|
||||
if self.storage_path.exists() or self.tasks
|
||||
if self.storage_path.exists() or before_tasks
|
||||
else None
|
||||
)
|
||||
payload_text = self._serialize_payload(tasks)
|
||||
@@ -246,7 +493,7 @@ class TaskRuntime:
|
||||
after_sha256=after_sha256,
|
||||
before_preview=before_preview if before_text.strip() else None,
|
||||
after_preview=_snapshot_text(payload_text),
|
||||
before_count=len(json.loads(before_text).get('tasks', [])) if before_text.strip() else 0,
|
||||
before_count=len(before_tasks),
|
||||
after_count=len(tasks),
|
||||
)
|
||||
|
||||
@@ -268,11 +515,81 @@ def _normalize_status(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
|
||||
aliases = {
|
||||
'complete': 'done',
|
||||
'completed': 'done',
|
||||
'open': 'todo',
|
||||
'complete': 'completed',
|
||||
'done': 'completed',
|
||||
'todo': 'pending',
|
||||
'open': 'pending',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_TASK_STATUSES:
|
||||
return lowered
|
||||
return 'todo'
|
||||
return 'pending'
|
||||
|
||||
|
||||
def _normalize_id_list(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, tuple):
|
||||
items = list(value)
|
||||
elif isinstance(value, list):
|
||||
items = value
|
||||
else:
|
||||
return ()
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
normalized.append(text)
|
||||
seen.add(text)
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def _merge_ids(existing: tuple[str, ...], additions: tuple[str, ...]) -> tuple[str, ...]:
|
||||
merged = list(existing)
|
||||
seen = set(existing)
|
||||
for item in additions:
|
||||
if item in seen:
|
||||
continue
|
||||
merged.append(item)
|
||||
seen.add(item)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def _unresolved_dependencies(
|
||||
task: PortingTask,
|
||||
tasks_by_id: dict[str, PortingTask],
|
||||
) -> tuple[str, ...]:
|
||||
unresolved: list[str] = []
|
||||
for dependency_id in task.blocked_by:
|
||||
dependency = tasks_by_id.get(dependency_id)
|
||||
if dependency is None:
|
||||
unresolved.append(dependency_id)
|
||||
continue
|
||||
if dependency.status not in TERMINAL_TASK_STATUSES:
|
||||
unresolved.append(dependency_id)
|
||||
return tuple(unresolved)
|
||||
|
||||
|
||||
def _task_sort_key(task: PortingTask) -> tuple[int, int, str, str]:
|
||||
status_rank = {
|
||||
'in_progress': 0,
|
||||
'pending': 1,
|
||||
'blocked': 2,
|
||||
'completed': 3,
|
||||
'cancelled': 4,
|
||||
}.get(task.status, 9)
|
||||
priority_rank = {
|
||||
'critical': 0,
|
||||
'high': 1,
|
||||
'medium': 2,
|
||||
'low': 3,
|
||||
}.get((task.priority or '').lower(), 9)
|
||||
return (status_rank, priority_rank, task.title.lower(), task.task_id)
|
||||
|
||||
|
||||
def _sort_tasks(tasks: tuple[PortingTask, ...] | list[PortingTask]) -> tuple[PortingTask, ...]:
|
||||
return tuple(sorted(tasks, key=_task_sort_key))
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenCounterInfo:
|
||||
backend: str
|
||||
source: str
|
||||
accurate: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedTokenCounter:
|
||||
info: TokenCounterInfo
|
||||
count_text: Callable[[str], int]
|
||||
|
||||
|
||||
def count_tokens(text: str, model: str | None = None) -> int:
|
||||
counter = resolve_token_counter(model)
|
||||
return counter.count_text(text)
|
||||
|
||||
|
||||
def describe_token_counter(model: str | None = None) -> TokenCounterInfo:
|
||||
return resolve_token_counter(model).info
|
||||
|
||||
|
||||
def resolve_token_counter(model: str | None = None) -> ResolvedTokenCounter:
|
||||
return _resolve_token_counter(
|
||||
_normalize_model(model),
|
||||
_normalize_env('CLAW_CODE_TOKENIZER_PATH'),
|
||||
_normalize_env('CLAW_CODE_TOKENIZER_MODEL'),
|
||||
_normalize_env('CLAW_CODE_TOKENIZER_TRUST_REMOTE_CODE'),
|
||||
)
|
||||
|
||||
|
||||
def clear_token_counter_cache() -> None:
|
||||
_resolve_token_counter.cache_clear()
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _resolve_token_counter(
|
||||
normalized_model: str | None,
|
||||
explicit_path: str | None,
|
||||
explicit_model: str | None,
|
||||
trust_remote_code: str | None,
|
||||
) -> ResolvedTokenCounter:
|
||||
transformer_ref = explicit_path or explicit_model or normalized_model
|
||||
|
||||
if _prefer_tiktoken(normalized_model):
|
||||
counter = _try_build_tiktoken_counter(normalized_model)
|
||||
if counter is not None:
|
||||
return counter
|
||||
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
|
||||
if counter is not None:
|
||||
return counter
|
||||
else:
|
||||
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
|
||||
if counter is not None:
|
||||
return counter
|
||||
counter = _try_build_tiktoken_counter(normalized_model)
|
||||
if counter is not None:
|
||||
return counter
|
||||
|
||||
return ResolvedTokenCounter(
|
||||
info=TokenCounterInfo(
|
||||
backend='heuristic',
|
||||
source='len(text)/4 fallback',
|
||||
accurate=False,
|
||||
),
|
||||
count_text=_heuristic_count,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_model(model: str | None) -> str | None:
|
||||
if not isinstance(model, str):
|
||||
return None
|
||||
normalized = model.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _normalize_env(name: str) -> str | None:
|
||||
value = os.environ.get(name)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _prefer_tiktoken(model: str | None) -> bool:
|
||||
if model is None:
|
||||
return False
|
||||
lowered = model.lower()
|
||||
return (
|
||||
lowered.startswith('gpt')
|
||||
or lowered.startswith('o1')
|
||||
or lowered.startswith('o3')
|
||||
or lowered.startswith('o4')
|
||||
or 'gpt-4' in lowered
|
||||
or 'gpt-5' in lowered
|
||||
or 'openai' in lowered
|
||||
)
|
||||
|
||||
|
||||
def _try_build_tiktoken_counter(model: str | None) -> ResolvedTokenCounter | None:
|
||||
if model is None:
|
||||
return None
|
||||
try:
|
||||
import tiktoken
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
encoding = None
|
||||
encoding_name = None
|
||||
try:
|
||||
encoding = tiktoken.encoding_for_model(model)
|
||||
encoding_name = getattr(encoding, 'name', model)
|
||||
except KeyError:
|
||||
fallback_name = _tiktoken_fallback_encoding(model)
|
||||
if fallback_name is None:
|
||||
return None
|
||||
encoding = tiktoken.get_encoding(fallback_name)
|
||||
encoding_name = fallback_name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _count(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return len(encoding.encode_ordinary(text))
|
||||
|
||||
return ResolvedTokenCounter(
|
||||
info=TokenCounterInfo(
|
||||
backend='tiktoken',
|
||||
source=encoding_name or 'unknown',
|
||||
accurate=True,
|
||||
),
|
||||
count_text=_count,
|
||||
)
|
||||
|
||||
|
||||
def _tiktoken_fallback_encoding(model: str) -> str | None:
|
||||
lowered = model.lower()
|
||||
if lowered.startswith('gpt') or lowered.startswith('o1') or lowered.startswith('o3') or lowered.startswith('o4'):
|
||||
return 'o200k_base'
|
||||
if 'gpt-3.5' in lowered or 'gpt-4' in lowered:
|
||||
return 'cl100k_base'
|
||||
return None
|
||||
|
||||
|
||||
def _try_build_transformers_counter(
|
||||
model_ref: str | None,
|
||||
trust_remote_code: str | None,
|
||||
) -> ResolvedTokenCounter | None:
|
||||
if model_ref is None:
|
||||
return None
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
trust_remote_code_enabled = isinstance(trust_remote_code, str) and trust_remote_code.lower() in {
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on',
|
||||
}
|
||||
tokenizer = None
|
||||
try:
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_ref,
|
||||
local_files_only=True,
|
||||
use_fast=True,
|
||||
trust_remote_code=trust_remote_code_enabled,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _count(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
encoded: Any = tokenizer.encode(text, add_special_tokens=False)
|
||||
return len(encoded)
|
||||
|
||||
return ResolvedTokenCounter(
|
||||
info=TokenCounterInfo(
|
||||
backend='transformers',
|
||||
source=f'{model_ref} (local_files_only)',
|
||||
accurate=True,
|
||||
),
|
||||
count_text=_count,
|
||||
)
|
||||
|
||||
|
||||
def _heuristic_count(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, math.ceil(len(text) / 4))
|
||||
Reference in New Issue
Block a user