mycode-coding-agent 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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Project-scoped persistence; writable handles own a lifecycle OS lock."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
5
|
+
from contextlib import contextmanager, ExitStack
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
from threading import RLock
|
|
12
|
+
from typing import Literal
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
16
|
+
|
|
17
|
+
from mycode.agent.events import AgentToolCall
|
|
18
|
+
from mycode.context.compact import CompactState, DEFAULT_COMPACT_FAILURE_COOLDOWN_MESSAGES
|
|
19
|
+
from mycode.conversation import Conversation
|
|
20
|
+
from mycode.persistence.filesystem import (
|
|
21
|
+
FilesystemStorageError, JsonSnapshotError, append_jsonl_record, prepare_jsonl_for_append,
|
|
22
|
+
read_json_snapshot, read_jsonl_records, write_json_snapshot,
|
|
23
|
+
)
|
|
24
|
+
from mycode.messages import Message
|
|
25
|
+
from mycode.project import ProjectIdentity
|
|
26
|
+
from mycode.persistence.project_storage import ProjectStorage, ProjectStorageError, validate_storage_component
|
|
27
|
+
from mycode.persistence.session_lock import (
|
|
28
|
+
SessionLifecycleLock, SessionLockError, SessionLockTimeoutError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
DEFAULT_SESSION_TITLE = "New session"
|
|
32
|
+
MAX_SESSION_TITLE_CHARS = 200
|
|
33
|
+
MAX_COMPACT_STATE_JSON_CHARS = 20000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionStoreError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionNotFoundError(SessionStoreError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionInUseError(SessionStoreError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionDataError(SessionStoreError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CompactStateDataError(SessionDataError):
|
|
53
|
+
"""Only invalid Compact snapshot data; never session or boundary failures."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Metadata(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
58
|
+
version: Literal[1] = 1
|
|
59
|
+
session_id: str
|
|
60
|
+
title: str = Field(min_length=1, max_length=MAX_SESSION_TITLE_CHARS)
|
|
61
|
+
created_at: datetime
|
|
62
|
+
updated_at: datetime
|
|
63
|
+
last_terminal_state: Literal["closed", "interrupted"] = "interrupted"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _ToolCall(BaseModel):
|
|
67
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
68
|
+
id: str = Field(min_length=1)
|
|
69
|
+
name: str = Field(min_length=1)
|
|
70
|
+
arguments: dict[str, object]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _TranscriptRecord(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
75
|
+
version: Literal[1] = 1
|
|
76
|
+
sequence: int = Field(ge=1)
|
|
77
|
+
role: Literal["user", "assistant", "tool"]
|
|
78
|
+
content: str
|
|
79
|
+
tool_calls: list[_ToolCall] = Field(default_factory=list)
|
|
80
|
+
tool_call_id: str | None = None
|
|
81
|
+
reasoning_content: str | None = None
|
|
82
|
+
reasoning_state: Literal["absent", "present_empty", "present_nonempty"] = "absent"
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="after")
|
|
85
|
+
def validate_message(self) -> "_TranscriptRecord":
|
|
86
|
+
if self.tool_calls and self.role != "assistant":
|
|
87
|
+
raise ValueError("Only assistant messages can contain tool calls.")
|
|
88
|
+
if (self.role == "tool") != bool(self.tool_call_id):
|
|
89
|
+
raise ValueError("Only tool results require tool_call_id.")
|
|
90
|
+
if self.reasoning_state != "absent" and (
|
|
91
|
+
self.role != "assistant" or not self.tool_calls
|
|
92
|
+
):
|
|
93
|
+
raise ValueError("Reasoning requires an assistant tool call.")
|
|
94
|
+
if self.reasoning_state == "present_nonempty":
|
|
95
|
+
if not self.reasoning_content:
|
|
96
|
+
raise ValueError("Nonempty reasoning required.")
|
|
97
|
+
elif self.reasoning_content is not None:
|
|
98
|
+
raise ValueError("Inconsistent reasoning content.")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def message(self) -> Message:
|
|
102
|
+
return Message(
|
|
103
|
+
role=self.role, content=self.content,
|
|
104
|
+
tool_calls=tuple(AgentToolCall(
|
|
105
|
+
id=c.id, name=c.name, arguments=c.arguments,
|
|
106
|
+
) for c in self.tool_calls),
|
|
107
|
+
tool_call_id=self.tool_call_id,
|
|
108
|
+
reasoning_content=self.reasoning_content,
|
|
109
|
+
reasoning_state=self.reasoning_state,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class SessionRecord:
|
|
115
|
+
id: str
|
|
116
|
+
project_key: str
|
|
117
|
+
workspace_root: Path
|
|
118
|
+
title: str
|
|
119
|
+
status: str
|
|
120
|
+
created_at: datetime
|
|
121
|
+
updated_at: datetime
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class CompactStateLoadResult:
|
|
126
|
+
state: CompactState
|
|
127
|
+
recovered_invalid_state: bool = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def _storage_errors() -> Iterator[None]:
|
|
132
|
+
try:
|
|
133
|
+
yield
|
|
134
|
+
except SessionLockTimeoutError as error:
|
|
135
|
+
raise SessionInUseError("Session is in use by another owner.") from error
|
|
136
|
+
except (FilesystemStorageError, ProjectStorageError, ValidationError) as error:
|
|
137
|
+
raise SessionDataError("Invalid session filesystem data.") from error
|
|
138
|
+
except (OSError, SessionLockError) as error:
|
|
139
|
+
raise SessionStoreError("Session filesystem operation failed.") from error
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SessionStore:
|
|
143
|
+
def __init__(
|
|
144
|
+
self, projects_root: str | Path | None = None, *,
|
|
145
|
+
now: Callable[[], datetime] | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
self.projects_root = projects_root
|
|
148
|
+
self._now = now or (lambda: datetime.now(timezone.utc))
|
|
149
|
+
|
|
150
|
+
def project_storage(self, project: ProjectIdentity) -> ProjectStorage:
|
|
151
|
+
with _storage_errors():
|
|
152
|
+
return ProjectStorage.open(project, projects_root=self.projects_root)
|
|
153
|
+
|
|
154
|
+
def _metadata(self, storage: ProjectStorage, session_id: str) -> _Metadata:
|
|
155
|
+
layout = storage.session(session_id)
|
|
156
|
+
if not layout.root.exists():
|
|
157
|
+
raise SessionNotFoundError(f"Session not found: {session_id}")
|
|
158
|
+
# lstat distinguishes an absent commit marker from a dangling link.
|
|
159
|
+
try:
|
|
160
|
+
layout.meta_path.lstat()
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
raise SessionNotFoundError(f"Session not committed: {session_id}") from None
|
|
163
|
+
data = read_json_snapshot(storage.project_directory, layout.meta_path)
|
|
164
|
+
meta = _Metadata.model_validate_json(json.dumps(data))
|
|
165
|
+
if meta.session_id != session_id:
|
|
166
|
+
raise SessionDataError("Session metadata identity mismatch.")
|
|
167
|
+
if any(t.tzinfo is None for t in (meta.created_at, meta.updated_at)):
|
|
168
|
+
raise SessionDataError("Session timestamps require timezone.")
|
|
169
|
+
return meta
|
|
170
|
+
|
|
171
|
+
def _record(self, project: ProjectIdentity, meta: _Metadata) -> SessionRecord:
|
|
172
|
+
return SessionRecord(
|
|
173
|
+
id=meta.session_id, project_key=project.key,
|
|
174
|
+
workspace_root=project.workspace_root, title=meta.title,
|
|
175
|
+
status=meta.last_terminal_state,
|
|
176
|
+
created_at=meta.created_at, updated_at=meta.updated_at,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def get_session(self, project: ProjectIdentity, session_id: str) -> SessionRecord | None:
|
|
180
|
+
with _storage_errors():
|
|
181
|
+
storage = self.project_storage(project)
|
|
182
|
+
try:
|
|
183
|
+
meta = self._metadata(storage, session_id)
|
|
184
|
+
except SessionNotFoundError:
|
|
185
|
+
return None
|
|
186
|
+
record = self._record(project, meta)
|
|
187
|
+
try:
|
|
188
|
+
with SessionLifecycleLock(
|
|
189
|
+
storage.session(session_id).lock_path, timeout_seconds=0,
|
|
190
|
+
).acquire():
|
|
191
|
+
pass
|
|
192
|
+
except SessionLockTimeoutError:
|
|
193
|
+
record = replace(record, status="active")
|
|
194
|
+
return record
|
|
195
|
+
|
|
196
|
+
def list_sessions(self, project: ProjectIdentity, *, limit: int = 10) -> list[SessionRecord]:
|
|
197
|
+
if limit < 1:
|
|
198
|
+
raise ValueError("limit must be positive.")
|
|
199
|
+
with _storage_errors():
|
|
200
|
+
storage = self.project_storage(project)
|
|
201
|
+
records = []
|
|
202
|
+
for path in storage.sessions_directory.iterdir():
|
|
203
|
+
try:
|
|
204
|
+
validate_storage_component(path.name, field_name="session_id")
|
|
205
|
+
except ProjectStorageError:
|
|
206
|
+
continue
|
|
207
|
+
if not path.is_dir() and not path.is_symlink():
|
|
208
|
+
continue
|
|
209
|
+
record = self.get_session(project, path.name)
|
|
210
|
+
if record is not None:
|
|
211
|
+
records.append(record)
|
|
212
|
+
return sorted(records, key=lambda r: (r.updated_at, r.id), reverse=True)[:limit]
|
|
213
|
+
|
|
214
|
+
@contextmanager
|
|
215
|
+
def open_session(
|
|
216
|
+
self, project: ProjectIdentity, session_id: str | None = None, *,
|
|
217
|
+
create: bool = False, title: str = DEFAULT_SESSION_TITLE,
|
|
218
|
+
) -> Iterator["WritableSession"]:
|
|
219
|
+
if session_id is None and not create:
|
|
220
|
+
raise SessionNotFoundError("Session id is required for resume.")
|
|
221
|
+
identifier = str(uuid4()) if session_id is None else session_id
|
|
222
|
+
with ExitStack() as ownership:
|
|
223
|
+
with _storage_errors():
|
|
224
|
+
storage = self.project_storage(project)
|
|
225
|
+
layout = storage.session(identifier)
|
|
226
|
+
ownership.enter_context(SessionLifecycleLock(
|
|
227
|
+
layout.lock_path, timeout_seconds=0,
|
|
228
|
+
).acquire())
|
|
229
|
+
if create:
|
|
230
|
+
if layout.root.exists():
|
|
231
|
+
try:
|
|
232
|
+
self._metadata(storage, identifier)
|
|
233
|
+
except SessionNotFoundError:
|
|
234
|
+
_validate_deletion_tree(layout.root)
|
|
235
|
+
shutil.rmtree(layout.root)
|
|
236
|
+
else:
|
|
237
|
+
raise SessionDataError("Session already exists.")
|
|
238
|
+
meta = _Metadata(
|
|
239
|
+
session_id=identifier, title=_title(title),
|
|
240
|
+
created_at=self._now(), updated_at=self._now(),
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
layout = storage.session(identifier, create=True)
|
|
244
|
+
layout.transcript_path.touch(exist_ok=False)
|
|
245
|
+
# Publish last: all prior data is an invisible orphan until this succeeds.
|
|
246
|
+
write_json_snapshot(
|
|
247
|
+
storage.project_directory, layout.meta_path, meta.model_dump(mode="json"),
|
|
248
|
+
)
|
|
249
|
+
except BaseException:
|
|
250
|
+
try:
|
|
251
|
+
if not layout.meta_path.exists() and layout.root.exists():
|
|
252
|
+
_validate_deletion_tree(layout.root)
|
|
253
|
+
shutil.rmtree(layout.root)
|
|
254
|
+
except (OSError, SessionDataError):
|
|
255
|
+
pass # An uncommitted orphan remains invisible and can be retried.
|
|
256
|
+
raise
|
|
257
|
+
else:
|
|
258
|
+
meta = self._metadata(storage, identifier)
|
|
259
|
+
records = prepare_jsonl_for_append(
|
|
260
|
+
storage.project_directory, layout.transcript_path,
|
|
261
|
+
)
|
|
262
|
+
messages = _messages(records)
|
|
263
|
+
writer = WritableSession(self, storage, meta, messages)
|
|
264
|
+
del records, messages
|
|
265
|
+
try:
|
|
266
|
+
yield writer
|
|
267
|
+
finally:
|
|
268
|
+
with writer._mutex:
|
|
269
|
+
writer._closed = True
|
|
270
|
+
|
|
271
|
+
def create_session(
|
|
272
|
+
self, project: ProjectIdentity, *, session_id: str | None = None,
|
|
273
|
+
title: str = DEFAULT_SESSION_TITLE,
|
|
274
|
+
) -> SessionRecord:
|
|
275
|
+
with self.open_session(project, session_id, create=True, title=title) as session:
|
|
276
|
+
return session.record
|
|
277
|
+
|
|
278
|
+
def load_conversation(self, project: ProjectIdentity, session_id: str) -> Conversation:
|
|
279
|
+
with _storage_errors():
|
|
280
|
+
storage = self.project_storage(project)
|
|
281
|
+
self._metadata(storage, session_id)
|
|
282
|
+
records = read_jsonl_records(
|
|
283
|
+
storage.project_directory, storage.session(session_id).transcript_path,
|
|
284
|
+
trailing_record="ignore",
|
|
285
|
+
)
|
|
286
|
+
return Conversation.from_messages(_messages(records))
|
|
287
|
+
|
|
288
|
+
def append_message(self, project: ProjectIdentity, session_id: str, message: Message) -> None:
|
|
289
|
+
self.append_messages(project, session_id, [message])
|
|
290
|
+
|
|
291
|
+
def append_messages(
|
|
292
|
+
self, project: ProjectIdentity, session_id: str, messages: Iterable[Message],
|
|
293
|
+
) -> None:
|
|
294
|
+
with self.open_session(project, session_id) as session:
|
|
295
|
+
session.append_messages(messages)
|
|
296
|
+
|
|
297
|
+
def rename_session(self, project: ProjectIdentity, session_id: str, title: str) -> SessionRecord:
|
|
298
|
+
with self.open_session(project, session_id) as session:
|
|
299
|
+
return session.rename(title)
|
|
300
|
+
|
|
301
|
+
def load_compact_state(self, project: ProjectIdentity, session_id: str) -> CompactState:
|
|
302
|
+
with _storage_errors():
|
|
303
|
+
storage = self.project_storage(project)
|
|
304
|
+
self._metadata(storage, session_id)
|
|
305
|
+
message_count = len(self.load_conversation(project, session_id).get_messages())
|
|
306
|
+
return _load_compact_snapshot(storage, session_id, message_count)
|
|
307
|
+
|
|
308
|
+
def save_compact_state(self, project: ProjectIdentity, session_id: str, state: CompactState) -> None:
|
|
309
|
+
with self.open_session(project, session_id) as session:
|
|
310
|
+
session.save_compact_state(state)
|
|
311
|
+
|
|
312
|
+
def delete_session(self, project: ProjectIdentity, session_id: str) -> bool:
|
|
313
|
+
with _storage_errors():
|
|
314
|
+
storage = self.project_storage(project)
|
|
315
|
+
layout = storage.session(session_id)
|
|
316
|
+
with SessionLifecycleLock(layout.lock_path, timeout_seconds=0).acquire():
|
|
317
|
+
if not layout.root.exists():
|
|
318
|
+
return False
|
|
319
|
+
_validate_deletion_tree(layout.root)
|
|
320
|
+
# Revoke logical existence before any partial physical cleanup.
|
|
321
|
+
layout.meta_path.unlink(missing_ok=True)
|
|
322
|
+
shutil.rmtree(layout.root)
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class WritableSession:
|
|
327
|
+
"""Scoped write capability; all methods reject use after the context exits."""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self, store: SessionStore, storage: ProjectStorage,
|
|
331
|
+
metadata: _Metadata, messages: list[Message],
|
|
332
|
+
) -> None:
|
|
333
|
+
self.store = store
|
|
334
|
+
self.storage = storage
|
|
335
|
+
self.layout = storage.session(metadata.session_id)
|
|
336
|
+
self._meta = metadata
|
|
337
|
+
self._messages = list(messages)
|
|
338
|
+
self._closed = False
|
|
339
|
+
self._mutex = RLock()
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def record(self) -> SessionRecord:
|
|
343
|
+
return self.store._record(self.storage.identity, self._meta)
|
|
344
|
+
|
|
345
|
+
def _check_open(self) -> None:
|
|
346
|
+
if self._closed:
|
|
347
|
+
raise SessionStoreError("Session writer is closed.")
|
|
348
|
+
self.storage.session(self.record.id)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def _message_count(self) -> int:
|
|
352
|
+
return len(self._messages)
|
|
353
|
+
|
|
354
|
+
def load_history(self) -> Conversation:
|
|
355
|
+
with self._mutex, _storage_errors():
|
|
356
|
+
self._check_open()
|
|
357
|
+
return Conversation.from_messages(self._messages)
|
|
358
|
+
|
|
359
|
+
def _save_metadata(self) -> None:
|
|
360
|
+
self._meta.updated_at = self.store._now()
|
|
361
|
+
write_json_snapshot(
|
|
362
|
+
self.storage.project_directory, self.layout.meta_path, self._meta.model_dump(mode="json"),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def append_messages(self, messages: Iterable[Message]) -> None:
|
|
366
|
+
with self._mutex, _storage_errors():
|
|
367
|
+
self._check_open()
|
|
368
|
+
prepared = [
|
|
369
|
+
_transcript(message, self._message_count + i + 1)
|
|
370
|
+
for i, message in enumerate(messages)
|
|
371
|
+
]
|
|
372
|
+
for record in prepared:
|
|
373
|
+
message = _TranscriptRecord.model_validate(record).message()
|
|
374
|
+
append_jsonl_record(self.storage.project_directory, self.layout.transcript_path, record)
|
|
375
|
+
self._messages.append(message)
|
|
376
|
+
if prepared:
|
|
377
|
+
self._save_metadata()
|
|
378
|
+
|
|
379
|
+
def rename(self, title: str) -> SessionRecord:
|
|
380
|
+
with self._mutex, _storage_errors():
|
|
381
|
+
self._check_open()
|
|
382
|
+
self._meta.title = _title(title)
|
|
383
|
+
self._save_metadata()
|
|
384
|
+
return self.record
|
|
385
|
+
|
|
386
|
+
def finish(self, status: Literal["closed", "interrupted"]) -> SessionRecord:
|
|
387
|
+
with self._mutex, _storage_errors():
|
|
388
|
+
self._check_open()
|
|
389
|
+
self._meta.last_terminal_state = status
|
|
390
|
+
self._save_metadata()
|
|
391
|
+
return self.record
|
|
392
|
+
|
|
393
|
+
def save_compact_state(self, state: CompactState) -> None:
|
|
394
|
+
with self._mutex, _storage_errors():
|
|
395
|
+
self._check_open()
|
|
396
|
+
_validate_compact(state, self._message_count)
|
|
397
|
+
write_json_snapshot(
|
|
398
|
+
self.storage.project_directory, self.layout.compact_path, state.model_dump(mode="json"),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def load_or_reset_compact_state(self) -> CompactStateLoadResult:
|
|
402
|
+
with self._mutex, _storage_errors():
|
|
403
|
+
self._check_open()
|
|
404
|
+
storage = self.store.project_storage(self.storage.identity)
|
|
405
|
+
self.store._metadata(storage, self.record.id)
|
|
406
|
+
try:
|
|
407
|
+
state = _load_compact_snapshot(storage, self.record.id, self._message_count)
|
|
408
|
+
return CompactStateLoadResult(state)
|
|
409
|
+
except CompactStateDataError:
|
|
410
|
+
state = CompactState(
|
|
411
|
+
consecutive_failure_count=1,
|
|
412
|
+
retry_after_message_count=self._message_count + DEFAULT_COMPACT_FAILURE_COOLDOWN_MESSAGES,
|
|
413
|
+
last_failure_reason="stored_compact_state_invalid",
|
|
414
|
+
)
|
|
415
|
+
self.save_compact_state(state)
|
|
416
|
+
return CompactStateLoadResult(state, True)
|
|
417
|
+
|
|
418
|
+
def append_subagent_event(self, run_id: str, event: dict[str, object]) -> None:
|
|
419
|
+
with self._mutex, _storage_errors():
|
|
420
|
+
self._check_open()
|
|
421
|
+
if event.get("type") not in {"state", "snapshot", "tool_audit", "result"}:
|
|
422
|
+
raise SessionDataError("Unsupported SubAgent event.")
|
|
423
|
+
append_jsonl_record(
|
|
424
|
+
self.storage.project_directory, self.layout.subagent_log_path(run_id),
|
|
425
|
+
{**event, "version": 1},
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _title(value: str) -> str:
|
|
430
|
+
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 200:
|
|
431
|
+
raise SessionDataError("Invalid session title.")
|
|
432
|
+
return value.strip()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _transcript(message: Message, sequence: int) -> dict[str, object]:
|
|
436
|
+
record = _TranscriptRecord(
|
|
437
|
+
sequence=sequence, role=message.role, content=message.content,
|
|
438
|
+
tool_calls=[_ToolCall(id=c.id, name=c.name, arguments=c.arguments) for c in message.tool_calls],
|
|
439
|
+
tool_call_id=message.tool_call_id, reasoning_content=message.reasoning_content,
|
|
440
|
+
reasoning_state=message.reasoning_state,
|
|
441
|
+
)
|
|
442
|
+
payload = record.model_dump(mode="json")
|
|
443
|
+
try:
|
|
444
|
+
json.dumps(payload, allow_nan=False)
|
|
445
|
+
except ValueError as error:
|
|
446
|
+
raise SessionDataError("Tool arguments must be finite JSON.") from error
|
|
447
|
+
return payload
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _messages(records: list[dict[str, object]]) -> list[Message]:
|
|
451
|
+
messages = []
|
|
452
|
+
for sequence, raw in enumerate(records, 1):
|
|
453
|
+
try:
|
|
454
|
+
json.dumps(raw, allow_nan=False)
|
|
455
|
+
except ValueError as error:
|
|
456
|
+
raise SessionDataError("Transcript contains nonfinite JSON.") from error
|
|
457
|
+
record = _TranscriptRecord.model_validate(raw)
|
|
458
|
+
if record.sequence != sequence:
|
|
459
|
+
raise SessionDataError("Transcript sequence is not contiguous.")
|
|
460
|
+
messages.append(record.message())
|
|
461
|
+
return messages
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _load_compact_snapshot(
|
|
465
|
+
storage: ProjectStorage, session_id: str, message_count: int,
|
|
466
|
+
) -> CompactState:
|
|
467
|
+
path = storage.session(session_id).compact_path
|
|
468
|
+
try:
|
|
469
|
+
path.lstat()
|
|
470
|
+
except FileNotFoundError:
|
|
471
|
+
return CompactState()
|
|
472
|
+
try:
|
|
473
|
+
payload = read_json_snapshot(storage.project_directory, path)
|
|
474
|
+
except JsonSnapshotError as error:
|
|
475
|
+
if isinstance(error.__cause__, OSError):
|
|
476
|
+
raise # I/O failure is not evidence of corrupt Compact data.
|
|
477
|
+
raise CompactStateDataError("Invalid Compact JSON snapshot.") from error
|
|
478
|
+
try:
|
|
479
|
+
state = CompactState.model_validate(payload)
|
|
480
|
+
except ValidationError as error:
|
|
481
|
+
raise CompactStateDataError("Invalid Compact schema.") from error
|
|
482
|
+
_validate_compact(state, message_count)
|
|
483
|
+
return state
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _validate_compact(state: CompactState, message_count: int) -> None:
|
|
487
|
+
if len(state.model_dump_json()) > MAX_COMPACT_STATE_JSON_CHARS:
|
|
488
|
+
raise CompactStateDataError("Session Compact state exceeds size limit.")
|
|
489
|
+
if state.boundary and state.boundary.covered_message_count > message_count:
|
|
490
|
+
raise CompactStateDataError("Compact boundary exceeds persisted history.")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _validate_deletion_tree(path: Path) -> None:
|
|
494
|
+
import stat
|
|
495
|
+
|
|
496
|
+
info = path.lstat()
|
|
497
|
+
if path.is_symlink() or getattr(info, "st_file_attributes", 0) & getattr(
|
|
498
|
+
stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0,
|
|
499
|
+
):
|
|
500
|
+
raise SessionDataError("Session deletion refuses links/reparse points.")
|
|
501
|
+
if path.is_dir():
|
|
502
|
+
for child in path.iterdir():
|
|
503
|
+
_validate_deletion_tree(child)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Presentation adapters for interactive frontends."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""CLI presentation, confirmation, and SubAgent observation adapters."""
|
|
2
|
+
|
|
3
|
+
from mycode.presentation.cli.confirmer import TerminalConfirmer
|
|
4
|
+
from mycode.presentation.cli.mcp_trust import TerminalMCPTrustConfirmer
|
|
5
|
+
from mycode.presentation.cli.presenter import CliDisplayMode, CliPresenter
|
|
6
|
+
from mycode.presentation.cli.subagent_observer import CliSubAgentObserver
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CliDisplayMode",
|
|
10
|
+
"CliPresenter",
|
|
11
|
+
"CliSubAgentObserver",
|
|
12
|
+
"TerminalConfirmer",
|
|
13
|
+
"TerminalMCPTrustConfirmer",
|
|
14
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from mycode.permissions import ApprovalScope, ConfirmationRequest, ConfirmationResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TerminalConfirmer:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
input_func: Callable[[str], str] = input,
|
|
10
|
+
output_func: Callable[[str], None] = print,
|
|
11
|
+
) -> None:
|
|
12
|
+
self.input_func = input_func
|
|
13
|
+
self.output_func = output_func
|
|
14
|
+
|
|
15
|
+
def confirm(self, request: ConfirmationRequest) -> ConfirmationResult:
|
|
16
|
+
permission_request = request.permission_request
|
|
17
|
+
permission_decision = request.permission_decision
|
|
18
|
+
|
|
19
|
+
self.output_func(
|
|
20
|
+
f"permission> {permission_request.tool_name} 需要确认"
|
|
21
|
+
)
|
|
22
|
+
if permission_request.target is not None:
|
|
23
|
+
self.output_func(f"target> {permission_request.target}")
|
|
24
|
+
self._output_metadata("resolved_path", request)
|
|
25
|
+
self._output_metadata("workspace_root", request)
|
|
26
|
+
self._output_metadata("path_scope", request)
|
|
27
|
+
self._output_metadata("pattern_scope", request)
|
|
28
|
+
self._output_metadata("command_display", request)
|
|
29
|
+
self._output_metadata("resolved_cwd", request)
|
|
30
|
+
self._output_metadata("cwd_scope", request)
|
|
31
|
+
self._output_metadata("command_risk_category", request)
|
|
32
|
+
self._output_metadata("command_risk", request)
|
|
33
|
+
self._output_metadata("command_risk_reason", request)
|
|
34
|
+
self._output_metadata("memory_scope", request)
|
|
35
|
+
self._output_metadata("memory_kind", request)
|
|
36
|
+
self._output_metadata("memory_key", request)
|
|
37
|
+
self._output_metadata("memory_content", request)
|
|
38
|
+
self._output_metadata("memory_path", request)
|
|
39
|
+
self._output_metadata("skill_name", request)
|
|
40
|
+
self._output_metadata("skill_source", request)
|
|
41
|
+
self._output_metadata("script", request)
|
|
42
|
+
self._output_metadata("arguments", request)
|
|
43
|
+
self._output_metadata("cwd", request)
|
|
44
|
+
self.output_func(f"reason> {permission_decision.reason}")
|
|
45
|
+
if request.prompt:
|
|
46
|
+
self.output_func(
|
|
47
|
+
f"message> {_localize_confirmation_message(request.prompt)}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
answer = self.input_func(
|
|
52
|
+
"是否批准?[y/yes 本次 | t/task 当前任务 | s/session 当前会话 | N 拒绝] "
|
|
53
|
+
).strip().lower()
|
|
54
|
+
except EOFError:
|
|
55
|
+
return ConfirmationResult.rejected(
|
|
56
|
+
message="Permission confirmation unavailable.",
|
|
57
|
+
metadata={"input": "eof"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
scopes: dict[str, ApprovalScope] = {
|
|
61
|
+
"y": "once", "yes": "once", "t": "task", "task": "task",
|
|
62
|
+
"s": "session", "session": "session",
|
|
63
|
+
}
|
|
64
|
+
if answer in scopes:
|
|
65
|
+
return ConfirmationResult.approved(
|
|
66
|
+
scope=scopes[answer],
|
|
67
|
+
message="Permission confirmation approved.",
|
|
68
|
+
metadata={"input": answer},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return ConfirmationResult.rejected(
|
|
72
|
+
message="Permission confirmation rejected.",
|
|
73
|
+
metadata={"input": answer},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _output_metadata(
|
|
77
|
+
self,
|
|
78
|
+
key: str,
|
|
79
|
+
request: ConfirmationRequest,
|
|
80
|
+
) -> None:
|
|
81
|
+
value = _metadata_value(request, key)
|
|
82
|
+
if value is not None:
|
|
83
|
+
if key == "memory_content":
|
|
84
|
+
self.output_func(f"{key}> {value!r}")
|
|
85
|
+
return
|
|
86
|
+
self.output_func(f"{key}> {value}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _metadata_value(
|
|
90
|
+
request: ConfirmationRequest,
|
|
91
|
+
key: str,
|
|
92
|
+
) -> object | None:
|
|
93
|
+
if key in request.metadata:
|
|
94
|
+
return request.metadata[key]
|
|
95
|
+
|
|
96
|
+
return request.permission_decision.metadata.get(key)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _localize_confirmation_message(message: str) -> str:
|
|
100
|
+
exact_translations = {
|
|
101
|
+
"Sensitive path requires confirmation.": "敏感路径需要确认。",
|
|
102
|
+
}
|
|
103
|
+
if message in exact_translations:
|
|
104
|
+
return exact_translations[message]
|
|
105
|
+
translations = {
|
|
106
|
+
"Path outside workspace requires confirmation: ": "工作区外路径需要确认:",
|
|
107
|
+
"Sensitive path requires confirmation: ": "敏感路径需要确认:",
|
|
108
|
+
"Ignored path requires confirmation: ": "忽略路径需要确认:",
|
|
109
|
+
"Sensitive path pattern requires confirmation: ": "敏感路径模式需要确认:",
|
|
110
|
+
"Write operation requires confirmation: ": "写操作需要确认:",
|
|
111
|
+
"Command operation requires confirmation: ": "命令操作需要确认:",
|
|
112
|
+
}
|
|
113
|
+
for prefix, translated in translations.items():
|
|
114
|
+
if message.startswith(prefix):
|
|
115
|
+
return translated + message[len(prefix) :]
|
|
116
|
+
return message
|