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,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})
|