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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,23 @@
1
+ from windcode.sessions.artifacts import ArtifactStore
2
+ from windcode.sessions.models import (
3
+ SCHEMA_VERSION,
4
+ ArtifactReference,
5
+ EventRecord,
6
+ SessionMetadata,
7
+ SessionStatus,
8
+ )
9
+ from windcode.sessions.store import SessionCorruptionError, SessionStore
10
+ from windcode.sessions.tree import ancestor_chain, create_branch
11
+
12
+ __all__ = [
13
+ "SCHEMA_VERSION",
14
+ "ArtifactReference",
15
+ "ArtifactStore",
16
+ "EventRecord",
17
+ "SessionCorruptionError",
18
+ "SessionMetadata",
19
+ "SessionStatus",
20
+ "SessionStore",
21
+ "ancestor_chain",
22
+ "create_branch",
23
+ ]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+ from uuid import uuid4
7
+
8
+ from windcode.sessions.models import ArtifactReference
9
+
10
+
11
+ class ArtifactStore:
12
+ def __init__(self, session_dir: Path) -> None:
13
+ self.session_dir = session_dir.expanduser().resolve()
14
+ self.artifacts_dir = self.session_dir / "artifacts"
15
+ self.artifacts_dir.mkdir(parents=True, exist_ok=True)
16
+
17
+ def put(self, content: str, *, preview_chars: int = 240) -> ArtifactReference:
18
+ encoded = content.encode("utf-8")
19
+ digest = hashlib.sha256(encoded).hexdigest()
20
+ relative_path = f"artifacts/{digest}.txt"
21
+ destination = self.session_dir / relative_path
22
+ if not destination.exists():
23
+ temporary = destination.with_suffix(f".tmp-{uuid4().hex}")
24
+ try:
25
+ with temporary.open("wb") as stream:
26
+ stream.write(encoded)
27
+ stream.flush()
28
+ os.fsync(stream.fileno())
29
+ try:
30
+ temporary.replace(destination)
31
+ except FileExistsError:
32
+ pass
33
+ finally:
34
+ temporary.unlink(missing_ok=True)
35
+ preview = content[:preview_chars]
36
+ if len(content) > preview_chars:
37
+ preview += "..."
38
+ return ArtifactReference(
39
+ relative_path=relative_path,
40
+ sha256=digest,
41
+ content_length=len(content),
42
+ preview=preview,
43
+ )
44
+
45
+ def externalize(
46
+ self,
47
+ content: str,
48
+ *,
49
+ threshold: int,
50
+ ) -> tuple[str, ArtifactReference | None]:
51
+ if len(content) <= threshold:
52
+ return content, None
53
+ reference = self.put(content)
54
+ summary = (
55
+ f"{reference.preview}\n\n"
56
+ f"[full output: {reference.relative_path}; sha256={reference.sha256}; "
57
+ f"characters={reference.content_length}]"
58
+ )
59
+ return summary, reference
60
+
61
+ def read(self, reference: ArtifactReference) -> str:
62
+ path = (self.session_dir / reference.relative_path).resolve()
63
+ if not path.is_relative_to(self.artifacts_dir):
64
+ raise ValueError("artifact reference escapes the artifact directory")
65
+ content = path.read_text(encoding="utf-8")
66
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
67
+ if digest != reference.sha256:
68
+ raise ValueError("artifact digest mismatch")
69
+ return content
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import UTC, datetime
5
+ from enum import StrEnum
6
+ from typing import Any, cast
7
+
8
+ SCHEMA_VERSION = 1
9
+
10
+
11
+ def utc_now() -> datetime:
12
+ return datetime.now(UTC)
13
+
14
+
15
+ class SessionStatus(StrEnum):
16
+ ACTIVE = "active"
17
+ COMPLETED = "completed"
18
+ FAILED = "failed"
19
+ CANCELLED = "cancelled"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SessionMetadata:
24
+ session_id: str
25
+ created_at: datetime
26
+ updated_at: datetime
27
+ summary: str = ""
28
+ next_sequence: int = 1
29
+ head_record_id: str | None = None
30
+ status: SessionStatus = SessionStatus.ACTIVE
31
+ schema_version: int = SCHEMA_VERSION
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "schema_version": self.schema_version,
36
+ "session_id": self.session_id,
37
+ "created_at": self.created_at.isoformat(),
38
+ "updated_at": self.updated_at.isoformat(),
39
+ "summary": self.summary,
40
+ "next_sequence": self.next_sequence,
41
+ "head_record_id": self.head_record_id,
42
+ "status": self.status.value,
43
+ }
44
+
45
+ @classmethod
46
+ def from_dict(cls, value: dict[str, Any]) -> SessionMetadata:
47
+ return cls(
48
+ schema_version=int(value["schema_version"]),
49
+ session_id=str(value["session_id"]),
50
+ created_at=datetime.fromisoformat(str(value["created_at"])),
51
+ updated_at=datetime.fromisoformat(str(value["updated_at"])),
52
+ summary=str(value.get("summary", "")),
53
+ next_sequence=int(value["next_sequence"]),
54
+ head_record_id=(
55
+ None if value.get("head_record_id") is None else str(value["head_record_id"])
56
+ ),
57
+ status=SessionStatus(str(value["status"])),
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class EventRecord:
63
+ sequence: int
64
+ record_id: str
65
+ parent_id: str | None
66
+ record_type: str
67
+ payload: dict[str, Any]
68
+ created_at: datetime = field(default_factory=utc_now)
69
+ schema_version: int = SCHEMA_VERSION
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "schema_version": self.schema_version,
74
+ "sequence": self.sequence,
75
+ "record_id": self.record_id,
76
+ "parent_id": self.parent_id,
77
+ "record_type": self.record_type,
78
+ "payload": self.payload,
79
+ "created_at": self.created_at.isoformat(),
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, value: dict[str, Any]) -> EventRecord:
84
+ raw_payload = value["payload"]
85
+ if not isinstance(raw_payload, dict):
86
+ raise ValueError("event payload must be an object")
87
+ payload = cast(dict[object, object], raw_payload)
88
+ return cls(
89
+ schema_version=int(value["schema_version"]),
90
+ sequence=int(value["sequence"]),
91
+ record_id=str(value["record_id"]),
92
+ parent_id=None if value.get("parent_id") is None else str(value["parent_id"]),
93
+ record_type=str(value["record_type"]),
94
+ payload={str(key): item for key, item in payload.items()},
95
+ created_at=datetime.fromisoformat(str(value["created_at"])),
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class ArtifactReference:
101
+ relative_path: str
102
+ sha256: str
103
+ content_length: int
104
+ preview: str
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+ from uuid import uuid4
10
+
11
+ from windcode.sessions.models import EventRecord, SessionMetadata, SessionStatus, utc_now
12
+
13
+
14
+ class SessionCorruptionError(ValueError):
15
+ pass
16
+
17
+
18
+ class SessionStore:
19
+ def __init__(self, sessions_root: Path, metadata: SessionMetadata) -> None:
20
+ self.sessions_root = sessions_root.expanduser().resolve()
21
+ self.metadata = metadata
22
+ self.session_dir = self.sessions_root / metadata.session_id
23
+ self.events_path = self.session_dir / "events.jsonl"
24
+ self.meta_path = self.session_dir / "meta.json"
25
+ self._lock = threading.Lock()
26
+
27
+ @classmethod
28
+ def create(cls, sessions_root: Path, session_id: str | None = None) -> SessionStore:
29
+ now = utc_now()
30
+ metadata = SessionMetadata(
31
+ session_id=session_id or uuid4().hex,
32
+ created_at=now,
33
+ updated_at=now,
34
+ )
35
+ store = cls(sessions_root, metadata)
36
+ store.session_dir.mkdir(parents=True, exist_ok=False)
37
+ (store.session_dir / "artifacts").mkdir()
38
+ store.events_path.touch()
39
+ store._write_metadata(durable=True)
40
+ return store
41
+
42
+ @classmethod
43
+ def open(cls, sessions_root: Path, session_id: str) -> SessionStore:
44
+ meta_path = sessions_root.expanduser().resolve() / session_id / "meta.json"
45
+ try:
46
+ raw = json.loads(meta_path.read_text(encoding="utf-8"))
47
+ if not isinstance(raw, dict):
48
+ raise ValueError("metadata must be an object")
49
+ metadata = SessionMetadata.from_dict(cast(dict[str, Any], raw))
50
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
51
+ raise SessionCorruptionError(f"cannot load {meta_path}: {exc}") from exc
52
+ return cls(sessions_root, metadata)
53
+
54
+ def _write_metadata(self, *, durable: bool) -> None:
55
+ temporary = self.meta_path.with_suffix(f".tmp-{uuid4().hex}")
56
+ data = json.dumps(self.metadata.to_dict(), ensure_ascii=True, sort_keys=True) + "\n"
57
+ try:
58
+ with temporary.open("w", encoding="utf-8") as stream:
59
+ stream.write(data)
60
+ stream.flush()
61
+ if durable:
62
+ os.fsync(stream.fileno())
63
+ temporary.replace(self.meta_path)
64
+ if durable:
65
+ directory_fd = os.open(self.session_dir, os.O_RDONLY)
66
+ try:
67
+ os.fsync(directory_fd)
68
+ finally:
69
+ os.close(directory_fd)
70
+ finally:
71
+ temporary.unlink(missing_ok=True)
72
+
73
+ def append(
74
+ self,
75
+ record_type: str,
76
+ payload: dict[str, Any],
77
+ *,
78
+ parent_id: str | None = None,
79
+ durable: bool = False,
80
+ ) -> EventRecord:
81
+ with self._lock:
82
+ record = EventRecord(
83
+ sequence=self.metadata.next_sequence,
84
+ record_id=uuid4().hex,
85
+ parent_id=self.metadata.head_record_id if parent_id is None else parent_id,
86
+ record_type=record_type,
87
+ payload=payload,
88
+ )
89
+ line = json.dumps(record.to_dict(), ensure_ascii=True, sort_keys=True) + "\n"
90
+ with self.events_path.open("a", encoding="utf-8") as stream:
91
+ stream.write(line)
92
+ stream.flush()
93
+ if durable:
94
+ os.fsync(stream.fileno())
95
+ self.metadata = replace(
96
+ self.metadata,
97
+ updated_at=utc_now(),
98
+ next_sequence=record.sequence + 1,
99
+ head_record_id=record.record_id,
100
+ )
101
+ self._write_metadata(durable=durable)
102
+ return record
103
+
104
+ def set_status(self, status: SessionStatus, *, durable: bool = True) -> None:
105
+ with self._lock:
106
+ self.metadata = replace(self.metadata, status=status, updated_at=utc_now())
107
+ self._write_metadata(durable=durable)
108
+
109
+ def set_summary(self, summary: str, *, durable: bool = True) -> None:
110
+ with self._lock:
111
+ self.metadata = replace(self.metadata, summary=summary)
112
+ self._write_metadata(durable=durable)
113
+
114
+ def load_records(self) -> tuple[EventRecord, ...]:
115
+ try:
116
+ lines = self.events_path.read_text(encoding="utf-8").splitlines()
117
+ except OSError as exc:
118
+ raise SessionCorruptionError(f"cannot read {self.events_path}: {exc}") from exc
119
+
120
+ records: list[EventRecord] = []
121
+ for index, line in enumerate(lines):
122
+ if not line.strip():
123
+ continue
124
+ try:
125
+ raw = json.loads(line)
126
+ if not isinstance(raw, dict):
127
+ raise ValueError("record must be an object")
128
+ record = EventRecord.from_dict(cast(dict[str, Any], raw))
129
+ expected = records[-1].sequence + 1 if records else 1
130
+ if record.sequence != expected:
131
+ raise ValueError(f"expected sequence {expected}, got {record.sequence}")
132
+ records.append(record)
133
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
134
+ if index == len(lines) - 1:
135
+ break
136
+ raise SessionCorruptionError(
137
+ f"invalid event record at line {index + 1}: {exc}"
138
+ ) from exc
139
+ return tuple(records)
140
+
141
+ def recover_interrupted_side_effects(self) -> tuple[EventRecord, ...]:
142
+ records = self.load_records()
143
+ finished = {
144
+ str(record.payload.get("call_id"))
145
+ for record in records
146
+ if record.record_type in {"tool_finished", "tool_interrupted"}
147
+ }
148
+ pending = [
149
+ record
150
+ for record in records
151
+ if record.record_type == "tool_started"
152
+ and record.payload.get("side_effect") is True
153
+ and str(record.payload.get("call_id")) not in finished
154
+ ]
155
+ recovered: list[EventRecord] = []
156
+ for record in pending:
157
+ recovered.append(
158
+ self.append(
159
+ "tool_interrupted",
160
+ {
161
+ "call_id": record.payload.get("call_id"),
162
+ "reason": "session ended before a durable result was recorded",
163
+ },
164
+ durable=True,
165
+ )
166
+ )
167
+ return tuple(recovered)
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from windcode.sessions.models import EventRecord
6
+ from windcode.sessions.store import SessionStore
7
+
8
+
9
+ def ancestor_chain(records: tuple[EventRecord, ...], record_id: str) -> tuple[EventRecord, ...]:
10
+ by_id = {record.record_id: record for record in records}
11
+ chain: list[EventRecord] = []
12
+ cursor: str | None = record_id
13
+ visited: set[str] = set()
14
+ while cursor is not None:
15
+ if cursor in visited:
16
+ raise ValueError("session record graph contains a cycle")
17
+ visited.add(cursor)
18
+ try:
19
+ record = by_id[cursor]
20
+ except KeyError as exc:
21
+ raise ValueError(f"unknown record id: {cursor}") from exc
22
+ chain.append(record)
23
+ cursor = record.parent_id
24
+ chain.reverse()
25
+ return tuple(chain)
26
+
27
+
28
+ def create_branch(
29
+ store: SessionStore,
30
+ parent_id: str,
31
+ record_type: str,
32
+ payload: dict[str, Any],
33
+ ) -> EventRecord:
34
+ ancestor_chain(store.load_records(), parent_id)
35
+ return store.append(record_type, payload, parent_id=parent_id, durable=True)
@@ -0,0 +1,10 @@
1
+ from windcode.tools.builtins import add_subagent_tools, create_builtin_registry
2
+ from windcode.tools.memory import register_memory_tools
3
+ from windcode.tools.registry import ToolRegistry
4
+
5
+ __all__ = [
6
+ "ToolRegistry",
7
+ "add_subagent_tools",
8
+ "create_builtin_registry",
9
+ "register_memory_tools",
10
+ ]
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import Literal, cast
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
11
+ from windcode.tools.filesystem import atomic_write_text, content_sha256, require_workspace_path
12
+
13
+ _HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?(?:\n)?$")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class HunkLine:
18
+ operation: Literal[" ", "+", "-"]
19
+ content: str
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Hunk:
24
+ old_start: int
25
+ old_count: int
26
+ new_start: int
27
+ new_count: int
28
+ lines: tuple[HunkLine, ...]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class FilePatch:
33
+ old_path: str | None
34
+ new_path: str | None
35
+ hunks: tuple[Hunk, ...]
36
+
37
+
38
+ class PatchParseError(ValueError):
39
+ pass
40
+
41
+
42
+ def _parse_path(header: str, prefix: str) -> str | None:
43
+ if not header.startswith(prefix):
44
+ raise PatchParseError(f"expected {prefix.strip()} header")
45
+ raw = header[len(prefix) :].strip().split("\t", 1)[0]
46
+ if raw == "/dev/null":
47
+ return None
48
+ if raw.startswith(("a/", "b/")):
49
+ raw = raw[2:]
50
+ path = PurePosixPath(raw)
51
+ if path.is_absolute() or ".." in path.parts or not path.parts:
52
+ raise PatchParseError(f"unsafe patch path: {raw}")
53
+ return str(path)
54
+
55
+
56
+ def parse_unified_diff(text: str) -> tuple[FilePatch, ...]:
57
+ if "GIT binary patch" in text or "Binary files " in text:
58
+ raise PatchParseError("binary patches are not supported")
59
+ lines = text.splitlines(keepends=True)
60
+ files: list[FilePatch] = []
61
+ index = 0
62
+ while index < len(lines):
63
+ if not lines[index].startswith("--- "):
64
+ index += 1
65
+ continue
66
+ old_path = _parse_path(lines[index], "--- ")
67
+ index += 1
68
+ if index >= len(lines):
69
+ raise PatchParseError("missing new file header")
70
+ new_path = _parse_path(lines[index], "+++ ")
71
+ index += 1
72
+ if old_path is None and new_path is None:
73
+ raise PatchParseError("both patch paths cannot be /dev/null")
74
+ hunks: list[Hunk] = []
75
+ while index < len(lines) and not lines[index].startswith("--- "):
76
+ if lines[index].startswith(("diff --git ", "index ")):
77
+ index += 1
78
+ continue
79
+ match = _HUNK_HEADER.match(lines[index])
80
+ if match is None:
81
+ if lines[index].strip():
82
+ raise PatchParseError(f"unexpected patch line: {lines[index].rstrip()}")
83
+ index += 1
84
+ continue
85
+ old_start = int(match.group(1))
86
+ old_count = int(match.group(2) or "1")
87
+ new_start = int(match.group(3))
88
+ new_count = int(match.group(4) or "1")
89
+ index += 1
90
+ hunk_lines: list[HunkLine] = []
91
+ while index < len(lines):
92
+ line = lines[index]
93
+ if line.startswith(("@@ ", "--- ")):
94
+ break
95
+ if line.startswith("\"):
96
+ if not hunk_lines:
97
+ raise PatchParseError("orphan no-newline marker")
98
+ previous = hunk_lines[-1]
99
+ hunk_lines[-1] = HunkLine(previous.operation, previous.content.rstrip("\r\n"))
100
+ index += 1
101
+ continue
102
+ if not line or line[0] not in {" ", "+", "-"}:
103
+ raise PatchParseError(f"invalid hunk line: {line.rstrip()}")
104
+ hunk_lines.append(HunkLine(cast(Literal[" ", "+", "-"], line[0]), line[1:]))
105
+ index += 1
106
+ actual_old = sum(item.operation != "+" for item in hunk_lines)
107
+ actual_new = sum(item.operation != "-" for item in hunk_lines)
108
+ if (actual_old, actual_new) != (old_count, new_count):
109
+ raise PatchParseError(
110
+ f"hunk count mismatch: expected {old_count}/{new_count}, "
111
+ f"found {actual_old}/{actual_new}"
112
+ )
113
+ hunks.append(Hunk(old_start, old_count, new_start, new_count, tuple(hunk_lines)))
114
+ if not hunks:
115
+ raise PatchParseError("file patch has no hunks")
116
+ files.append(FilePatch(old_path, new_path, tuple(hunks)))
117
+ if not files:
118
+ raise PatchParseError("no unified diff file headers found")
119
+ return tuple(files)
120
+
121
+
122
+ def apply_file_patch(content: str, patch: FilePatch) -> str:
123
+ source = content.splitlines(keepends=True)
124
+ output: list[str] = []
125
+ cursor = 0
126
+ for hunk in patch.hunks:
127
+ target = max(hunk.old_start - 1, 0)
128
+ if target < cursor or target > len(source):
129
+ raise ValueError("hunks overlap or start beyond the source file")
130
+ output.extend(source[cursor:target])
131
+ cursor = target
132
+ for line in hunk.lines:
133
+ if line.operation in {" ", "-"}:
134
+ if cursor >= len(source) or source[cursor] != line.content:
135
+ raise ValueError(f"patch context does not match source at line {cursor + 1}")
136
+ if line.operation == " ":
137
+ output.append(source[cursor])
138
+ cursor += 1
139
+ else:
140
+ output.append(line.content)
141
+ output.extend(source[cursor:])
142
+ return "".join(output)
143
+
144
+
145
+ class ApplyPatchInput(BaseModel):
146
+ model_config = ConfigDict(extra="forbid")
147
+
148
+ patch: str = Field(min_length=1)
149
+ expected_sha256: dict[str, str] = Field(default_factory=dict[str, str])
150
+
151
+
152
+ class ApplyPatchTool:
153
+ name = "apply_patch"
154
+ description = "Preflight and atomically apply a bounded multi-file unified diff."
155
+ input_model = ApplyPatchInput
156
+ effects = frozenset({ToolEffect.WORKSPACE_WRITE})
157
+
158
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
159
+ parsed = cast(ApplyPatchInput, arguments)
160
+ patches = parse_unified_diff(parsed.patch)
161
+ planned: list[tuple[FilePatch, Path, str | None, str]] = []
162
+ for patch in patches:
163
+ relative = patch.new_path or patch.old_path
164
+ if relative is None:
165
+ raise PatchParseError("patch is missing a usable path")
166
+ path = require_workspace_path(context.workspace, relative)
167
+ old_content = "" if patch.old_path is None else path.read_text(encoding="utf-8")
168
+ expected = parsed.expected_sha256.get(relative)
169
+ actual = content_sha256(old_content) if patch.old_path is not None else None
170
+ if expected is not None and expected != actual:
171
+ return ToolResult(
172
+ output=f"{relative}: file changed since patch was prepared",
173
+ is_error=True,
174
+ data={"error": "stale_content", "path": relative, "actual_sha256": actual},
175
+ )
176
+ new_content = apply_file_patch(old_content, patch)
177
+ planned.append((patch, path, None if patch.new_path is None else new_content, relative))
178
+
179
+ changes: list[dict[str, object]] = []
180
+ for patch, path, new_content, relative in planned:
181
+ if new_content is None:
182
+ path.unlink()
183
+ action = "deleted"
184
+ else:
185
+ atomic_write_text(path, new_content)
186
+ action = "created" if patch.old_path is None else "modified"
187
+ changes.append({"path": relative, "action": action})
188
+ return ToolResult(
189
+ output=f"applied patch to {len(changes)} file(s)",
190
+ data={"changes": changes},
191
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
9
+
10
+
11
+ class Question(BaseModel):
12
+ model_config = ConfigDict(extra="forbid", frozen=True)
13
+
14
+ id: str = Field(min_length=1)
15
+ prompt: str = Field(min_length=1)
16
+ options: tuple[str, ...] = Field(min_length=2, max_length=3)
17
+
18
+
19
+ class AskUserInput(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+ questions: tuple[Question, ...] = Field(min_length=1, max_length=3)
23
+
24
+
25
+ class AskUserTool:
26
+ name = "ask_user"
27
+ description = "Ask one to three multiple-choice questions through the active run channel."
28
+ input_model = AskUserInput
29
+ effects = frozenset({ToolEffect.USER_INTERACTION})
30
+
31
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
32
+ parsed = cast(AskUserInput, arguments)
33
+ if context.request_user is None:
34
+ return ToolResult(
35
+ output="no user response channel is available",
36
+ is_error=True,
37
+ data={"error": "interaction_unavailable"},
38
+ )
39
+ if context.cancelled():
40
+ raise asyncio.CancelledError
41
+ payload = tuple(question.model_dump(mode="json") for question in parsed.questions)
42
+ response = await context.request_user(payload)
43
+ if not isinstance(response, dict):
44
+ return ToolResult(
45
+ output="user response channel returned an invalid response",
46
+ is_error=True,
47
+ data={"error": "invalid_user_response"},
48
+ )
49
+ raw_answers = cast(dict[object, object], response)
50
+ answers = {str(key): str(value) for key, value in raw_answers.items()}
51
+ expected = {question.id for question in parsed.questions}
52
+ if set(answers) != expected:
53
+ return ToolResult(
54
+ output="user response did not answer every question",
55
+ is_error=True,
56
+ data={"error": "incomplete_user_response"},
57
+ )
58
+ return ToolResult(output="user answered the questions", data={"answers": answers})