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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from windcode.extensions.models import (
|
|
8
|
+
ActivationState,
|
|
9
|
+
CapabilityKind,
|
|
10
|
+
CapabilityRecord,
|
|
11
|
+
Diagnostic,
|
|
12
|
+
DiagnosticSeverity,
|
|
13
|
+
DiagnosticStage,
|
|
14
|
+
ExtensionScope,
|
|
15
|
+
ExtensionSource,
|
|
16
|
+
PermissionRequirement,
|
|
17
|
+
capability_id,
|
|
18
|
+
)
|
|
19
|
+
from windcode.extensions.skills.parser import parse_skill_metadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class DiscoveryRoot:
|
|
24
|
+
path: Path
|
|
25
|
+
scope: ExtensionScope
|
|
26
|
+
trusted: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class DiscoveryResult:
|
|
31
|
+
records: tuple[CapabilityRecord, ...]
|
|
32
|
+
definitions: dict[str, Any]
|
|
33
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover_skills(
|
|
37
|
+
roots: tuple[DiscoveryRoot, ...], *, max_metadata_bytes: int = 65_536
|
|
38
|
+
) -> DiscoveryResult:
|
|
39
|
+
candidates: list[tuple[CapabilityRecord, Any]] = []
|
|
40
|
+
diagnostics: list[Diagnostic] = []
|
|
41
|
+
for root in sorted(roots, key=lambda item: (item.scope.value, str(item.path))):
|
|
42
|
+
if not root.path.exists():
|
|
43
|
+
continue
|
|
44
|
+
for directory in sorted(
|
|
45
|
+
(path for path in root.path.iterdir() if path.is_dir()), key=lambda path: path.name
|
|
46
|
+
):
|
|
47
|
+
source = ExtensionSource(root.scope, directory)
|
|
48
|
+
try:
|
|
49
|
+
metadata = parse_skill_metadata(directory, max_bytes=max_metadata_bytes)
|
|
50
|
+
stable_id = capability_id(CapabilityKind.SKILL, metadata.name)
|
|
51
|
+
record = CapabilityRecord(
|
|
52
|
+
stable_id,
|
|
53
|
+
metadata.name,
|
|
54
|
+
CapabilityKind.SKILL,
|
|
55
|
+
source,
|
|
56
|
+
trusted=root.trusted,
|
|
57
|
+
activation=ActivationState.AVAILABLE
|
|
58
|
+
if root.trusted
|
|
59
|
+
else ActivationState.INACTIVE,
|
|
60
|
+
permissions=PermissionRequirement(filesystem_read=True),
|
|
61
|
+
)
|
|
62
|
+
candidates.append((record, metadata))
|
|
63
|
+
except (OSError, ValueError) as exc:
|
|
64
|
+
diagnostics.append(
|
|
65
|
+
Diagnostic(
|
|
66
|
+
DiagnosticStage.PARSE,
|
|
67
|
+
DiagnosticSeverity.ERROR,
|
|
68
|
+
"invalid_skill",
|
|
69
|
+
str(exc),
|
|
70
|
+
source.source_id,
|
|
71
|
+
"Fix the Skill metadata or remove this source.",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
grouped: dict[tuple[CapabilityKind, str], list[tuple[CapabilityRecord, Any]]] = {}
|
|
76
|
+
for candidate in candidates:
|
|
77
|
+
record = candidate[0]
|
|
78
|
+
grouped.setdefault((record.kind, record.public_name), []).append(candidate)
|
|
79
|
+
records: list[CapabilityRecord] = []
|
|
80
|
+
definitions: dict[str, Any] = {}
|
|
81
|
+
for key in sorted(grouped, key=lambda item: (item[0].value, item[1])):
|
|
82
|
+
items = sorted(grouped[key], key=lambda item: item[0].sort_key)
|
|
83
|
+
by_scope: dict[ExtensionScope, list[tuple[CapabilityRecord, Any]]] = {}
|
|
84
|
+
for item in items:
|
|
85
|
+
by_scope.setdefault(item[0].source.scope, []).append(item)
|
|
86
|
+
conflicted = {scope for scope, values in by_scope.items() if len(values) > 1}
|
|
87
|
+
eligible = [item for item in items if item[0].source.scope not in conflicted]
|
|
88
|
+
winner = eligible[-1] if eligible else None
|
|
89
|
+
for item in items:
|
|
90
|
+
record, definition = item
|
|
91
|
+
if record.source.scope in conflicted:
|
|
92
|
+
diagnostic = Diagnostic(
|
|
93
|
+
DiagnosticStage.MERGE,
|
|
94
|
+
DiagnosticSeverity.ERROR,
|
|
95
|
+
"same_scope_conflict",
|
|
96
|
+
f"duplicate {record.kind.value} named {record.public_name}",
|
|
97
|
+
record.source.source_id,
|
|
98
|
+
"Remove or rename one same-scope definition.",
|
|
99
|
+
)
|
|
100
|
+
diagnostics.append(diagnostic)
|
|
101
|
+
records.append(
|
|
102
|
+
CapabilityRecord(
|
|
103
|
+
record.capability_id,
|
|
104
|
+
record.public_name,
|
|
105
|
+
record.kind,
|
|
106
|
+
record.source,
|
|
107
|
+
record.enabled,
|
|
108
|
+
record.trusted,
|
|
109
|
+
record.required,
|
|
110
|
+
ActivationState.FAILED,
|
|
111
|
+
record.permissions,
|
|
112
|
+
None,
|
|
113
|
+
(diagnostic,),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
elif winner is not None and record is not winner[0]:
|
|
117
|
+
records.append(
|
|
118
|
+
CapabilityRecord(
|
|
119
|
+
record.capability_id,
|
|
120
|
+
record.public_name,
|
|
121
|
+
record.kind,
|
|
122
|
+
record.source,
|
|
123
|
+
record.enabled,
|
|
124
|
+
record.trusted,
|
|
125
|
+
record.required,
|
|
126
|
+
ActivationState.INACTIVE,
|
|
127
|
+
record.permissions,
|
|
128
|
+
winner[0].source.source_id,
|
|
129
|
+
record.diagnostics,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
records.append(record)
|
|
134
|
+
definitions[record.capability_id] = definition
|
|
135
|
+
return DiscoveryResult(
|
|
136
|
+
tuple(sorted(records, key=lambda item: item.sort_key)),
|
|
137
|
+
definitions,
|
|
138
|
+
tuple(sorted(diagnostics, key=lambda item: (item.source_id, item.category))),
|
|
139
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from windcode.domain.events import ExtensionEvent
|
|
6
|
+
|
|
7
|
+
EXTENSION_ACTIONS = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"discovery_completed",
|
|
10
|
+
"diagnostic",
|
|
11
|
+
"snapshot_reloaded",
|
|
12
|
+
"capability_activated",
|
|
13
|
+
"skill_loaded",
|
|
14
|
+
"mcp_connecting",
|
|
15
|
+
"mcp_connected",
|
|
16
|
+
"mcp_called",
|
|
17
|
+
"mcp_closed",
|
|
18
|
+
"hook_started",
|
|
19
|
+
"hook_finished",
|
|
20
|
+
"hook_rejected",
|
|
21
|
+
"plugin_state_changed",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extension_event(
|
|
27
|
+
*,
|
|
28
|
+
event_id: str,
|
|
29
|
+
session_id: str,
|
|
30
|
+
run_id: str,
|
|
31
|
+
turn: int,
|
|
32
|
+
action: str,
|
|
33
|
+
snapshot_generation: int,
|
|
34
|
+
extension_id: str,
|
|
35
|
+
source_id: str,
|
|
36
|
+
status: str = "",
|
|
37
|
+
server_id: str | None = None,
|
|
38
|
+
hook_id: str | None = None,
|
|
39
|
+
call_id: str | None = None,
|
|
40
|
+
details: dict[str, Any] | None = None,
|
|
41
|
+
) -> ExtensionEvent:
|
|
42
|
+
if action not in EXTENSION_ACTIONS:
|
|
43
|
+
raise ValueError(f"unknown extension event action: {action}")
|
|
44
|
+
return ExtensionEvent(
|
|
45
|
+
event_id=event_id,
|
|
46
|
+
session_id=session_id,
|
|
47
|
+
run_id=run_id,
|
|
48
|
+
turn=turn,
|
|
49
|
+
action=action,
|
|
50
|
+
snapshot_generation=snapshot_generation,
|
|
51
|
+
extension_id=extension_id,
|
|
52
|
+
source_id=source_id,
|
|
53
|
+
status=status,
|
|
54
|
+
server_id=server_id,
|
|
55
|
+
hook_id=hook_id,
|
|
56
|
+
call_id=call_id,
|
|
57
|
+
details={} if details is None else dict(details),
|
|
58
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from windcode.domain.tools import ToolEffect
|
|
7
|
+
from windcode.extensions.hooks.executor import HookExecutor
|
|
8
|
+
from windcode.extensions.hooks.models import HookContext, HookDefinition, HookOutcome
|
|
9
|
+
|
|
10
|
+
HookObserver = Callable[[str, HookDefinition, HookContext, HookOutcome | None], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookDispatcher:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
hooks: tuple[HookDefinition, ...],
|
|
17
|
+
executor: HookExecutor,
|
|
18
|
+
observer: HookObserver | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.hooks = tuple(sorted(hooks, key=lambda item: item.sort_key))
|
|
21
|
+
self.executor = executor
|
|
22
|
+
self._active_sources: set[str] = set()
|
|
23
|
+
self._background: set[asyncio.Task[HookOutcome]] = set()
|
|
24
|
+
self._closed = False
|
|
25
|
+
self.observer = observer
|
|
26
|
+
|
|
27
|
+
async def dispatch(self, context: HookContext, *, background: bool = False) -> HookOutcome:
|
|
28
|
+
if self._closed:
|
|
29
|
+
raise RuntimeError("Hook dispatcher is closed")
|
|
30
|
+
matched = tuple(
|
|
31
|
+
hook
|
|
32
|
+
for hook in self.hooks
|
|
33
|
+
if hook.matcher.matches(context)
|
|
34
|
+
and context.source_id != f"hook:{hook.source_id}/{hook.hook_id}"
|
|
35
|
+
)
|
|
36
|
+
if background:
|
|
37
|
+
required = tuple(hook for hook in matched if hook.required)
|
|
38
|
+
for hook in required:
|
|
39
|
+
try:
|
|
40
|
+
await self._run_one(hook, context)
|
|
41
|
+
except (TimeoutError, RuntimeError, ValueError, OSError) as exc:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"required Hook failed: {hook.source_id}/{hook.hook_id}: {exc}"
|
|
44
|
+
) from exc
|
|
45
|
+
for hook in matched:
|
|
46
|
+
if hook.required:
|
|
47
|
+
continue
|
|
48
|
+
task = asyncio.create_task(self._run_one(hook, context))
|
|
49
|
+
self._background.add(task)
|
|
50
|
+
return HookOutcome()
|
|
51
|
+
rejected: str | None = None
|
|
52
|
+
effects: set[ToolEffect] = set()
|
|
53
|
+
notifications: list[str] = []
|
|
54
|
+
prompts: list[tuple[str, str]] = []
|
|
55
|
+
diagnostics: list[str] = []
|
|
56
|
+
for hook in matched:
|
|
57
|
+
try:
|
|
58
|
+
outcome = await self._run_one(hook, context)
|
|
59
|
+
except (TimeoutError, RuntimeError, ValueError, OSError) as exc:
|
|
60
|
+
diagnostics.append(f"{hook.source_id}/{hook.hook_id}: {exc}")
|
|
61
|
+
if hook.required:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"required Hook failed: {hook.source_id}/{hook.hook_id}: {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
if hook.decision_making:
|
|
66
|
+
rejected = rejected or "security Hook failed closed"
|
|
67
|
+
continue
|
|
68
|
+
rejected = rejected or outcome.rejected
|
|
69
|
+
effects.update(outcome.additional_effects)
|
|
70
|
+
notifications.extend(outcome.notifications)
|
|
71
|
+
prompts.extend(outcome.sourced_prompts)
|
|
72
|
+
diagnostics.extend(outcome.diagnostics)
|
|
73
|
+
return HookOutcome(
|
|
74
|
+
rejected,
|
|
75
|
+
frozenset(effects),
|
|
76
|
+
tuple(notifications),
|
|
77
|
+
tuple(prompts),
|
|
78
|
+
tuple(diagnostics),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def _run_one(self, hook: HookDefinition, context: HookContext) -> HookOutcome:
|
|
82
|
+
recursion_key = f"{hook.source_id}/{hook.hook_id}"
|
|
83
|
+
if recursion_key in self._active_sources:
|
|
84
|
+
raise RuntimeError("recursive Hook invocation blocked")
|
|
85
|
+
self._active_sources.add(recursion_key)
|
|
86
|
+
try:
|
|
87
|
+
if self.observer is not None:
|
|
88
|
+
await self.observer("started", hook, context, None)
|
|
89
|
+
async with asyncio.timeout(hook.timeout_seconds):
|
|
90
|
+
outcome = await self.executor.execute(hook, context)
|
|
91
|
+
if self.observer is not None:
|
|
92
|
+
await self.observer(
|
|
93
|
+
"rejected" if outcome.rejected is not None else "finished",
|
|
94
|
+
hook,
|
|
95
|
+
context,
|
|
96
|
+
outcome,
|
|
97
|
+
)
|
|
98
|
+
return outcome
|
|
99
|
+
finally:
|
|
100
|
+
self._active_sources.discard(recursion_key)
|
|
101
|
+
|
|
102
|
+
async def aclose(self) -> None:
|
|
103
|
+
self._closed = True
|
|
104
|
+
tasks = tuple(self._background)
|
|
105
|
+
if tasks:
|
|
106
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
107
|
+
self._background.clear()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from windcode.domain.messages import SourcedContextMessage
|
|
6
|
+
from windcode.extensions.hooks.models import (
|
|
7
|
+
HookContext,
|
|
8
|
+
HookDefinition,
|
|
9
|
+
HookOutcome,
|
|
10
|
+
NotifyAction,
|
|
11
|
+
PromptAction,
|
|
12
|
+
RejectAction,
|
|
13
|
+
TightenAction,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CommandRunner = Callable[[str, str, HookContext], Awaitable[str]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HookExecutor:
|
|
20
|
+
def __init__(self, command_runner: CommandRunner | None = None) -> None:
|
|
21
|
+
self.command_runner = command_runner
|
|
22
|
+
self.notifications: list[tuple[str, str]] = []
|
|
23
|
+
self.context_messages: list[SourcedContextMessage] = []
|
|
24
|
+
|
|
25
|
+
def drain_context(self) -> tuple[SourcedContextMessage, ...]:
|
|
26
|
+
messages = tuple(self.context_messages)
|
|
27
|
+
self.context_messages.clear()
|
|
28
|
+
return messages
|
|
29
|
+
|
|
30
|
+
async def execute(self, hook: HookDefinition, context: HookContext) -> HookOutcome:
|
|
31
|
+
action = hook.action
|
|
32
|
+
if isinstance(action, NotifyAction):
|
|
33
|
+
message = action.message[: hook.output_limit]
|
|
34
|
+
self.notifications.append((hook.source_id, message))
|
|
35
|
+
return HookOutcome(notifications=(message,))
|
|
36
|
+
if isinstance(action, PromptAction):
|
|
37
|
+
content = action.content[: hook.output_limit]
|
|
38
|
+
self.context_messages.append(SourcedContextMessage(hook.source_id, content))
|
|
39
|
+
return HookOutcome(sourced_prompts=((hook.source_id, content),))
|
|
40
|
+
if isinstance(action, RejectAction):
|
|
41
|
+
return HookOutcome(rejected=action.reason[: hook.output_limit])
|
|
42
|
+
if isinstance(action, TightenAction):
|
|
43
|
+
return HookOutcome(additional_effects=action.effects)
|
|
44
|
+
if self.command_runner is None:
|
|
45
|
+
raise RuntimeError("Hook command runner is not configured")
|
|
46
|
+
output = await self.command_runner(
|
|
47
|
+
action.command, f"hook:{hook.source_id}/{hook.hook_id}", context
|
|
48
|
+
)
|
|
49
|
+
return HookOutcome(notifications=(output[: hook.output_limit],))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from windcode.domain.tools import ToolEffect
|
|
8
|
+
from windcode.extensions.hooks.models import (
|
|
9
|
+
DECISION_EVENTS,
|
|
10
|
+
CommandAction,
|
|
11
|
+
HookDefinition,
|
|
12
|
+
HookEvent,
|
|
13
|
+
HookMatcher,
|
|
14
|
+
NotifyAction,
|
|
15
|
+
PromptAction,
|
|
16
|
+
RejectAction,
|
|
17
|
+
TightenAction,
|
|
18
|
+
)
|
|
19
|
+
from windcode.extensions.models import normalize_id
|
|
20
|
+
from windcode.extensions.paths import read_bounded
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_hook_definition(
|
|
24
|
+
root: Path, relative_path: str, *, source_id: str, max_bytes: int = 65_536
|
|
25
|
+
) -> HookDefinition:
|
|
26
|
+
try:
|
|
27
|
+
raw = cast(
|
|
28
|
+
dict[str, object],
|
|
29
|
+
tomllib.loads(read_bounded(root, relative_path, max_bytes=max_bytes).decode("utf-8")),
|
|
30
|
+
)
|
|
31
|
+
except (UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
32
|
+
raise ValueError(f"invalid Hook definition: {exc}") from exc
|
|
33
|
+
allowed = {
|
|
34
|
+
"id",
|
|
35
|
+
"event",
|
|
36
|
+
"tool_id",
|
|
37
|
+
"status",
|
|
38
|
+
"priority",
|
|
39
|
+
"timeout_seconds",
|
|
40
|
+
"output_limit",
|
|
41
|
+
"required",
|
|
42
|
+
"action",
|
|
43
|
+
}
|
|
44
|
+
unknown = set(raw) - allowed
|
|
45
|
+
if unknown:
|
|
46
|
+
raise ValueError(f"unknown Hook fields: {', '.join(sorted(unknown))}")
|
|
47
|
+
hook_id = normalize_id(str(raw.get("id", "")))
|
|
48
|
+
event = HookEvent(str(raw.get("event", "")))
|
|
49
|
+
action_raw = raw.get("action")
|
|
50
|
+
if not isinstance(action_raw, dict):
|
|
51
|
+
raise ValueError("Hook action must be a table")
|
|
52
|
+
action_data = cast(dict[str, object], action_raw)
|
|
53
|
+
action_type = str(action_data.get("type", ""))
|
|
54
|
+
if action_type == "notify" and set(action_data) == {"type", "message"}:
|
|
55
|
+
action = NotifyAction(str(action_data["message"]))
|
|
56
|
+
elif action_type == "prompt" and set(action_data) == {"type", "content"}:
|
|
57
|
+
action = PromptAction(str(action_data["content"]))
|
|
58
|
+
elif action_type == "command" and set(action_data) == {"type", "command"}:
|
|
59
|
+
action = CommandAction(str(action_data["command"]))
|
|
60
|
+
elif action_type == "reject" and set(action_data) == {"type", "reason"}:
|
|
61
|
+
action = RejectAction(str(action_data["reason"]))
|
|
62
|
+
elif action_type == "tighten" and set(action_data) == {"type", "effects"}:
|
|
63
|
+
effects = action_data["effects"]
|
|
64
|
+
if not isinstance(effects, list):
|
|
65
|
+
raise ValueError("tighten effects must be an array")
|
|
66
|
+
effect_values = cast(list[object], effects)
|
|
67
|
+
action = TightenAction(frozenset(ToolEffect(str(value)) for value in effect_values))
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"invalid Hook action: {action_type}")
|
|
70
|
+
if isinstance(action, (RejectAction, TightenAction)) and event not in DECISION_EVENTS:
|
|
71
|
+
raise ValueError("reject and tighten actions are only valid before tool policy")
|
|
72
|
+
return HookDefinition(
|
|
73
|
+
hook_id,
|
|
74
|
+
source_id,
|
|
75
|
+
HookMatcher(
|
|
76
|
+
event,
|
|
77
|
+
None if raw.get("tool_id") is None else str(raw["tool_id"]),
|
|
78
|
+
None if raw.get("status") is None else str(raw["status"]),
|
|
79
|
+
),
|
|
80
|
+
action,
|
|
81
|
+
_int_field(raw.get("priority"), 100, "priority"),
|
|
82
|
+
_float_field(raw.get("timeout_seconds"), 10.0, "timeout_seconds"),
|
|
83
|
+
_int_field(raw.get("output_limit"), 4096, "output_limit"),
|
|
84
|
+
bool(raw.get("required", False)),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _int_field(value: object, default: int, name: str) -> int:
|
|
89
|
+
if value is None:
|
|
90
|
+
return default
|
|
91
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
92
|
+
raise ValueError(f"Hook {name} must be an integer")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _float_field(value: object, default: float, name: str) -> float:
|
|
97
|
+
if value is None:
|
|
98
|
+
return default
|
|
99
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
100
|
+
raise ValueError(f"Hook {name} must be a number")
|
|
101
|
+
return float(value)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from windcode.domain.tools import ToolEffect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HookEvent(StrEnum):
|
|
10
|
+
SESSION_START = "session_start"
|
|
11
|
+
SESSION_END = "session_end"
|
|
12
|
+
USER_SUBMIT = "user_submit"
|
|
13
|
+
RUN_START = "run_start"
|
|
14
|
+
RUN_END = "run_end"
|
|
15
|
+
RUN_ERROR = "run_error"
|
|
16
|
+
TOOL_BEFORE_POLICY = "tool_before_policy"
|
|
17
|
+
TOOL_AFTER = "tool_after"
|
|
18
|
+
PERMISSION_REQUEST = "permission_request"
|
|
19
|
+
COMPACT_BEFORE = "compact_before"
|
|
20
|
+
COMPACT_AFTER = "compact_after"
|
|
21
|
+
SUBAGENT_START = "subagent_start"
|
|
22
|
+
SUBAGENT_END = "subagent_end"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DECISION_EVENTS = frozenset({HookEvent.TOOL_BEFORE_POLICY})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class HookContext:
|
|
30
|
+
version: int
|
|
31
|
+
event: HookEvent
|
|
32
|
+
session_id: str
|
|
33
|
+
run_id: str
|
|
34
|
+
correlation_id: str
|
|
35
|
+
source_id: str = "windcode"
|
|
36
|
+
tool_id: str | None = None
|
|
37
|
+
status: str | None = None
|
|
38
|
+
fields: tuple[tuple[str, str | int | float | bool | None], ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class HookMatcher:
|
|
43
|
+
event: HookEvent
|
|
44
|
+
tool_id: str | None = None
|
|
45
|
+
status: str | None = None
|
|
46
|
+
|
|
47
|
+
def matches(self, context: HookContext) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
context.event is self.event
|
|
50
|
+
and (self.tool_id is None or self.tool_id == context.tool_id)
|
|
51
|
+
and (self.status is None or self.status == context.status)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class NotifyAction:
|
|
57
|
+
message: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class PromptAction:
|
|
62
|
+
content: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class CommandAction:
|
|
67
|
+
command: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class RejectAction:
|
|
72
|
+
reason: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class TightenAction:
|
|
77
|
+
effects: frozenset[ToolEffect]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
HookAction = NotifyAction | PromptAction | CommandAction | RejectAction | TightenAction
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class HookDefinition:
|
|
85
|
+
hook_id: str
|
|
86
|
+
source_id: str
|
|
87
|
+
matcher: HookMatcher
|
|
88
|
+
action: HookAction
|
|
89
|
+
priority: int = 100
|
|
90
|
+
timeout_seconds: float = 10.0
|
|
91
|
+
output_limit: int = 4096
|
|
92
|
+
required: bool = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def decision_making(self) -> bool:
|
|
96
|
+
return isinstance(self.action, (RejectAction, TightenAction))
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def sort_key(self) -> tuple[int, str, str]:
|
|
100
|
+
return (self.priority, self.source_id, self.hook_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class HookOutcome:
|
|
105
|
+
rejected: str | None = None
|
|
106
|
+
additional_effects: frozenset[ToolEffect] = frozenset()
|
|
107
|
+
notifications: tuple[str, ...] = ()
|
|
108
|
+
sourced_prompts: tuple[tuple[str, str], ...] = ()
|
|
109
|
+
diagnostics: tuple[str, ...] = ()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from windcode.extensions.mcp.client import McpClient, ResolvedHttpServer, ResolvedStdioServer
|
|
2
|
+
from windcode.extensions.mcp.runtime import McpRuntime, McpServerState
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"McpClient",
|
|
6
|
+
"McpRuntime",
|
|
7
|
+
"McpServerState",
|
|
8
|
+
"ResolvedHttpServer",
|
|
9
|
+
"ResolvedStdioServer",
|
|
10
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import jsonschema
|
|
8
|
+
from mcp.types import AudioContent, ImageContent, TextContent
|
|
9
|
+
from pydantic import BaseModel, RootModel
|
|
10
|
+
|
|
11
|
+
from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
|
|
12
|
+
from windcode.extensions.mcp.catalog import McpToolDefinition, mcp_tool_wire_name
|
|
13
|
+
from windcode.extensions.mcp.runtime import McpRuntime
|
|
14
|
+
from windcode.sessions.artifacts import ArtifactStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class McpArguments(RootModel[dict[str, Any]]):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class McpToolAdapter:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
definition: McpToolDefinition,
|
|
25
|
+
runtime: McpRuntime,
|
|
26
|
+
*,
|
|
27
|
+
artifact_store: ArtifactStore | None = None,
|
|
28
|
+
output_limit: int = 20_000,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.definition = definition
|
|
31
|
+
self.runtime = runtime
|
|
32
|
+
self.artifact_store = artifact_store
|
|
33
|
+
self.output_limit = output_limit
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def name(self) -> str:
|
|
37
|
+
return mcp_tool_wire_name(self.definition.server_id, self.definition.name)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def description(self) -> str:
|
|
41
|
+
return self.definition.description or f"MCP tool from {self.definition.server_id}"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def input_model(self) -> type[BaseModel]:
|
|
45
|
+
return McpArguments
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def input_schema(self) -> Mapping[str, Any]:
|
|
49
|
+
return self.definition.input_schema
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def effects(self) -> frozenset[ToolEffect]:
|
|
53
|
+
return frozenset({ToolEffect.PROCESS, ToolEffect.NETWORK})
|
|
54
|
+
|
|
55
|
+
def validate_arguments(self, arguments: Mapping[str, Any]) -> McpArguments:
|
|
56
|
+
jsonschema.Draft202012Validator(self.definition.input_schema).validate(arguments) # pyright: ignore[reportUnknownMemberType]
|
|
57
|
+
return McpArguments(dict(arguments))
|
|
58
|
+
|
|
59
|
+
async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
|
|
60
|
+
if context.cancelled():
|
|
61
|
+
return ToolResult("MCP call cancelled", True, data={"error": "cancelled"})
|
|
62
|
+
parsed = cast(McpArguments, arguments)
|
|
63
|
+
result = await self.runtime.call(
|
|
64
|
+
self.definition.server_id,
|
|
65
|
+
lambda client: client.call_tool(
|
|
66
|
+
self.definition.name, cast(dict[str, object], parsed.root)
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
from mcp.types import CallToolResult
|
|
70
|
+
|
|
71
|
+
if not isinstance(result, CallToolResult):
|
|
72
|
+
raise ValueError("invalid MCP call result")
|
|
73
|
+
parts: list[str] = []
|
|
74
|
+
for item in result.content:
|
|
75
|
+
if isinstance(item, TextContent):
|
|
76
|
+
parts.append(item.text)
|
|
77
|
+
elif isinstance(item, (ImageContent, AudioContent)):
|
|
78
|
+
parts.append(f"[{item.type}: {item.mimeType}; base64={item.data}]")
|
|
79
|
+
else:
|
|
80
|
+
parts.append(json.dumps(item.model_dump(mode="json"), sort_keys=True))
|
|
81
|
+
# MCP servers commonly mirror structuredContent into text content for
|
|
82
|
+
# backwards-compatible clients. Prefer the canonical content when present.
|
|
83
|
+
if not parts and result.structuredContent is not None:
|
|
84
|
+
parts.append(json.dumps(result.structuredContent, sort_keys=True))
|
|
85
|
+
output = "\n".join(parts)
|
|
86
|
+
artifact_ref: str | None = None
|
|
87
|
+
if self.artifact_store is not None:
|
|
88
|
+
output, reference = self.artifact_store.externalize(output, threshold=self.output_limit)
|
|
89
|
+
artifact_ref = None if reference is None else reference.relative_path
|
|
90
|
+
elif len(output) > self.output_limit:
|
|
91
|
+
output = output[: self.output_limit] + "..."
|
|
92
|
+
return ToolResult(
|
|
93
|
+
output,
|
|
94
|
+
is_error=result.isError,
|
|
95
|
+
artifact_ref=artifact_ref,
|
|
96
|
+
data={
|
|
97
|
+
"source": self.definition.server_id,
|
|
98
|
+
"tool": self.definition.stable_id,
|
|
99
|
+
"error": "remote_error" if result.isError else None,
|
|
100
|
+
},
|
|
101
|
+
)
|