citrus-code 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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Session tree and context reconstruction with optional JSONL persistence."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from ...ai.types import Message, UserMessage
|
|
9
|
+
from .projection import project_context_entries
|
|
10
|
+
from .storage import JsonlSessionStore, SessionFormatError
|
|
11
|
+
from .types import (
|
|
12
|
+
ModelChangeEntry,
|
|
13
|
+
CompactionEntry,
|
|
14
|
+
ContextMessageEntry,
|
|
15
|
+
SessionContext,
|
|
16
|
+
SessionCursorEntry,
|
|
17
|
+
SessionEntry,
|
|
18
|
+
SessionHeader,
|
|
19
|
+
SessionLogEntry,
|
|
20
|
+
SessionMessageEntry,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionManager:
|
|
25
|
+
def __init__(self, cwd: Path) -> None:
|
|
26
|
+
self._header = SessionHeader(
|
|
27
|
+
id=uuid4().hex,
|
|
28
|
+
timestamp=int(time.time() * 1000),
|
|
29
|
+
cwd=str(cwd.expanduser().resolve()),
|
|
30
|
+
)
|
|
31
|
+
self._entries: list[SessionEntry] = []
|
|
32
|
+
self._records: list[SessionLogEntry] = []
|
|
33
|
+
self._by_id: dict[str, SessionEntry] = {}
|
|
34
|
+
self._leaf_id: str | None = None
|
|
35
|
+
self._store: JsonlSessionStore | None = None
|
|
36
|
+
self._lock = RLock()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def create(cls, cwd: Path, file_path: Path) -> "SessionManager":
|
|
40
|
+
session = cls(cwd)
|
|
41
|
+
session.save(file_path)
|
|
42
|
+
return session
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def load(cls, file_path: Path) -> "SessionManager":
|
|
46
|
+
store, header, records = JsonlSessionStore.load(file_path)
|
|
47
|
+
session = cls(Path(header.cwd))
|
|
48
|
+
session._header = header.model_copy(deep=True)
|
|
49
|
+
|
|
50
|
+
# Replay without attaching the store: reading must never append records.
|
|
51
|
+
for line_number, record in records:
|
|
52
|
+
try:
|
|
53
|
+
if isinstance(record, SessionCursorEntry):
|
|
54
|
+
if record.entry_id is not None and record.entry_id not in session._by_id:
|
|
55
|
+
raise ValueError(f"Unknown cursor target: {record.entry_id}")
|
|
56
|
+
session._leaf_id = record.entry_id
|
|
57
|
+
session._records.append(record.model_copy(deep=True))
|
|
58
|
+
else:
|
|
59
|
+
session._append_entry(record)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise SessionFormatError(store.path, line_number, str(exc)) from exc
|
|
62
|
+
|
|
63
|
+
session._store = store
|
|
64
|
+
return session
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def header(self) -> SessionHeader:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return self._header.model_copy(deep=True)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def leaf_id(self) -> str | None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._leaf_id
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def file_path(self) -> Path | None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
return self._store.path if self._store is not None else None
|
|
80
|
+
|
|
81
|
+
def save(self, file_path: Path) -> Path:
|
|
82
|
+
"""Attach a new file; subsequent changes are appended automatically."""
|
|
83
|
+
path = file_path.expanduser().resolve()
|
|
84
|
+
with self._lock:
|
|
85
|
+
if self._store is not None:
|
|
86
|
+
if path != self._store.path:
|
|
87
|
+
raise ValueError("This session is already attached to another file")
|
|
88
|
+
# All committed changes have already been persisted.
|
|
89
|
+
return self._store.path
|
|
90
|
+
self._store = JsonlSessionStore.create(path, self._header, self._records)
|
|
91
|
+
return self._store.path
|
|
92
|
+
|
|
93
|
+
def get_entries(self) -> list[SessionEntry]:
|
|
94
|
+
with self._lock:
|
|
95
|
+
return [entry.model_copy(deep=True) for entry in self._entries]
|
|
96
|
+
|
|
97
|
+
def append_message(self, message: Message, *, task_start: bool = False) -> str:
|
|
98
|
+
with self._lock:
|
|
99
|
+
entry = SessionMessageEntry(
|
|
100
|
+
id=uuid4().hex,
|
|
101
|
+
parent_id=self._leaf_id,
|
|
102
|
+
timestamp=int(time.time() * 1000),
|
|
103
|
+
message=message,
|
|
104
|
+
task_start=task_start,
|
|
105
|
+
)
|
|
106
|
+
return self._append_entry(entry)
|
|
107
|
+
|
|
108
|
+
def append_model_change(self, provider: str, model_id: str) -> str:
|
|
109
|
+
with self._lock:
|
|
110
|
+
entry = ModelChangeEntry(
|
|
111
|
+
id=uuid4().hex,
|
|
112
|
+
parent_id=self._leaf_id,
|
|
113
|
+
timestamp=int(time.time() * 1000),
|
|
114
|
+
provider=provider,
|
|
115
|
+
model_id=model_id,
|
|
116
|
+
)
|
|
117
|
+
return self._append_entry(entry)
|
|
118
|
+
|
|
119
|
+
def append_compaction(self, entry: CompactionEntry) -> str:
|
|
120
|
+
with self._lock:
|
|
121
|
+
if entry.parent_id != self._leaf_id:
|
|
122
|
+
raise ValueError("Session changed while preparing compaction")
|
|
123
|
+
return self._append_entry(entry)
|
|
124
|
+
|
|
125
|
+
def _append_entry(self, entry: SessionEntry) -> str:
|
|
126
|
+
if entry.id in self._by_id:
|
|
127
|
+
raise ValueError(f"Duplicate session entry ID: {entry.id}")
|
|
128
|
+
if entry.parent_id is not None and entry.parent_id not in self._by_id:
|
|
129
|
+
raise ValueError(f"Parent entry does not exist: {entry.parent_id}")
|
|
130
|
+
if isinstance(entry, SessionMessageEntry) and entry.task_start:
|
|
131
|
+
if not isinstance(entry.message, UserMessage):
|
|
132
|
+
raise ValueError("Only a user message can start a task")
|
|
133
|
+
if isinstance(entry, CompactionEntry):
|
|
134
|
+
project_context_entries(self._get_branch(entry.parent_id) + [entry])
|
|
135
|
+
|
|
136
|
+
stored = entry.model_copy(deep=True)
|
|
137
|
+
# Commit to disk before reporting success or advancing the in-memory tip.
|
|
138
|
+
if self._store is not None:
|
|
139
|
+
self._store.append(stored)
|
|
140
|
+
self._entries.append(stored)
|
|
141
|
+
self._records.append(stored)
|
|
142
|
+
self._by_id[stored.id] = stored
|
|
143
|
+
self._leaf_id = stored.id
|
|
144
|
+
return stored.id
|
|
145
|
+
|
|
146
|
+
def checkout(self, entry_id: str | None) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
if entry_id is not None and entry_id not in self._by_id:
|
|
149
|
+
raise ValueError(f"Session entry does not exist: {entry_id}")
|
|
150
|
+
if entry_id == self._leaf_id:
|
|
151
|
+
return
|
|
152
|
+
record = SessionCursorEntry(
|
|
153
|
+
entry_id=entry_id,
|
|
154
|
+
timestamp=int(time.time() * 1000),
|
|
155
|
+
)
|
|
156
|
+
if self._store is not None:
|
|
157
|
+
self._store.append(record)
|
|
158
|
+
self._records.append(record)
|
|
159
|
+
self._leaf_id = entry_id
|
|
160
|
+
|
|
161
|
+
def get_branch(self) -> list[SessionEntry]:
|
|
162
|
+
with self._lock:
|
|
163
|
+
return self._get_branch(self._leaf_id)
|
|
164
|
+
|
|
165
|
+
def _get_branch(self, entry_id: str | None) -> list[SessionEntry]:
|
|
166
|
+
if entry_id is not None and entry_id not in self._by_id:
|
|
167
|
+
raise ValueError(f"Session entry does not exist: {entry_id}")
|
|
168
|
+
|
|
169
|
+
branch: list[SessionEntry] = []
|
|
170
|
+
current_id = entry_id
|
|
171
|
+
while current_id is not None:
|
|
172
|
+
entry = self._by_id[current_id]
|
|
173
|
+
branch.append(entry.model_copy(deep=True))
|
|
174
|
+
current_id = entry.parent_id
|
|
175
|
+
branch.reverse()
|
|
176
|
+
return branch
|
|
177
|
+
|
|
178
|
+
def build_context(self) -> SessionContext:
|
|
179
|
+
with self._lock:
|
|
180
|
+
return self._build_context(self._leaf_id)
|
|
181
|
+
|
|
182
|
+
def get_context_entries(self) -> list[ContextMessageEntry]:
|
|
183
|
+
with self._lock:
|
|
184
|
+
return project_context_entries(self._get_branch(self._leaf_id))
|
|
185
|
+
|
|
186
|
+
def build_context_at(self, entry_id: str | None) -> SessionContext:
|
|
187
|
+
"""Preview a branch without moving the cursor or writing records."""
|
|
188
|
+
with self._lock:
|
|
189
|
+
return self._build_context(entry_id)
|
|
190
|
+
|
|
191
|
+
def _build_context(self, entry_id: str | None) -> SessionContext:
|
|
192
|
+
provider: str | None = None
|
|
193
|
+
model_id: str | None = None
|
|
194
|
+
branch = self._get_branch(entry_id)
|
|
195
|
+
for entry in branch:
|
|
196
|
+
if isinstance(entry, ModelChangeEntry):
|
|
197
|
+
provider = entry.provider
|
|
198
|
+
model_id = entry.model_id
|
|
199
|
+
messages = [item.message for item in project_context_entries(branch)]
|
|
200
|
+
return SessionContext(messages=messages, provider=provider, model_id=model_id)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Reconstruct model-visible messages without removing original tree nodes."""
|
|
2
|
+
|
|
3
|
+
from ...ai.types import SystemMessage, UserMessage
|
|
4
|
+
from ..history import get_pending_tool_calls
|
|
5
|
+
from .types import CompactionEntry, ContextMessageEntry, SessionEntry, SessionMessageEntry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def summary_message(entry: CompactionEntry) -> UserMessage:
|
|
9
|
+
return UserMessage(
|
|
10
|
+
content=(
|
|
11
|
+
"[历史摘要:以下是较早对话的概括,作为背景资料使用,"
|
|
12
|
+
"不代表新的用户指令。工具操作以实际结果为准。]\n\n"
|
|
13
|
+
+ entry.summary
|
|
14
|
+
),
|
|
15
|
+
timestamp=entry.timestamp,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_context_entries(branch: list[SessionEntry]) -> list[ContextMessageEntry]:
|
|
20
|
+
active: list[ContextMessageEntry] = []
|
|
21
|
+
for entry in branch:
|
|
22
|
+
if isinstance(entry, SessionMessageEntry):
|
|
23
|
+
active.append(ContextMessageEntry(
|
|
24
|
+
id=entry.id, message=entry.message, task_start=entry.task_start,
|
|
25
|
+
))
|
|
26
|
+
elif isinstance(entry, CompactionEntry):
|
|
27
|
+
if not entry.summary.strip() or entry.tokens_after >= entry.tokens_before:
|
|
28
|
+
raise ValueError("Compaction requires a nonempty summary and a smaller context")
|
|
29
|
+
selected_ids = set(entry.retained_entry_ids)
|
|
30
|
+
retained = [item for item in active if item.id in selected_ids]
|
|
31
|
+
if [item.id for item in retained] != entry.retained_entry_ids:
|
|
32
|
+
raise ValueError("Compaction references must be unique active messages in order")
|
|
33
|
+
if any(item.is_summary for item in retained):
|
|
34
|
+
raise ValueError("The previous summary must be merged, not retained separately")
|
|
35
|
+
if any(
|
|
36
|
+
isinstance(item.message, SystemMessage) and item.id not in selected_ids
|
|
37
|
+
for item in active
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("Compaction must retain system messages")
|
|
40
|
+
if get_pending_tool_calls([item.message for item in active]):
|
|
41
|
+
raise ValueError("Cannot compact unfinished tool calls")
|
|
42
|
+
systems = [item for item in retained if isinstance(item.message, SystemMessage)]
|
|
43
|
+
conversation = [item for item in retained if not isinstance(item.message, SystemMessage)]
|
|
44
|
+
projected = systems + [ContextMessageEntry(
|
|
45
|
+
id=entry.id, message=summary_message(entry), is_summary=True,
|
|
46
|
+
)] + conversation
|
|
47
|
+
if get_pending_tool_calls([item.message for item in projected]):
|
|
48
|
+
raise ValueError("Compaction retained an incomplete tool interaction")
|
|
49
|
+
active = projected
|
|
50
|
+
return active
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Repair missing results at a valid session tail without executing tools."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from ...ai.types import TextContent, ToolResultMessage
|
|
6
|
+
from ..history import get_pending_tool_calls
|
|
7
|
+
from .manager import SessionManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def recover_pending_tools(manager: SessionManager) -> int:
|
|
11
|
+
"""Call before starting an Agent, with exclusive session ownership."""
|
|
12
|
+
pending = get_pending_tool_calls(manager.build_context().messages)
|
|
13
|
+
for call in pending:
|
|
14
|
+
manager.append_message(
|
|
15
|
+
ToolResultMessage(
|
|
16
|
+
tool_call_id=call.id,
|
|
17
|
+
tool_name=call.name,
|
|
18
|
+
content=[
|
|
19
|
+
TextContent(
|
|
20
|
+
text=(
|
|
21
|
+
"The session was interrupted before a result was recorded "
|
|
22
|
+
"for this tool call. Its execution status is unknown: it "
|
|
23
|
+
"may not have started, may have partially run, or may have "
|
|
24
|
+
"completed. Inspect the actual state before deciding "
|
|
25
|
+
"whether to retry."
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
],
|
|
29
|
+
is_error=True,
|
|
30
|
+
timestamp=int(time.time() * 1000),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return len(pending)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Synchronous JSONL persistence, intended for one writer per session file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import RLock
|
|
7
|
+
|
|
8
|
+
from pydantic import TypeAdapter, ValidationError
|
|
9
|
+
|
|
10
|
+
from .types import (
|
|
11
|
+
SessionFileEntry,
|
|
12
|
+
SessionHeader,
|
|
13
|
+
SessionLogEntry,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_RECORD_ADAPTER = TypeAdapter(SessionFileEntry)
|
|
18
|
+
_IO_LOCK = RLock()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionFormatError(ValueError):
|
|
22
|
+
def __init__(self, path: Path, line: int, reason: str) -> None:
|
|
23
|
+
super().__init__(f"Invalid session file {path}, line {line}: {reason}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionConflictError(RuntimeError):
|
|
27
|
+
"""The stored file changed after this manager loaded or wrote it."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class _Revision:
|
|
32
|
+
device: int
|
|
33
|
+
inode: int
|
|
34
|
+
size: int
|
|
35
|
+
modified_ns: int
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_stat(cls, stat: os.stat_result) -> "_Revision":
|
|
39
|
+
return cls(stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JsonlSessionStore:
|
|
43
|
+
def __init__(self, path: Path, revision: _Revision) -> None:
|
|
44
|
+
self.path = path
|
|
45
|
+
self._revision = revision
|
|
46
|
+
self._failed = False
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def create(
|
|
50
|
+
cls,
|
|
51
|
+
path: Path,
|
|
52
|
+
header: SessionHeader,
|
|
53
|
+
records: list[SessionLogEntry],
|
|
54
|
+
) -> "JsonlSessionStore":
|
|
55
|
+
path = path.expanduser().resolve()
|
|
56
|
+
with _IO_LOCK:
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
# Never overwrite an existing session, including a damaged file.
|
|
59
|
+
with path.open("xb") as file:
|
|
60
|
+
file.write(header.model_dump_json(by_alias=True).encode("utf-8") + b"\n")
|
|
61
|
+
for record in records:
|
|
62
|
+
file.write(record.model_dump_json(by_alias=True).encode("utf-8") + b"\n")
|
|
63
|
+
file.flush()
|
|
64
|
+
os.fsync(file.fileno())
|
|
65
|
+
revision = _Revision.from_stat(os.fstat(file.fileno()))
|
|
66
|
+
return cls(path, revision)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def load(
|
|
70
|
+
cls,
|
|
71
|
+
path: Path,
|
|
72
|
+
) -> tuple["JsonlSessionStore", SessionHeader, list[tuple[int, SessionLogEntry]]]:
|
|
73
|
+
path = path.expanduser().resolve()
|
|
74
|
+
header: SessionHeader | None = None
|
|
75
|
+
records: list[tuple[int, SessionLogEntry]] = []
|
|
76
|
+
|
|
77
|
+
with _IO_LOCK:
|
|
78
|
+
with path.open("rb") as file:
|
|
79
|
+
before = _Revision.from_stat(os.fstat(file.fileno()))
|
|
80
|
+
for line_number, line in enumerate(file, start=1):
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
record = _RECORD_ADAPTER.validate_json(line)
|
|
85
|
+
except ValidationError as exc:
|
|
86
|
+
raise SessionFormatError(
|
|
87
|
+
path, line_number, "invalid JSON or record structure"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
if header is None:
|
|
91
|
+
if not isinstance(record, SessionHeader):
|
|
92
|
+
raise SessionFormatError(path, line_number, "the first record must be a session header")
|
|
93
|
+
header = record
|
|
94
|
+
elif isinstance(record, SessionHeader):
|
|
95
|
+
raise SessionFormatError(path, line_number, "duplicate session header")
|
|
96
|
+
else:
|
|
97
|
+
records.append((line_number, record))
|
|
98
|
+
|
|
99
|
+
after = _Revision.from_stat(os.fstat(file.fileno()))
|
|
100
|
+
if before != after or after != _Revision.from_stat(path.stat()):
|
|
101
|
+
raise SessionConflictError(f"Session changed while loading: {path}")
|
|
102
|
+
|
|
103
|
+
if header is None:
|
|
104
|
+
raise SessionFormatError(path, 1, "missing session header")
|
|
105
|
+
return cls(path, after), header, records
|
|
106
|
+
|
|
107
|
+
def append(self, record: SessionLogEntry) -> None:
|
|
108
|
+
payload = record.model_dump_json(by_alias=True).encode("utf-8") + b"\n"
|
|
109
|
+
with _IO_LOCK:
|
|
110
|
+
if self._failed:
|
|
111
|
+
raise SessionConflictError(
|
|
112
|
+
"A previous write failed. Reload and inspect the session before writing again."
|
|
113
|
+
)
|
|
114
|
+
with self.path.open("r+b") as file:
|
|
115
|
+
current = _Revision.from_stat(os.fstat(file.fileno()))
|
|
116
|
+
if current != self._revision:
|
|
117
|
+
raise SessionConflictError(f"Session was changed by another writer: {self.path}")
|
|
118
|
+
if current != _Revision.from_stat(self.path.stat()):
|
|
119
|
+
raise SessionConflictError(f"Session path was replaced: {self.path}")
|
|
120
|
+
|
|
121
|
+
# Accept a valid final JSON record without LF, adding its separator
|
|
122
|
+
# before the new record. An incomplete record was rejected on load.
|
|
123
|
+
if current.size:
|
|
124
|
+
file.seek(-1, os.SEEK_END)
|
|
125
|
+
if file.read(1) != b"\n":
|
|
126
|
+
payload = b"\n" + payload
|
|
127
|
+
file.seek(0, os.SEEK_END)
|
|
128
|
+
try:
|
|
129
|
+
written = file.write(payload)
|
|
130
|
+
if written != len(payload):
|
|
131
|
+
raise OSError("Incomplete session write")
|
|
132
|
+
file.flush()
|
|
133
|
+
os.fsync(file.fileno())
|
|
134
|
+
self._revision = _Revision.from_stat(os.fstat(file.fileno()))
|
|
135
|
+
except BaseException:
|
|
136
|
+
# A partial tail is not silently truncated or skipped.
|
|
137
|
+
self._failed = True
|
|
138
|
+
raise
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from typing import Annotated, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from ...ai.types import Message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionRecord(BaseModel):
|
|
9
|
+
model_config = ConfigDict(
|
|
10
|
+
extra="forbid",
|
|
11
|
+
strict=True,
|
|
12
|
+
populate_by_name=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionHeader(SessionRecord):
|
|
17
|
+
type: Literal["session"] = "session"
|
|
18
|
+
|
|
19
|
+
version: Literal[1] = 1
|
|
20
|
+
|
|
21
|
+
id: str = Field(min_length=1)
|
|
22
|
+
timestamp: int = Field(ge=0)
|
|
23
|
+
cwd: str = Field(min_length=1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionEntryBase(SessionRecord):
|
|
27
|
+
id: str = Field(min_length=1)
|
|
28
|
+
|
|
29
|
+
parent_id: str | None = Field(
|
|
30
|
+
alias="parentId",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
timestamp: int = Field(ge=0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionMessageEntry(SessionEntryBase):
|
|
37
|
+
type: Literal["message"] = "message"
|
|
38
|
+
|
|
39
|
+
message: Message
|
|
40
|
+
task_start: bool = Field(default=False, alias="taskStart")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CompactionEntry(SessionEntryBase):
|
|
44
|
+
type: Literal["compaction"] = "compaction"
|
|
45
|
+
summary: str = Field(min_length=1)
|
|
46
|
+
retained_entry_ids: list[str] = Field(alias="retainedEntryIds", min_length=1)
|
|
47
|
+
tokens_before: int = Field(alias="tokensBefore", ge=0)
|
|
48
|
+
tokens_after: int = Field(alias="tokensAfter", ge=0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ContextMessageEntry(BaseModel):
|
|
52
|
+
"""A message in the active projection, including a synthetic summary."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
id: str
|
|
56
|
+
message: Message
|
|
57
|
+
task_start: bool = False
|
|
58
|
+
is_summary: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ModelChangeEntry(SessionEntryBase):
|
|
62
|
+
type: Literal["model_change"] = "model_change"
|
|
63
|
+
|
|
64
|
+
provider: str = Field(min_length=1)
|
|
65
|
+
|
|
66
|
+
model_id: str = Field(
|
|
67
|
+
alias="modelId",
|
|
68
|
+
min_length=1,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SessionCursorEntry(SessionRecord):
|
|
73
|
+
"""Persist the selected branch tip without creating a new tree node."""
|
|
74
|
+
|
|
75
|
+
type: Literal["cursor"] = "cursor"
|
|
76
|
+
entry_id: str | None = Field(alias="entryId")
|
|
77
|
+
timestamp: int = Field(ge=0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
SessionEntry = Annotated[
|
|
81
|
+
SessionMessageEntry | ModelChangeEntry | CompactionEntry,
|
|
82
|
+
Field(discriminator="type"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SessionLogEntry = Annotated[
|
|
87
|
+
SessionMessageEntry | ModelChangeEntry | CompactionEntry | SessionCursorEntry,
|
|
88
|
+
Field(discriminator="type"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
SessionFileEntry = Annotated[
|
|
93
|
+
SessionHeader
|
|
94
|
+
| SessionMessageEntry
|
|
95
|
+
| ModelChangeEntry
|
|
96
|
+
| CompactionEntry
|
|
97
|
+
| SessionCursorEntry,
|
|
98
|
+
Field(discriminator="type"),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionContext(BaseModel):
|
|
103
|
+
model_config = ConfigDict(extra="forbid")
|
|
104
|
+
|
|
105
|
+
messages: list[Message]
|
|
106
|
+
|
|
107
|
+
provider: str | None = None
|
|
108
|
+
model_id: str | None = None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .bash import BashTool
|
|
2
|
+
from .edit import EditTool
|
|
3
|
+
from .factory import (
|
|
4
|
+
DEFAULT_CODING_TOOLS,
|
|
5
|
+
READ_ONLY_TOOLS,
|
|
6
|
+
create_all_tools,
|
|
7
|
+
create_coding_tools,
|
|
8
|
+
create_read_only_tools,
|
|
9
|
+
create_tool,
|
|
10
|
+
create_tools,
|
|
11
|
+
)
|
|
12
|
+
from .find import FindTool
|
|
13
|
+
from .grep import GrepTool
|
|
14
|
+
from .ls import LsTool
|
|
15
|
+
from .powershell import PowerShellTool
|
|
16
|
+
from .read import ReadTool
|
|
17
|
+
from .write import WriteTool
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BashTool",
|
|
21
|
+
"DEFAULT_CODING_TOOLS",
|
|
22
|
+
"EditTool",
|
|
23
|
+
"FindTool",
|
|
24
|
+
"GrepTool",
|
|
25
|
+
"LsTool",
|
|
26
|
+
"PowerShellTool",
|
|
27
|
+
"READ_ONLY_TOOLS",
|
|
28
|
+
"ReadTool",
|
|
29
|
+
"WriteTool",
|
|
30
|
+
"create_all_tools",
|
|
31
|
+
"create_coding_tools",
|
|
32
|
+
"create_read_only_tools",
|
|
33
|
+
"create_tool",
|
|
34
|
+
"create_tools",
|
|
35
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import difflib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
6
|
+
|
|
7
|
+
from ...agent.types import ToolExecutionResult
|
|
8
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
9
|
+
from .edit_text import TextEdit, apply_text_edits, normalize_to_lf
|
|
10
|
+
from .mutation import file_mutation
|
|
11
|
+
from .paths import resolve_to_cwd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EditArguments(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
16
|
+
|
|
17
|
+
path: str = Field(min_length=1, description="File to edit, relative or absolute.")
|
|
18
|
+
edits: list[TextEdit] = Field(
|
|
19
|
+
min_length=1,
|
|
20
|
+
description=(
|
|
21
|
+
"Targeted replacements matched against the original file. "
|
|
22
|
+
"Each oldText must be unique. Regions must not overlap; "
|
|
23
|
+
"merge nearby changes into one edit."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EditTool:
|
|
29
|
+
name: str = "edit"
|
|
30
|
+
description: str = (
|
|
31
|
+
"Edit one file using exact text replacement. Each edits[].oldText must "
|
|
32
|
+
"match a unique, non-overlapping region of the original file. "
|
|
33
|
+
"Use multiple edits for separate locations; merge overlapping changes."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def __init__(self, cwd: Path) -> None:
|
|
37
|
+
self._cwd = cwd.resolve()
|
|
38
|
+
|
|
39
|
+
def get_definition(self) -> ToolDefinition:
|
|
40
|
+
return ToolDefinition(
|
|
41
|
+
name=self.name,
|
|
42
|
+
description=self.description,
|
|
43
|
+
parameters=EditArguments.model_json_schema(by_alias=True),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def execute(
|
|
47
|
+
self,
|
|
48
|
+
tool_call_id: str,
|
|
49
|
+
arguments: dict[str, JsonValue],
|
|
50
|
+
) -> ToolExecutionResult:
|
|
51
|
+
args = EditArguments.model_validate(arguments)
|
|
52
|
+
path = resolve_to_cwd(args.path, self._cwd)
|
|
53
|
+
return await asyncio.to_thread(self._edit, path, args)
|
|
54
|
+
|
|
55
|
+
def _edit(self, path: Path, args: EditArguments) -> ToolExecutionResult:
|
|
56
|
+
with file_mutation(path):
|
|
57
|
+
raw = path.read_bytes().decode("utf-8")
|
|
58
|
+
bom = "\ufeff" if raw.startswith("\ufeff") else ""
|
|
59
|
+
text = raw[len(bom):]
|
|
60
|
+
first_newline = text.find("\n")
|
|
61
|
+
ending = (
|
|
62
|
+
"\r\n"
|
|
63
|
+
if first_newline > 0 and text[first_newline - 1] == "\r"
|
|
64
|
+
else "\n"
|
|
65
|
+
)
|
|
66
|
+
original = normalize_to_lf(text)
|
|
67
|
+
updated = apply_text_edits(original, args.edits, args.path)
|
|
68
|
+
|
|
69
|
+
old_lines = original.splitlines(keepends=True)
|
|
70
|
+
new_lines = updated.splitlines(keepends=True)
|
|
71
|
+
patch_parts: list[str] = []
|
|
72
|
+
for line in difflib.unified_diff(
|
|
73
|
+
old_lines, new_lines, fromfile=args.path, tofile=args.path, n=4
|
|
74
|
+
):
|
|
75
|
+
patch_parts.append(line)
|
|
76
|
+
if not line.endswith("\n"):
|
|
77
|
+
patch_parts.append("\n\\n")
|
|
78
|
+
patch = "".join(patch_parts)
|
|
79
|
+
|
|
80
|
+
first_changed_line = 1
|
|
81
|
+
matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
|
82
|
+
for tag, _, _, new_start, _ in matcher.get_opcodes():
|
|
83
|
+
if tag != "equal":
|
|
84
|
+
first_changed_line = new_start + 1
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
# Match and prepare the report before touching the file.
|
|
88
|
+
final = bom + updated.replace("\n", ending)
|
|
89
|
+
path.write_bytes(final.encode("utf-8"))
|
|
90
|
+
|
|
91
|
+
return ToolExecutionResult(
|
|
92
|
+
content=[TextContent(
|
|
93
|
+
text=f"Successfully replaced {len(args.edits)} block(s) in {args.path}."
|
|
94
|
+
)],
|
|
95
|
+
details={
|
|
96
|
+
"path": str(path),
|
|
97
|
+
"diff": patch,
|
|
98
|
+
"patch": patch,
|
|
99
|
+
"firstChangedLine": first_changed_line,
|
|
100
|
+
},
|
|
101
|
+
)
|