windcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
windcode/__init__.py
ADDED
windcode/__main__.py
ADDED
windcode/auth/store.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol, TypedDict, cast
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from platformdirs import user_data_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredentialStoreError(RuntimeError):
|
|
14
|
+
"""Raised when credentials cannot be persisted safely."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CredentialStore(Protocol):
|
|
18
|
+
def get(self, credential_id: str) -> str | None: ...
|
|
19
|
+
|
|
20
|
+
def set(self, credential_id: str, secret: str) -> None: ...
|
|
21
|
+
|
|
22
|
+
def delete(self, credential_id: str) -> None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiCredential(TypedDict):
|
|
26
|
+
type: str
|
|
27
|
+
key: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileCredentialStore:
|
|
31
|
+
"""OpenCode-style API credentials stored in the user's data directory."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
34
|
+
self.path = (path or user_data_path("windcode") / "auth.json").expanduser().resolve()
|
|
35
|
+
|
|
36
|
+
def _read(self) -> dict[str, ApiCredential]:
|
|
37
|
+
try:
|
|
38
|
+
raw: object = json.loads(self.path.read_text(encoding="utf-8"))
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return {}
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
raise CredentialStoreError("无法读取 Windcode 凭据文件") from exc
|
|
43
|
+
if not isinstance(raw, dict):
|
|
44
|
+
raise CredentialStoreError("Windcode 凭据文件格式无效")
|
|
45
|
+
values = cast(dict[object, object], raw)
|
|
46
|
+
credentials: dict[str, ApiCredential] = {}
|
|
47
|
+
for credential_id, value in values.items():
|
|
48
|
+
if not isinstance(credential_id, str):
|
|
49
|
+
continue
|
|
50
|
+
if not isinstance(value, dict):
|
|
51
|
+
continue
|
|
52
|
+
record = cast(dict[object, object], value)
|
|
53
|
+
credential_type = record.get("type")
|
|
54
|
+
key = record.get("key")
|
|
55
|
+
if credential_type == "api" and isinstance(key, str):
|
|
56
|
+
credentials[credential_id] = {"type": "api", "key": key}
|
|
57
|
+
return credentials
|
|
58
|
+
|
|
59
|
+
def get(self, credential_id: str) -> str | None:
|
|
60
|
+
credential = self._read().get(credential_id)
|
|
61
|
+
return credential["key"] if credential is not None else None
|
|
62
|
+
|
|
63
|
+
def set(self, credential_id: str, secret: str) -> None:
|
|
64
|
+
values = self._read()
|
|
65
|
+
values[credential_id] = {"type": "api", "key": secret}
|
|
66
|
+
self._write(values)
|
|
67
|
+
|
|
68
|
+
def delete(self, credential_id: str) -> None:
|
|
69
|
+
values = self._read()
|
|
70
|
+
if values.pop(credential_id, None) is not None:
|
|
71
|
+
self._write(values)
|
|
72
|
+
|
|
73
|
+
def _write(self, values: Mapping[str, ApiCredential]) -> None:
|
|
74
|
+
try:
|
|
75
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
76
|
+
os.chmod(self.path.parent, 0o700)
|
|
77
|
+
temporary = self.path.with_suffix(f"{self.path.suffix}.tmp-{uuid4().hex}")
|
|
78
|
+
try:
|
|
79
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
80
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
81
|
+
json.dump(dict(values), stream, ensure_ascii=True, indent=2, sort_keys=True)
|
|
82
|
+
stream.write("\n")
|
|
83
|
+
stream.flush()
|
|
84
|
+
os.fsync(stream.fileno())
|
|
85
|
+
temporary.replace(self.path)
|
|
86
|
+
os.chmod(self.path, 0o600)
|
|
87
|
+
finally:
|
|
88
|
+
temporary.unlink(missing_ok=True)
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise CredentialStoreError("无法写入 Windcode 凭据文件") from exc
|
windcode/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from windcode.config import AppConfig, ConfigError, PermissionMode, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class CLIOptions:
|
|
16
|
+
workspace: Path
|
|
17
|
+
config_file: Path | None
|
|
18
|
+
model: str | None
|
|
19
|
+
resume_session: str | None
|
|
20
|
+
permission_mode: PermissionMode | None
|
|
21
|
+
sandbox_enabled: bool | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="windcode", description="Terminal coding agent")
|
|
26
|
+
parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd())
|
|
27
|
+
parser.add_argument("--config", type=Path, help="explicit TOML configuration file")
|
|
28
|
+
parser.add_argument("--model", help="provider alias or model override")
|
|
29
|
+
parser.add_argument("--resume", metavar="SESSION_ID", help="resume an existing session")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--permission-mode",
|
|
32
|
+
choices=tuple(mode.value for mode in PermissionMode),
|
|
33
|
+
help="initial permission mode",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--sandbox",
|
|
37
|
+
action=argparse.BooleanOptionalAction,
|
|
38
|
+
default=None,
|
|
39
|
+
help="enable or explicitly disable the Linux tool sandbox",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_options(argv: Sequence[str] | None = None) -> CLIOptions:
|
|
45
|
+
namespace = build_parser().parse_args(argv)
|
|
46
|
+
raw_mode = namespace.permission_mode
|
|
47
|
+
return CLIOptions(
|
|
48
|
+
workspace=namespace.workspace.expanduser().resolve(),
|
|
49
|
+
config_file=namespace.config,
|
|
50
|
+
model=namespace.model,
|
|
51
|
+
resume_session=namespace.resume,
|
|
52
|
+
permission_mode=PermissionMode(raw_mode) if raw_mode is not None else None,
|
|
53
|
+
sandbox_enabled=namespace.sandbox,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_config(options: CLIOptions) -> AppConfig:
|
|
58
|
+
overrides: dict[str, object] = {}
|
|
59
|
+
if options.permission_mode is not None:
|
|
60
|
+
overrides["permission"] = {"mode": options.permission_mode.value}
|
|
61
|
+
if options.sandbox_enabled is not None:
|
|
62
|
+
overrides["sandbox"] = {"enabled": options.sandbox_enabled}
|
|
63
|
+
return load_config(
|
|
64
|
+
options.workspace,
|
|
65
|
+
explicit_file=options.config_file,
|
|
66
|
+
overrides=overrides,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_extensions_parser() -> argparse.ArgumentParser:
|
|
71
|
+
parser = argparse.ArgumentParser(prog="windcode extensions")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"action", choices=("list", "inspect", "install", "enable", "disable", "reload", "trust")
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("target", nargs="?")
|
|
76
|
+
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
77
|
+
parser.add_argument("--config", type=Path)
|
|
78
|
+
parser.add_argument("--enable", action="store_true", help="enable a plugin while installing")
|
|
79
|
+
parser.add_argument("--untrust", action="store_true", help="remove workspace trust")
|
|
80
|
+
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _run_extensions(argv: Sequence[str]) -> int:
|
|
85
|
+
namespace = build_extensions_parser().parse_args(argv)
|
|
86
|
+
workspace: Path = namespace.workspace.expanduser().resolve()
|
|
87
|
+
if not workspace.is_dir(): # noqa: ASYNC240 - CLI setup happens before concurrent work
|
|
88
|
+
raise ConfigError(workspace, "workspace is not a directory")
|
|
89
|
+
config = load_config(workspace, explicit_file=namespace.config)
|
|
90
|
+
from windcode.sdk import Windcode
|
|
91
|
+
|
|
92
|
+
async with Windcode.open(config, workspace=workspace) as client:
|
|
93
|
+
action = str(namespace.action)
|
|
94
|
+
target = None if namespace.target is None else str(namespace.target)
|
|
95
|
+
if action == "list":
|
|
96
|
+
value: object = await client.list_extensions()
|
|
97
|
+
elif action == "inspect":
|
|
98
|
+
if target is None:
|
|
99
|
+
raise ConfigError("extensions inspect", "TARGET is required")
|
|
100
|
+
value = await client.inspect_extension(target)
|
|
101
|
+
elif action == "install":
|
|
102
|
+
if target is None:
|
|
103
|
+
raise ConfigError("extensions install", "PATH is required")
|
|
104
|
+
value = await client.install_extension(Path(target), enable=bool(namespace.enable))
|
|
105
|
+
elif action in {"enable", "disable"}:
|
|
106
|
+
if target is None:
|
|
107
|
+
raise ConfigError(f"extensions {action}", "TARGET is required")
|
|
108
|
+
value = await client.set_extension_enabled(target, action == "enable")
|
|
109
|
+
elif action == "trust":
|
|
110
|
+
trust_target = (
|
|
111
|
+
workspace if target is None else Path(target).expanduser().resolve() # noqa: ASYNC240
|
|
112
|
+
)
|
|
113
|
+
value = await client.trust_extension_workspace(
|
|
114
|
+
trust_target, not bool(namespace.untrust)
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
value = await client.reload_extensions()
|
|
118
|
+
if namespace.json_output:
|
|
119
|
+
from dataclasses import asdict, is_dataclass
|
|
120
|
+
|
|
121
|
+
payload = (
|
|
122
|
+
[asdict(item) for item in value]
|
|
123
|
+
if isinstance(value, tuple)
|
|
124
|
+
else asdict(value)
|
|
125
|
+
if is_dataclass(value)
|
|
126
|
+
else value
|
|
127
|
+
)
|
|
128
|
+
print(json.dumps(payload, default=str, sort_keys=True))
|
|
129
|
+
else:
|
|
130
|
+
if isinstance(value, tuple):
|
|
131
|
+
for item in value:
|
|
132
|
+
print(getattr(item, "capability_id", str(item)))
|
|
133
|
+
else:
|
|
134
|
+
print(value)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run(argv: Sequence[str] | None = None) -> int:
|
|
139
|
+
arguments = tuple(sys.argv[1:] if argv is None else argv)
|
|
140
|
+
if arguments and arguments[0] == "extensions":
|
|
141
|
+
try:
|
|
142
|
+
return asyncio.run(_run_extensions(arguments[1:]))
|
|
143
|
+
except ConfigError as exc:
|
|
144
|
+
print(f"windcode: {exc}", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
except KeyError as exc:
|
|
147
|
+
print(f"windcode: {exc}", file=sys.stderr)
|
|
148
|
+
return 3
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
print(f"windcode: {exc}", file=sys.stderr)
|
|
151
|
+
return 4
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
print(f"windcode: {exc}", file=sys.stderr)
|
|
154
|
+
return 5
|
|
155
|
+
try:
|
|
156
|
+
options = parse_options(arguments)
|
|
157
|
+
if not options.workspace.is_dir():
|
|
158
|
+
raise ConfigError(options.workspace, "workspace is not a directory")
|
|
159
|
+
config = resolve_config(options)
|
|
160
|
+
except ConfigError as exc:
|
|
161
|
+
print(f"windcode: {exc}", file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
from windcode.tui import WindcodeApp
|
|
165
|
+
|
|
166
|
+
app = WindcodeApp(
|
|
167
|
+
config,
|
|
168
|
+
workspace=options.workspace,
|
|
169
|
+
model=options.model,
|
|
170
|
+
session_id=options.resume_session,
|
|
171
|
+
permission_mode=(
|
|
172
|
+
options.permission_mode.value if options.permission_mode is not None else None
|
|
173
|
+
),
|
|
174
|
+
config_file=options.config_file,
|
|
175
|
+
)
|
|
176
|
+
app.run()
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
181
|
+
raise SystemExit(run(argv))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from windcode.config.loader import ConfigError, load_config
|
|
2
|
+
from windcode.config.models import (
|
|
3
|
+
HARD_MAX_CONCURRENT_SUBAGENTS,
|
|
4
|
+
HARD_MAX_SUBAGENT_TASKS,
|
|
5
|
+
AppConfig,
|
|
6
|
+
BudgetConfig,
|
|
7
|
+
ContextConfig,
|
|
8
|
+
DelegationMode,
|
|
9
|
+
MemoryConfig,
|
|
10
|
+
PermissionConfig,
|
|
11
|
+
PermissionMode,
|
|
12
|
+
ProviderConfig,
|
|
13
|
+
ProviderProtocol,
|
|
14
|
+
SandboxConfig,
|
|
15
|
+
StorageConfig,
|
|
16
|
+
SubagentConfig,
|
|
17
|
+
TraceConfig,
|
|
18
|
+
)
|
|
19
|
+
from windcode.config.writer import save_memory_config, save_model_config
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"HARD_MAX_CONCURRENT_SUBAGENTS",
|
|
23
|
+
"HARD_MAX_SUBAGENT_TASKS",
|
|
24
|
+
"AppConfig",
|
|
25
|
+
"BudgetConfig",
|
|
26
|
+
"ConfigError",
|
|
27
|
+
"ContextConfig",
|
|
28
|
+
"DelegationMode",
|
|
29
|
+
"MemoryConfig",
|
|
30
|
+
"PermissionConfig",
|
|
31
|
+
"PermissionMode",
|
|
32
|
+
"ProviderConfig",
|
|
33
|
+
"ProviderProtocol",
|
|
34
|
+
"SandboxConfig",
|
|
35
|
+
"StorageConfig",
|
|
36
|
+
"SubagentConfig",
|
|
37
|
+
"TraceConfig",
|
|
38
|
+
"load_config",
|
|
39
|
+
"save_memory_config",
|
|
40
|
+
"save_model_config",
|
|
41
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_config_path
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from windcode.config.models import AppConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
def __init__(self, source: Path | str, message: str) -> None:
|
|
17
|
+
self.source = source
|
|
18
|
+
super().__init__(f"{source}: {message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _deep_merge(base: dict[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
|
|
22
|
+
merged = copy.deepcopy(base)
|
|
23
|
+
for key, value in overlay.items():
|
|
24
|
+
previous = merged.get(key)
|
|
25
|
+
if isinstance(previous, dict) and isinstance(value, Mapping):
|
|
26
|
+
merged[key] = _deep_merge(
|
|
27
|
+
cast(dict[str, Any], previous), cast(Mapping[str, Any], value)
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
merged[key] = copy.deepcopy(value)
|
|
31
|
+
return merged
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_toml(path: Path, *, required: bool) -> dict[str, Any]:
|
|
35
|
+
if not path.exists():
|
|
36
|
+
if required:
|
|
37
|
+
raise ConfigError(path, "configuration file does not exist")
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
with path.open("rb") as stream:
|
|
41
|
+
return tomllib.load(stream)
|
|
42
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
43
|
+
raise ConfigError(path, str(exc)) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config(
|
|
47
|
+
workspace: Path,
|
|
48
|
+
*,
|
|
49
|
+
user_file: Path | None = None,
|
|
50
|
+
project_file: Path | None = None,
|
|
51
|
+
explicit_file: Path | None = None,
|
|
52
|
+
overrides: Mapping[str, Any] | None = None,
|
|
53
|
+
) -> AppConfig:
|
|
54
|
+
workspace = workspace.expanduser().resolve()
|
|
55
|
+
default_user = user_config_path("windcode") / "config.toml"
|
|
56
|
+
default_project = workspace / ".windcode" / "config.toml"
|
|
57
|
+
layers: list[tuple[Path | str, dict[str, Any]]] = [
|
|
58
|
+
(
|
|
59
|
+
user_file or default_user,
|
|
60
|
+
_read_toml(user_file or default_user, required=user_file is not None),
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
project_file or default_project,
|
|
64
|
+
_read_toml(project_file or default_project, required=project_file is not None),
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
if explicit_file is not None:
|
|
68
|
+
layers.append((explicit_file, _read_toml(explicit_file, required=True)))
|
|
69
|
+
if overrides is not None:
|
|
70
|
+
layers.append(("explicit overrides", dict(overrides)))
|
|
71
|
+
|
|
72
|
+
merged: dict[str, Any] = {}
|
|
73
|
+
project_mcp_servers: set[str] = set()
|
|
74
|
+
disabled_aliases: set[str] = set()
|
|
75
|
+
last_source: Path | str = "built-in defaults"
|
|
76
|
+
for layer_index, (source, layer) in enumerate(layers):
|
|
77
|
+
if layer:
|
|
78
|
+
if layer_index == 1:
|
|
79
|
+
raw_extensions = layer.get("extensions")
|
|
80
|
+
if isinstance(raw_extensions, Mapping):
|
|
81
|
+
extension_values = cast(Mapping[object, object], raw_extensions)
|
|
82
|
+
raw_mcp = extension_values.get("mcp_servers")
|
|
83
|
+
if isinstance(raw_mcp, Mapping):
|
|
84
|
+
mcp_values = cast(Mapping[object, object], raw_mcp)
|
|
85
|
+
project_mcp_servers.update(str(server_id) for server_id in mcp_values)
|
|
86
|
+
raw_disabled = layer.get("disabled_providers", [])
|
|
87
|
+
raw_enabled = layer.get("enabled_providers", [])
|
|
88
|
+
if not isinstance(raw_disabled, list) or not isinstance(raw_enabled, list):
|
|
89
|
+
raise ConfigError(source, "provider enable/disable lists must be arrays")
|
|
90
|
+
disabled_aliases.update(str(alias) for alias in cast(list[object], raw_disabled))
|
|
91
|
+
disabled_aliases.difference_update(
|
|
92
|
+
str(alias) for alias in cast(list[object], raw_enabled)
|
|
93
|
+
)
|
|
94
|
+
merged = _deep_merge(merged, layer)
|
|
95
|
+
last_source = source
|
|
96
|
+
merged.pop("disabled_providers", None)
|
|
97
|
+
merged.pop("enabled_providers", None)
|
|
98
|
+
if project_mcp_servers:
|
|
99
|
+
raw_extensions = merged.setdefault("extensions", {})
|
|
100
|
+
if isinstance(raw_extensions, dict):
|
|
101
|
+
raw_extensions["project_mcp_servers"] = sorted(project_mcp_servers)
|
|
102
|
+
if disabled_aliases and isinstance(merged.get("providers"), dict):
|
|
103
|
+
providers = cast(dict[str, Any], merged["providers"])
|
|
104
|
+
for alias in disabled_aliases:
|
|
105
|
+
providers.pop(alias, None)
|
|
106
|
+
if merged.get("primary_provider") in disabled_aliases:
|
|
107
|
+
merged.pop("primary_provider", None)
|
|
108
|
+
fallback = merged.get("fallback_chain")
|
|
109
|
+
if isinstance(fallback, list):
|
|
110
|
+
fallback_values = cast(list[object], fallback)
|
|
111
|
+
merged["fallback_chain"] = [
|
|
112
|
+
alias for alias in fallback_values if str(alias) not in disabled_aliases
|
|
113
|
+
]
|
|
114
|
+
try:
|
|
115
|
+
return AppConfig.model_validate(merged)
|
|
116
|
+
except ValidationError as exc:
|
|
117
|
+
raise ConfigError(last_source, str(exc)) from exc
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Annotated, Literal, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StrictModel(BaseModel):
|
|
10
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderProtocol(StrEnum):
|
|
14
|
+
ANTHROPIC_MESSAGES = "anthropic_messages"
|
|
15
|
+
OPENAI_RESPONSES = "openai_responses"
|
|
16
|
+
OPENAI_COMPATIBLE = "openai_compatible"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProviderConfig(StrictModel):
|
|
20
|
+
protocol: ProviderProtocol
|
|
21
|
+
model: str = Field(min_length=1)
|
|
22
|
+
provider_id: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9][a-z0-9_-]*$")
|
|
23
|
+
api_key_env: str | None = Field(default=None, min_length=1, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
24
|
+
credential_id: str | None = Field(
|
|
25
|
+
default=None, min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$"
|
|
26
|
+
)
|
|
27
|
+
base_url: str | None = None
|
|
28
|
+
|
|
29
|
+
@model_validator(mode="after")
|
|
30
|
+
def validate_credentials(self) -> Self:
|
|
31
|
+
if self.api_key_env is None and self.credential_id is None:
|
|
32
|
+
raise ValueError("provider requires api_key_env or credential_id")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BudgetConfig(StrictModel):
|
|
37
|
+
max_model_steps: int = Field(default=40, ge=1)
|
|
38
|
+
max_tool_calls: int = Field(default=100, ge=1)
|
|
39
|
+
max_runtime_seconds: float = Field(default=1800.0, gt=0)
|
|
40
|
+
shell_timeout_seconds: float = Field(default=120.0, gt=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
HARD_MAX_SUBAGENT_TASKS = 16
|
|
44
|
+
HARD_MAX_CONCURRENT_SUBAGENTS = 8
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DelegationMode(StrEnum):
|
|
48
|
+
EXPLICIT = "explicit"
|
|
49
|
+
PROACTIVE = "proactive"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SubagentConfig(StrictModel):
|
|
53
|
+
mode: DelegationMode = DelegationMode.EXPLICIT
|
|
54
|
+
max_tasks: int = Field(default=8, ge=1, le=HARD_MAX_SUBAGENT_TASKS)
|
|
55
|
+
max_concurrent: int = Field(default=4, ge=1, le=HARD_MAX_CONCURRENT_SUBAGENTS)
|
|
56
|
+
max_model_steps: int = Field(default=20, ge=1)
|
|
57
|
+
max_tool_calls: int = Field(default=50, ge=1)
|
|
58
|
+
max_runtime_seconds: float = Field(default=900.0, gt=0)
|
|
59
|
+
max_total_model_steps: int = Field(default=80, ge=1)
|
|
60
|
+
max_total_tool_calls: int = Field(default=200, ge=1)
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def validate_aggregate_limits(self) -> Self:
|
|
64
|
+
if self.max_concurrent > self.max_tasks:
|
|
65
|
+
raise ValueError("max_concurrent cannot exceed max_tasks")
|
|
66
|
+
if self.max_total_model_steps < self.max_model_steps:
|
|
67
|
+
raise ValueError("max_total_model_steps cannot be below max_model_steps")
|
|
68
|
+
if self.max_total_tool_calls < self.max_tool_calls:
|
|
69
|
+
raise ValueError("max_total_tool_calls cannot be below max_tool_calls")
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PermissionMode(StrEnum):
|
|
74
|
+
PLAN = "plan"
|
|
75
|
+
DEFAULT = "default"
|
|
76
|
+
ACCEPT_EDITS = "accept_edits"
|
|
77
|
+
FULL_ACCESS = "full_access"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PermissionConfig(StrictModel):
|
|
81
|
+
mode: PermissionMode = PermissionMode.DEFAULT
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SandboxConfig(StrictModel):
|
|
85
|
+
enabled: bool = True
|
|
86
|
+
network_enabled: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ContextConfig(StrictModel):
|
|
90
|
+
window_tokens: int = Field(default=128_000, ge=1_024)
|
|
91
|
+
compaction_threshold: float = Field(default=0.8, gt=0.0, lt=1.0)
|
|
92
|
+
preserve_recent_turns: int = Field(default=8, ge=1)
|
|
93
|
+
max_tool_result_chars: int = Field(default=20_000, ge=1_000)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TraceConfig(StrictModel):
|
|
97
|
+
enabled: bool = True
|
|
98
|
+
include_tool_arguments: bool = False
|
|
99
|
+
include_transient_events: bool = False
|
|
100
|
+
retention_days: int = Field(default=14, ge=1, le=3_650)
|
|
101
|
+
max_total_mb: int = Field(default=100, ge=1, le=100_000)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StorageConfig(StrictModel):
|
|
105
|
+
project_state_root: str | None = None
|
|
106
|
+
user_storage_root: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MemoryConfig(StrictModel):
|
|
110
|
+
enabled: bool = True
|
|
111
|
+
extraction_enabled: bool = True
|
|
112
|
+
recall_limit: int = Field(default=5, ge=1, le=20)
|
|
113
|
+
recall_max_chars: int = Field(default=12_000, ge=1_000, le=100_000)
|
|
114
|
+
baseline_max_records: int = Field(default=30, ge=1, le=200)
|
|
115
|
+
baseline_max_chars: int = Field(default=6_000, ge=1_000, le=100_000)
|
|
116
|
+
extraction_max_chars: int = Field(default=20_000, ge=1_000, le=200_000)
|
|
117
|
+
extraction_max_output_tokens: int = Field(default=600, ge=128, le=4_096)
|
|
118
|
+
candidate_retention_days: int = Field(default=90, ge=1, le=3_650)
|
|
119
|
+
stale_after_days: int = Field(default=30, ge=1, le=3_650)
|
|
120
|
+
user_profile_enabled: bool = True
|
|
121
|
+
project_knowledge_enabled: bool = True
|
|
122
|
+
experience_enabled: bool = True
|
|
123
|
+
sop_enabled: bool = True
|
|
124
|
+
reference_enabled: bool = True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class EnvironmentReference(StrictModel):
|
|
128
|
+
env: str = Field(min_length=1, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CredentialReference(StrictModel):
|
|
132
|
+
credential: str = Field(min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SecretReference = Annotated[
|
|
136
|
+
EnvironmentReference | CredentialReference, Field(union_mode="left_to_right")
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class McpStdioConfig(StrictModel):
|
|
141
|
+
transport: Literal["stdio"] = "stdio"
|
|
142
|
+
enabled: bool = Field(default=True, validation_alias=AliasChoices("enable", "enabled"))
|
|
143
|
+
command: str = Field(min_length=1)
|
|
144
|
+
args: tuple[str, ...] = ()
|
|
145
|
+
cwd: str | None = Field(default=None, min_length=1)
|
|
146
|
+
env: dict[str, SecretReference] = Field(default_factory=dict[str, SecretReference])
|
|
147
|
+
required: bool = False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class McpHttpConfig(StrictModel):
|
|
151
|
+
transport: Literal["streamable_http"]
|
|
152
|
+
enabled: bool = Field(default=True, validation_alias=AliasChoices("enable", "enabled"))
|
|
153
|
+
url: str = Field(min_length=1, pattern=r"^https?://")
|
|
154
|
+
headers: dict[str, SecretReference] = Field(default_factory=dict[str, SecretReference])
|
|
155
|
+
required: bool = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
McpServerConfig = Annotated[McpStdioConfig | McpHttpConfig, Field(union_mode="left_to_right")]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ExtensionConfig(StrictModel):
|
|
162
|
+
enabled: bool = False
|
|
163
|
+
direct_tool_limit: int = Field(default=24, ge=0, le=256)
|
|
164
|
+
connect_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
|
|
165
|
+
call_timeout_seconds: float = Field(default=60.0, gt=0, le=3600)
|
|
166
|
+
hook_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
|
|
167
|
+
max_metadata_bytes: int = Field(default=65_536, ge=1_024, le=1_048_576)
|
|
168
|
+
max_content_bytes: int = Field(default=1_048_576, ge=1_024, le=16_777_216)
|
|
169
|
+
max_scan_depth: int = Field(default=8, ge=1, le=32)
|
|
170
|
+
max_scan_entries: int = Field(default=10_000, ge=1, le=100_000)
|
|
171
|
+
skill_roots: tuple[str, ...] = ()
|
|
172
|
+
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict[str, McpServerConfig])
|
|
173
|
+
project_mcp_servers: frozenset[str] = Field(default_factory=frozenset, exclude=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AppConfig(StrictModel):
|
|
177
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=dict[str, ProviderConfig])
|
|
178
|
+
primary_provider: str | None = None
|
|
179
|
+
fallback_chain: tuple[str, ...] = ()
|
|
180
|
+
budgets: BudgetConfig = Field(default_factory=BudgetConfig)
|
|
181
|
+
permission: PermissionConfig = Field(default_factory=PermissionConfig)
|
|
182
|
+
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
183
|
+
context: ContextConfig = Field(default_factory=ContextConfig)
|
|
184
|
+
trace: TraceConfig = Field(default_factory=TraceConfig)
|
|
185
|
+
storage: StorageConfig = Field(default_factory=StorageConfig)
|
|
186
|
+
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
|
187
|
+
subagents: SubagentConfig = Field(default_factory=SubagentConfig)
|
|
188
|
+
extensions: ExtensionConfig = Field(default_factory=ExtensionConfig)
|
|
189
|
+
|
|
190
|
+
@model_validator(mode="after")
|
|
191
|
+
def validate_provider_chain(self) -> Self:
|
|
192
|
+
if self.primary_provider is None:
|
|
193
|
+
if self.fallback_chain:
|
|
194
|
+
raise ValueError("fallback_chain requires primary_provider")
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
chain = (self.primary_provider, *self.fallback_chain)
|
|
198
|
+
missing = [name for name in chain if name not in self.providers]
|
|
199
|
+
if missing:
|
|
200
|
+
raise ValueError(f"provider chain references unknown providers: {', '.join(missing)}")
|
|
201
|
+
if len(set(chain)) != len(chain):
|
|
202
|
+
raise ValueError("provider chain contains a duplicate or cycle")
|
|
203
|
+
return self
|